虚拟DOM+diff算法解读

为何采用虚拟DOM

尤雨溪曾在知乎正面的回答这个问题:

  1. 为函数式的 UI 编程方式打开了大门;
  2. 可以渲染到 DOM 以外的 backend,比如 ReactNative。

针对这两点谈谈自己的理解:

  1. 为函数式的 UI 编程方式打开了大门;

    • 虚拟DOM是由react引入的概念,react的核心理念就是函数式编程,类似ui = f (a),其中a是数据,函数f则是react的render,每个a的改动,都会重新生成新的ui
    • 每次生成新的ui就需要重新刷新页面代价太过昂贵,虚拟DOM以及diff算法的引入可以最大限度的复用旧的DOM,使得渲染性能大幅提升。
  2. 可以渲染到 DOM 以外的 backend,比如 ReactNative。

    • 有了虚拟DOM就可以轻松实现跨平台,多平台的core都相同,只是在render到具体平台的时候采取不同的render就好了

真实DOM的操作

diff算法最后的结果还是要落到对真实DOM的操作上去,这种操作有三种:新增、删除、移位。这三种操作都需要获取自身DOMparentDOM,以vue的源码来解释:

  • 新增、移位操作可以通过如下实现
export function removeChild (node: Node, child: Node) {
  node.removeChild(child)
}

export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}
  • 移位操作则通过insert实现
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

diff算法

diff算法我认为包括三部分:虚拟DOM树的遍历、parent节点下的children的比较、diff完成之后对真实DOM的操作时机

虚拟DOM的遍历:

虚拟DOM说到底只是一颗树形结构,对于树的遍历我们知道有深度遍历和广度遍历

深度遍历需要栈结构,可以通过递归(内核维护调用栈)的方式实现,也可以采用人为构造栈,然后循环栈完成深度遍历。通常深度优先搜索法不全部保留结点,扩展完的结点从栈中弹出删去,这样,在栈中存储的结点数就是深度值,因此它占用空间较少

广度遍历则采用队列的方式实现,由于广度优先是按照树的层级来遍历的,在遍历某层的时候需要将下一层的数据推进队列里面,所以队列的长度通常会比树的宽度还要宽。

目前,不管是vue还是react,采用的都是深度遍历算法

vue2.0

vue的渲染主要分三部分:

  • 虚拟树的遍历
  • 子节点的diff
  • 真实DOM的更新

虚拟树的遍历:
采用递归的先序深度遍历算法

子节点的diff:
对于相同的节点,继续比较子节点:

  • 同一级子元素新老虚拟DOM列表分别设置startIndexendIndex,并交叉判断startIndexendIndex是否是相同元素
  • 对比的结果有三种情况:新增、删除、移位
  • 新增:老的startIndex不动,新的startIndex移位,并在老的startIndex元素前插入
  • 移位:
    • startIndex和老startIndex或者新endIndex和老endIndex相同,只要移动startIndex或者endIndex就可以了;
    • startIndex和老endIndex相同,新startIndex++,老endIndex--,将老endIndexele插入到老startIndexele前面
    • endIndex和老startIndex相同,新endIndex--,老startIndex++,将老的startIndexele插入到老endIndexele后面
    • startIndexkey匹配到老的vnodekey,将老vnodeele插入到老startIndexele前面,还有一个操作:将老vnode标记位undefined,(oldCh[idxInOld] = undefined),
  • 删除
    • 等新startIndex和新endIndex合拢,老startIndex和老endIndex之间的非undefinedvnodeele全部删除,undefinednode代表已经处理过了(移位)

真实DOM的更新

  • 由于采用递归的方式处理vnode,所以节点更新真实DOM的时机是该节点下所有子节点更新完毕后才会更新,即从下而上
  • 节点的props的处理是早于子节点的diff,所以props的更新是从上而下

vue2.0思维导图

react16

react的渲染虽然采用深度遍历,但是是非递归方式,而是采用链表的方式,这样做的原因是方便fiber的引入

react的渲染可以分为两个部分:

  • reconciliation 阶段
  • commit 阶段

reconciliation 阶段:

react树的reconciliation

所谓的reconciliation阶段就是虚拟DOM的diff阶段,由于采用了递归的链表结构,所以每个节点必然经历两次的遍历,这两次的遍历分别为:beginWorkcompleteUnitOfWork

  • beginWork:完成对子节点的diff过程(新增,删除,移位),并给相应的vnode打上effectTag,返回第一个子节点。

通过唯一 key 可以判断新老集合中是否存在相同的节点,if (prevChild === nextChild),如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

  • completeUnitOfWork:完成对当前节点的副作用的收集(主要的props的改动),并将所有需要改动的节点串成一个链表effect-list,挂到hostRoot节点上,返回兄弟节点或者是父节点。

