探索Virtual DOM的前世此生

做者:百度外卖  程亚杰  李显  卢培鹏
转载请标明出处
复制代码

缘起

在前端开发过程当中,对性能产生最大影响的因素莫过于DOM的重排重绘了,React做为前端框架领跑者,为了有效解决DOM更新开销的问题,采用了Virtual DOM的思路,不只提高了DOM操做的效率,更推进了数据驱动式组件开发的造成与完善。一旦习惯了数据驱动式开发,再要求咱们使用显式DOM操做开发的话,虐心程度无异于春节返乡的车票卖完了,只能坐长途展转煎熬了。前端

而VirtualDOM的主要思想就是模拟DOM的树状结构,在内存中建立保存映射DOM信息的节点数据,在因为交互等因素须要视图更新时,先经过对节点数据进行diff后获得差别结果后,再一次性对DOM进行批量更新操做,这就比如在内存中建立了一个平行世界,浏览器中DOM树的每个节点与属性数据都在这个平行世界中存在着另外一个版本的虚拟DOM树,全部复杂曲折的更新逻辑都在平行世界中的VirtualDOM处理完成,只将最终的更新结果发送给浏览器中的DOM树执行,这样就避免了冗余琐碎的DOM树操做负担,进而有效提升了性能。vue

若是你已是熟练使用vue或者react的项目老手,本文将助你一探这些前端框架进行视图更新背后的工做原理,而且能够必定程度掌握VirtualDOM的核心算法,即使你还未享受过这些数据驱动的工具带来的便利,经过阅读本文,你也将了解到一些当下的前端框架是如何对开发模式产生巨变影响的。同时本文也是咱们对相关知识学习的一个总结,不免有误,欢迎多多指正,并期待大大们的指点。node


Diff效率之争

VirtualDOM是react在组件化开发场景下,针对DOM重排重绘性能瓶颈做出的重要优化方案,而他最具价值的核心功能是如何识别并保存新旧节点数据结构之间差别的方法,也便是diff算法。毫无疑问的是diff算法的复杂度与效率是决定VirtualDOM可以带来性能提高效果的关键因素。所以,在VirtualDOM方案被提出以后,社区中不断涌现出对diff的改进算法,引用司徒正美的经典介绍:react

最开始经典的深度优先遍历DFS算法,其复杂度为O(n^3),存在高昂的diff成本,而后是cito.js的横空出世,它对从此全部虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度距离算法应用(算法复杂度 为O(n^2))。但这样的diff算法太过复杂了,因而后来者snabbdom将kivi.js进行简化,去掉编辑长度距离算法,调整两端比较算法。速度略有损失,但可读性大大提升。再以后,就是著名的vue2.0 把snabbdom整个库整合掉了。

所以目前VirtualDOM的主流diff算法趋向一致,在主要diff思路上,snabbdom与react的reconilation方式基本相同。git

Diff主要策略

  • 按tree层级diff(level by level)

因为diff的数据结构是以DOM渲染为目标的模拟树状层级结构的节点数据,而在WebUI中不多出现DOM的层级结构由于交互而产生更新,所以VirtualDOM的diff策略是在新旧节点树之间按层级进行diff获得差别,而非传统的按深度遍历搜索,这种经过大胆假设获得的改进方案,不只符合实际场景的须要,并且大幅下降了算法实现复杂度,从O(n^3)提高至O(n)。github


  • 按类型进行diff

不管VirtualDOM中的节点数据对应的是一个原生的DOM节点仍是vue或者react中的一个组件,不一样类型的节点所具备的子树节点之间结构每每差别明显,所以对不一样类型的节点的子树进行diff的投入成本与产出比将会很高昂,为了提高diff效率,VirtualDOM只对相同类型的同一个节点进行diff,当新旧节点发生了类型的改变时,则并不进行子树的比较,直接建立新类型的VirtualDOM,替换旧节点。web


  • 列表diff

当被diff节点处于同一层级时,经过三种节点操做新旧节点进行更新:插入,移动和删除,同时提供给用户设置key属性的方式调整diff更新中默认的排序方式,在没有key值的列表diff中,只能经过按顺序进行每一个元素的对比,更新,插入与删除,在数据量较大的状况下,diff效率低下,若是可以基于设置key标识尽心diff,就可以快速识别新旧列表之间的变化内容,提高diff效率。算法


