虚拟DOM+diff算法解读

2018-07-08  本文已影响102人  yiludege

为何采用虚拟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)
}
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列表分别设置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的更新

vue2.0思维导图

react16

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

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

reconciliation 阶段:

react树的reconciliation

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

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

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

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

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;
}
上一篇下一篇

猜你喜欢

热点阅读