reconciliation阶段的最终结果是产生一个effect-list列表,这个effect-list列表里面的节点的两个属性标明接下来的commit阶段需要对该节点进行的处理:effectTag以及updateQueueeffectTag表明对节点位置的改动,updateQueue表明对节点状态的改动

commit 阶段
commit阶段主要是循环effect-list来对节点分别处理,并且对effect-list进行了三次循环

  • 第一遍:执行所有的(effectTag & Snapshot)为true的节点的commitBeforeMutationLifeCycles,即执行周期函数getSnapshotBeforeUpdate

  • 第二遍:为所有(effectTag & (Placement | Update | Deletion))即移位、更新、删除的节点进行commitPlacement以及commitWorkcommitPlacement做的就是移位和删除的动作,commitWork则将节点的updateQueue拿出来更新

  • 第三遍:为所有的(effectTag & (Update | Callback))的节点进行commitLifeCycles,即执行周期函数componentDidUpdate

react16思维导图

fiber节点参考:

const fiber = {
  // Instance
  tag = tag; // fiber 的类型。 
    - IndeterminateComponent
    - FunctionalComponent
    - ClassComponent // Menu, Table
    - HostRoot // ReactDOM.render 的第二个参数
    - HostPortal
    - HostComponent // div, span
    - HostText // 纯文本节点,即 dom  的 nodeName 等于 '#text'
    - CallComponent // 对应 call return 中的 call
    - CallHandlerPhase // call 中的 handler 阶段
    - ReturnComponent // 对应 call return 中的 return
    - Fragment 
    - Mode // AsyncMode || StrictMode
    - ContextConsumer
    - ContextProvider
    - ForwardRef
  key = key; // fiber 的唯一标识
  type = null; // 与 react element 里的 type 一致
  stateNode = null; // 对应组件或者 dom 的实例

  // Fiber
  return = null; // 等价于栈帧中的函数调用后的返回地址,这里即是父 fiber
  child = null; // 即组件的 render 的返回值,是一个单链表(因为返回值不一定是一个单一的元素)
  sibling = null; // 单链表
  index = 0;

  ref = null;

  // props 等价于一个函数的 arguments
  pendingProps = pendingProps; // 新的 props(要么是当前的 props,要么是 wip 的 props),默认就等于 element.props,对于 Fragment 和 Portal,则等于 props.children
  memoizedProps = null; // 旧的 props,等于 wipFiber.pendingProps 或者 wipFiber.pendingProps.children
    - 一般 oldProps = workInProgress.memoizedProps
    - 一般 newProps = workInProgress.pendingProps
  updateQueue = null; // 状态更新和回调的函数队列
  memoizedState = null; // 组件实例的 state

  mode = mode; // 用于描述处理 fiber 和它的子树的方式,创建后就不应被改变,如未指定则从父 fiber 继承。NoContext || AsyncMode || StrictMode

  // Effects
  effectTag = NoEffect; // 当需要变化的时候,具体需要进行的操作的类型
    - NoEffect // 初始值
    - PerformedWork // 开始处理后置为 PerformedWork
    - Placement // 插入,保持原位,移动 dom 节点
    - Update // 对 dom 结构的改变。mount 或者 update 后置为 Update
    - PlacementAndUpdate
    - Deletion
    - ContentReset // 将一个只包含字符串的 dom 节点,或者 textarea,或者 dangerouslySetInnerHTML 替换成其它类型的节点
    - Callback // setState 的回调
    - DidCapture // 渲染出错,准备捕获出错信息
    - Ref // 准备执行 ref 回调
    - ErrLog // 渲染出错,执行 componentDidCatch
    - Snapshot // getSnapshotBeforeUpdate
    - HostEffectMask // 暂时没用
    - Incomplete // 任何造成 fiber 的工作无法完成的情况
    - ShouldCapture //  需要处理错误
    
  nextEffect = null; // 下一个需要处理的有副作用的 fiber

  // 本 fiber 的子树中有副作用的第一个和最后一个 fiber
  firstEffect = null; // 
  lastEffect = null; //

  expirationTime = NoWork; // 将来的某个时间点,在那之前必须完成所有工作

  alternate = null; // WIP 树里面的 fiber,如果不在更新期间,那么就等于当前的 fiber,如果是新创建的节点,那么就没有 alternate
  
  // dev mode
  _debugID; // 自增的标识每一个 fiber 的 id
  _debugSource = null; // 文件名和行数,与 React Element 的 source 一致
  _debugOwner = null; // 与 React Element 的 owner 一致
  _debugIsCurrentlyTiming = false;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容