Virtual DOM不一样的实现方式

基于以上的三条diff原则,咱们就能够自由选择Virtual DOM的具体方案,甚至本身动手进行diff实践,在那以前,让咱们先以Vue中的snabbdom与React中的Reconcile这两个Virtual DOM的实现方案为对象进行学习。segmentfault

snabbdom的vnode

在众多VirtuaDOM实现方案中,snabbdom以其高效的实现,小巧的体积与灵活的可扩展性脱颖而出,它的核心代码只有300行+,却已被适用于vue等轻量级前端框架中做为VirtualDOM的主要功能实现。api

一个使用snabbdom建立的demo是这样的:

import snabbdom from 'snabbdom';
import h from 'snabbdom/h';
const patch = snabbdom.init([
  require('snabbdom/modules/class'),          // makes it easy to toggle classes
  require('snabbdom/modules/props'),          // for setting properties on DOM elements
  require('snabbdom/modules/style'),          // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners'), // attaches event listeners
]);

var vnode = h('div', {style: {fontWeight: 'bold'}}, 'Hello world');
patch(document.getElementById('placeholder'), vnode)
复制代码

在snabbdom中提供了h函数作为建立VirtualDOM的主要函数,h函数接受的三个参数同时揭示了diff算法中关注的三个核心:节点类型,属性数据,子节点对象。而patch方法便是用来建立初始DOM节点与更新VirtualDOM的diff核心函数。

function view(name) { 
  return h('div', [
    h('input', {
      props: { type: 'text', placeholder: 'Type a your name' },
      on   : { input: update }
    }),
    h('hr'),
    h('div', 'Hello ' + name)
  ]); 
}

var oldVnode = document.getElementById('placeholder');

function update(event) {
  const newVnode = view(event.target.value);
  oldVnode = patch(oldVnode, newVnode);
}

oldVnode = patch(oldVnode, view(''));
复制代码

以上是一个经过input事件触发VirtualDOM更新的典型app。在h函数中,不光能够为VirtualDOM保存数据属性,还能够设置事件回调函数,并在其中获取并处理相关的事件属性,如update回调中的event对象。经过捕获事件中建立新的vnode,与旧的vnode进行diff,最终对当前的oldVnode进行更新,并向用户展现更新结果,他的工做流程以下:


在snabbdom源码中的核心patch函数中很明显的体现了VirtualDOM的按类型diff与列表diff的策略:若是patch的新旧节点通过sameVnode判断不是同一个节点,则进行新节点的建立插入与旧节点的删除,而sameVnode也便是判断两个节点是否有相同的key标识与传入的带有节点类型等信息的selector字符串是否相同为依据的:

function sameVnode(vnode1, vnode2) {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
复制代码

而对相同节点进行新旧diff的主函数patchVnode的实现流程以下,其中oldCh与ch为保存旧的当前节点与将要更新的新节点:


//新的text不存在
        if (isUndef(vnode.text)) {
            if (isDef(oldCh) && isDef(ch)) {
                if (oldCh !== ch)
                    updateChildren(elm, oldCh, ch, insertedVnodeQueue);
            }
            //旧的子节点不存在,新的存在
            else if (isDef(ch)) {
                //旧的text存在
                if (isDef(oldVnode.text))
                    api.setTextContent(elm, '');
                //把新的插入到elm底下
                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
            }
            //新的子节点不存在,旧的存在
            else if (isDef(oldCh)) {
                removeVnodes(elm, oldCh, 0, oldCh.length - 1);
            }
            //新的子节点不存在,旧的text存在
            else if (isDef(oldVnode.text)) {
                api.setTextContent(elm, '');
            }
       
复制代码
  1. 若是新节点多是复杂节点而非text节点,则对节点的children进一步diff:先判断是否存在新节点的children总体新增或删除的状况,如果则进行批量更新, 而新旧节点都包含children列表的状况进行updateChildren处理
  2. 若是新旧节点都是text节点,且二者不一样则只进行text更新便可

如下介绍updateChildren的核心diff方式,以旧节点oldCh为当前VirtualDOM状态,将新节点newCh的变化对oldCh进行更新获得新的VirtualDOM状态,并记录新旧节点的startIndex与endIndex两端同时比较,这样会比从单向按顺序对比的方式更快获得diff结果:

  • 当新旧节点的startVnode与endVnode 各自对应相同时,继续对比,startVnode与endVnode位置各自向中间移动一位。
  • 发现oldStartVnode,newEndVnode相同时,也就是oldStartVnode成为了新的末端节点,就将oldStartVnode插到oldEndVnode的后一个位置



  • 当oldEndVnode,newStartVnode相同时,也就是oldEndVnode成为了新的头部节点,就将oldEndVnode插入到oldStartVnode前一个位置



  • 当发现oldCh里没有当前newCh中的节点,将新节点插入到oldStartVnode的前边,同时这里会借助节点中的key值进行map查找是否在其余位置中有匹配的旧节点,若是有匹配,就对旧节点进行更新,再将其插入到当前的oldStartVnode的前面。


  • 在这一轮对比结束时后,有两种状况,当oldStartIdx > oldEndIdx,说明旧节点oldCh已经遍历完。那么剩下newStartIdx和newEndIdx之间的vnode的新节点就调用addVnodes,批量插入父节点的before节点位置,before不少时候是为null的。addVnodes调用的是insertBefore操做dom节点,咱们看看insertBefore的文档:parentElement.insertBefore(newElement, referenceElement)若是referenceElement为null则newElement将被插入到子节点的末尾。若是newElement已经在DOM树中,newElement首先会从DOM树中移除。因此before为null,newElement将被插入到子节点的末尾。
  • 若是newStartIdx > newEndIdx,就是newCh先在第一轮对比中遍历完。此时oldCh中的oldStartIdx和oldEndIdx之间的vnode是须要被删除的,调用removeVnodes将它们从dom里删除。


React的reconcilation

在react的历史版本中,完成数据节点diff的过程是reconcilation,,当你在一个组件中调用setState时,react会将该组件节点标记为dirty,进行reconcile并获得从新构建的子树virtual-dom,在工做流结束时从新render带有dirty标记的节点, 若是你是在组件的根节点上进行setState,那么整个组件树Virtual DOM都会从新建立,但因为这并非直接操做真实的DOM,因此实际上产生的影响仍然有限。

在React16的重写中,最重要的改变时将核心架构改成了代号为Fiber的异步渲染架构。从本质上看,一个Fiber就是一个POJO对象,一个React Element能够对应一个或多个Fiber节点,Fiber包含着DOM节点与React组件中的全部工做须要的属性数据。所以虽然React的代码中其实没有明确的Virtual DOM概念,但经过对Fiber的设计充分完成了Virtual DOM的功能与机制。

Fiber除了承担Virtual DOM的工做以外,它真正设计目的是实现一种在前端执行的轻量执行线程,同普通线程同样共享定址空间,但却可以受React自身的Fiber系统调度,实现渲染任务细分,可计时,可打断,可重启,可调度的协做式多任务处理的强大渲染任务控制机制。

言归正传,尽管Fiber异步渲染的机制几乎重写了整个reconcile的过程,但经过源码分析能够看到对节点reconcile的思路与16以前版本基本一致:

在react的16.3.1版本中,会在页面初始化render运行过程当中,对应页面结构建立FiberNode,经过child属性与siblings属性分别存放子节点与兄弟节点,同时使用return属性标记父节点,便于遍历与修改。Fiber在update的时候,会从原来的Fiber(咱们称为current)clone出一个新的Fiber(称为alternate)。两个Fiber diff出的变化(side effect)记录在alternate上。因此一个组件在更新时最多会有两个Fiber与其对应,在更新结束后alternate会取代以前的current的成为新的current节点。



这里略过Fiber复杂的构建过程,咱们直接来看在某个组件须要更新时的内部机制,也就是组件中setState方法被调用后,首先会在该组件对应的Fiber节点中设置updateQueue属性以队列的形式存储更新内容,而后从顶端开始对整个Fiber树开始进行深度遍历,查找到须要进行更新的Fiber节点,判断的依据就是该节点是否有updateQueue中的更新内容,若是存在更新,就运行咱们熟知的shouldUpdateComponent函数来判断,shouldUpdateComponent返回为真,就执行componentWillUpdate函数,并根据其节点类型决定按哪一种方式进行更新,也就是运行reconcile机制进行diff,若是diff的是component节点,待diff完成以后再运行lifeCycle中的componentDidUpdate函数。

const shouldUpdate = checkShouldComponentUpdate(
      workInProgress,
      oldProps,
      newProps,
      oldState,
      newState,
      newContext,
    );

    if (shouldUpdate) {
      // 【译】这是为了支持react-lifecycles-compat的兼容组件
      // 使用新的API的时候不能调用非安全的生命周期钩子
      if (
        !hasNewLifecycles &&
        (typeof instance.UNSAFE_componentWillUpdate === 'function' ||
          typeof instance.componentWillUpdate === 'function')
      ) {
        //开始计时componentWillUpdate阶段
        startPhaseTimer(workInProgress, 'componentWillUpdate');
        //执行组件实例上的componentWillUpdate钩子
        if (typeof instance.componentWillUpdate === 'function') {
          instance.componentWillUpdate(newProps, newState, newContext);
        }
        if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
          instance.UNSAFE_componentWillUpdate(newProps, newState, newContext);
        }
        //结束计时componentWillUpdate阶段
        stopPhaseTimer();
      }
      // 在当前工做中的Fiber打上标签,后续执行componentDidUpdate钩子
      if (typeof instance.componentDidUpdate === 'function') {
        workInProgress.effectTag |= Update;
      }
      if (typeof instance.getSnapshotBeforeUpdate === 'function') {
        workInProgress.effectTag |= Snapshot;
      }
    } else {
      // 【译】若是当前节点已经在更新中,即便咱们终止了更新,仍然应该执行componentDidUpdate钩子
      if (typeof instance.componentDidUpdate === 'function') {
        if (
          oldProps !== current.memoizedProps ||
          oldState !== current.memoizedState
        ) {
          workInProgress.effectTag |= Update;
        }
      }
      if (typeof instance.getSnapshotBeforeUpdate === 'function') {
        if (
          oldProps !== current.memoizedProps ||
          oldState !== current.memoizedState
        ) {
          workInProgress.effectTag |= Snapshot;
        }
      }
复制代码


这里提到,咱们在组件中setState以后,React会将其视为dirty节点,在事件流结束后,找出dirty的组件节点并进行diff,值得注意的是,虽然从新render构建一颗新的Virtual DOM树不会触碰真正的DOM,这里也并无从新建立新的Fiber树,取而代之的是在每一个Fiber节点中都设置了alternate属性与current属性来分别存放用于更新替代与当前的节点版本,只是在从新遍历整颗树后找到dirty的节点生成新的Fiber节点用于更新:



正如react官方文档中描述的同样,当一个节点须要被更新时(shouldComponentUpdate),下一步则须要对它及其子节点进行shouldComponentUpdate判断与Reconcile的过程来对节点进行更新,这里咱们能够经过在组件中写入覆盖的shouldComponentUpdate函数来决定是否进行更新的逻辑:



Reconcile过程的核心源代码起始于reconcileChildFiber函数,主要实现方式是:根据传入组件的类型进行不一样的reconcile过程,其中最为复杂的是传入子组件数组调用reconcileChildrenArray处理的状况。reconcileChildrenArray函数在开始进行新旧子节点数组reconcile时,默认先按index顺序进行对比,因为Fiber节点自己没有设置向后指针,所以React目前没有采起两端同时对比的算法,也就是说每个同层级别的兄弟Fiber节点只能指向下一个节点。所以在一般状况下,对比过程当中react只会调用updateSlot将获得的新Fiber数据按其不一样类型直接更新到旧Fiber的位置中。

在按顺序对比中,若是使用updateSlot未发现key值不相等的状况,则进行将老节点替换成为新节点,第一轮遍历完成后,则判断若是是新节点已遍历完成,就将剩余的老节点批量删除,若是是老节点遍历完成仍有新节点剩余,则将新节点批量插入老节点末端,若是在第一轮遍历中发现key值不相等的状况,则直接跳出以上步骤,按照key值进行遍历更新,最后再删除没有被上述状况涉及的元素,因而可知在列表结构的组件中,添加key值是有助于提高diff算法效率的。

如下是reconcileChildrenArray函数源代码:

// react使用flow进行类型检查
function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    expirationTime: ExpirationTime,
  ): Fiber | null {
    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;

    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;

    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      // 没有采用两端同时对比,受限于Fiber列表的单向结构
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
      // 指向下一个旧的兄弟节点
        nextOldFiber = oldFiber.sibling;
      }
      // 尝试使用新的Fiber更新旧节点
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        expirationTime,
      );
、    //若是在遍历中发现key值不相等的状况,则直接跳出第一轮遍历
      if (newFiber === null) {
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      if (shouldTrackSideEffects) {
        if (oldFiber && newFiber.alternate === null) {
         // 【译】咱们找到了匹配的节点,但咱们并不保留当前的Fiber,因此咱们须要删除当前的子节点
          deleteChild(returnFiber, oldFiber);
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 记录上一个更新的子节点
      if (previousNewFiber === null) {  
        resultingFirstChild = newFiber;
      } else { 
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }

    if (newIdx === newChildren.length) {
      // 【译】咱们已经遍历完了全部的新节点,直接删除剩余旧节点
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    if (oldFiber === null) {
      // 若是旧节点先遍历完,则按顺序插入剩余的新节点,这里受限于Fiber的结构比较繁琐
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(
          returnFiber,
          newChildren[newIdx],
          expirationTime,
        );
        if (!newFiber) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }

    // 【译】把子节点都设置快速查找的map映射集
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // 【译】使用map查找须要保存或删除的节点
    for (; newIdx < newChildren.length; newIdx++) {
      // 按map查找并建立新的Fiber
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber) {
        if (shouldTrackSideEffects) {
          if (newFiber.alternate !== null) {
            // 【译】新的Fiber也是一个工做线程,可是若是已有当前的实例,那咱们就能够复用这个Fiber,
            // 咱们要从列表中删除这个新的,避免准备复用的Fiber被删除
            existingChildren.delete(
              newFiber.key === null ? newIdx : newFiber.key,
            );
          }
        }
        // 插入当前更新位置
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    if (shouldTrackSideEffects) 
      // 【译】到此全部剩余的子节点都将被删除,加入删除队列
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }
    //最终返回Fiber子节点列表的第一个节点
    return resultingFirstChild;
  }
复制代码


结束语:

VirtualDOM的设计是提高前端渲染性能的有效方案,也所以提供了以数据为驱动的前端框架工具的基础,将咱们从DOM的繁琐操做中解放出来,不一样的VirtualDOM方案在diff方面基本基于三条diff原则,具体diff过程则考虑自身运行上下文中的数据结构,算法效率,组件生命周期与设计来选择diff实现。例如上文snabbdom的updateChildren执行中使用了两端同时对比以及根据位置顺序进行移动的更新策略,而React则受限于Fiber的单向结构采用按顺序直接替换的方式更新,但React优化的组件设计与Fiber的工做线程机制在总体渲染性能方面带来了效率提高,同时二者都提供了基于key值进行diff的策略改善方式。

VirtualDOM的设计影响深远,本文仅对VirtualDOM中的思想与diff实现进行了详细介绍,此外,如何建立一个VirtualDOM树,如何将diff结果进行patch更新等内容仍有许多不一样的具体实现方式能够进行探索,以及React16的Fiber机制更是在异步渲染方面上又进了一步,值得咱们持续关注与学习。


参考阅读

diff算法类:

snabbdom源码

React-less Virtual DOM with Snabbdom :functions everywhere!

解析 snabbdom 源码,教你实现精简的 Virtual DOM 库

React’s diff algorithm

Snabbdom - a Virtual DOM Focusing on Simplicity - Interview with Simon Friis Vindum

去哪儿网迷你React的研发心得


Fiber介绍类

React Fiber Architecture

如何理解 React Fiber 架构?

React 16 Fiber源码速览

How React Fiber can make your web and mobile apps smoother and more responsive

React的新引擎—React Fiber是什么?

相关文章
相关标签/搜索