React && VUE Virtual Dom的Diff算法统一之路 snabbdom.js解读

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

最开始经典的深度优先遍历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整个库整合掉了。vue

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

virtual dom中心思想

若是没有理解virtual dom的构建思想,那么你能够参考这篇精致文章Boiling React Down to a Few Lines in jQuery
virtual dom优化开发的方式是:经过vnode,来实现无状态组件,结合单向数据流(undirectional data flow),进行UI更新,总体代码结构是:node

var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)
state.dispatch('change')
var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)

virtual dom库选择

在众多virtual dom库中,咱们选择snabbdom库,缘由有不少:react

1.snabbdom性能排名靠前,虽然这个benchmark的参考性不高
2。snabbdom示例丰富
3.snabbdom具备必定的生态圈,如motorcycle.js,cycle-snabbdom,cerebral
4.snabbdom实现的十分优雅,使用的是recursive方式调用patch,对比infernojs优化痕迹明显的代码,snabbdom更易读。
5.在阅读过程当中发现,snabbdom的模块化,插件支持作得极佳webpack

snabbdom的工做方式

咱们来查看snabbdom基本使用方式。web

// snabbdom在./snabbdom.js
var snabbdom = require('snabbdom')
// 初始化snabbdom,获得patch。随后,咱们能够看到snabbdom设计的精妙之处
var patch = snabbdom.init([
  require('snabbdom/modules/class'),
  require('snabbdom/modules/props'),
  require('snabbdom/modules/style'),
  require('snabbdom/modules/eventlisteners')
])
// h是一个生成vnode的包装函数,factory模式?对生成vnode更精细的包装就是使用jsx
// 在工程里,咱们一般使用webpack或者browserify对jsx编译
var h = require('snabbdom/h')
// 构造一个virtual dom,在实际中,咱们一般但愿一个无状态的vnode
// 而且咱们经过state来创造vnode
// react使用具备render方法的对象来做为组件,这个组件能够接受props和state
// 在snabbdom里面,咱们一样能够实现相似效果
// function component(state){return h(...)}
var vnode = 
  h(
    'div#container.two.classes', 
    {on: {click: someFn}}, 
    [ 
      h('span', {style: {fontWeight: 'bold'}}, 'This is bold'), 
      ' and this is just normal text', 
      h('a', {props: {href: '/foo'}}, 
      'I\'ll take you places!')
    ]
  )
// 获得初始的容器,注意container是一个dom element
var container = document.getElementById('container')
// 将vnode patch到container中
// patch函数会对第一个参数作处理,若是第一个参数不是vnode,那么就把它包装成vnode
// patch事后,vnode发生变化,表明了如今virtual dom的状态
patch(container, vnode)
// 建立一个新的vnode
var newVnode = 
  h(
    'div#container.two.classes', 
    {on: {click: anotherEventHandler}}, 
    [ 
      h('span', {style: {fontWeight: 'normal', fontStyle: 'italics'}},
      'This is now italics'), 
      ' and this is still just normal text', 
      h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
    ]
  )
// 将新的vnode patch到vnode上,如今newVnode表明vdom的状态
patch(vnode, newVnode)

vnode的定义

阅读vdom实现,首先弄清楚vnode的定义

vnode的定义在./vnode.js中 vnode具有的属性

1.tagName 能够是custom tag,能够是'div','span',etc,表明这个virtual dom的tag name
2.data, virtual dom数据,它们与dom element的prop、attr的语义相似。可是virtual dom包含的数据能够更灵活。
好比利用./modules/class.js插件,咱们在data里面轻松toggle一个类名算法

h('p', {class: {'hide': hideIntro}})
    • children,
      对应element的children,可是这是vdom的children。vdom的实现重点就在对children的patch上
    • text, 对应element.textContent,在children里定义一个string,那么咱们会为这个string建立一个textNode
    • elm, 对dom element的引用
    • key,用于提示children patch过程,随后将详细说明

    h参数

    随后是h函数的包装api

    h的实如今./h.js数组

    包装函数一共注意三点浏览器

    • 对svg的包装,建立svg须要namespace
    • 将vdom.text统一转化为string类型
    • 将vdom.children中的string element转化为textNode

    与dom api的对接

    实如今./htmldomapi.js

    采用adapter模式,对dom api进行包装,而后将htmldomapi做为默认的浏览器接口
    这种设计很机智。在扩展snabbdom的兼容性的时候,只须要改变snabbdom.init使用的浏览器接口,而不用改变patch等方法的实现

    snabbdom的patch解析

    snabbdom的核心内容实如今./snabbdom.js。snabbdom的核心实现不到三百行(233 sloc),很是简短。

    在snabbdom里面实现了snabbdom的virtual dom diff算法与virtual dom lifecycle hook支持。
    virtual dom diff
    vdom diff是virtual dom的核心算法,snabbdom的实现原理与react官方文档Reconciliation一致
    总结起来有:

    • 对两个树结构进行完整的diff和patch,复杂度增加为O(n^3),几乎不可用
    • 对两个数结构进行启发式diff,将大大节省开销

    一篇阅读量颇丰的文章React’s diff algorithm也说明的就是启发过程,惋惜,没有实际的代码参照。如今,咱们根据snabbdom代码来看启发规则的运用,结束后,你会明白virtual dom的实现有多简单。

    首先来到snabbdom.js中init函数的return语句

    return function(oldVnode, vnode) {
      var i, elm, parent;
      // insertedVnodeQueue存在于整个patch过程
      // 用于收集patch中新插入的vnode
      var insertedVnodeQueue = [];
      // 在进行patch以前,咱们须要运行prepatch hook
      // cbs是init函数变量,即,这个return语句中函数的闭包
      // 这里,咱们不理会lifecycle hook,而只关注vdom diff算法
      for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    
      // 若是oldVnode不是vnode(在第一次调用时,oldVnode是dom element)
      // 那么用emptyNodeAt函数来将其包装为vnode
      if (isUndef(oldVnode.sel)) {
        oldVnode = emptyNodeAt(oldVnode);
      }
      
      // sameVnode是上述“值不值得patch”的核心
      // sameVnode实现很简单,查看两个vnode的key与sel是否分别相同
      // ()=>{vnode1.key === vnode2.key && vnode1.sel === vnode2.
      // 比较语义不一样的结构没有意义,好比diff一个'div'和'span'
      // 而应该移除div,根据span vnode插入新的span
      // diff两个key不相同的vnode一样没有意义
      // 指定key就是为了区分element
      // 对于不一样key的element,不该该去根据newVnode来改变oldVnode的数据
      // 而应该移除再也不oldVnode,添加newVnode
      if (sameVnode(oldVnode, vnode)) {
        // oldVnode与vnode的sel和key分别相同,那么这两个vnode值得去比较
        //patchVnode根据vnode来更新oldVnode
        patchVnode(oldVnode, vnode, insertedVnodeQueue);
      } else {
        //不值得去patch的,咱们就暴力点
        // 移除oldVnode,根据newVnode建立elm,并添加至parent中
        elm = oldVnode.elm;
        parent = api.parentNode(elm);
    
        // createElm根据vnode建立element
        createElm(vnode, insertedVnodeQueue);
    
        if (parent !== null) {
          // 将新建立的element添加到parent中
          api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
          // 同时移除oldVnode
          removeVnodes(parent, [oldVnode], 0, 0);
        }
      }
    
      // 结束之后,调用插入vnode的insert hook
      for (i = 0; i < insertedVnodeQueue.length; ++i) {
        insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
      }
    
      // 整个patch结束,调用cbs中的post hook
      for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
      return vnode;
    };
    ```
    
        ###而后咱们阅读patch的过程
    
    ```
    function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
      var i, hook;
      // 如前,在patch以前,调用prepatch hook,可是这个是vnode在data里定义的prepatch hook,而不是全局定义的prepatch hook
      if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
        i(oldVnode, vnode);
      }
    
      var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
    
      // 若是oldVnode和vnode引用相同,则不必比较。在良好设计的vdom里,大部分时间咱们都在执行这个返回语句。
      if (oldVnode === vnode) return;
      // 若是两次引用不一样,那说明新的vnode建立了
      // 与以前同样,咱们先看这两个vnode值不值得去patch
      if (!sameVnode(oldVnode, vnode)) {
        // 这四条语句是否与init返回函数里那四条相同?
        var parentElm = api.parentNode(oldVnode.elm);
        elm = createElm(vnode, insertedVnodeQueue);
        api.insertBefore(parentElm, elm, oldVnode.elm);
        removeVnodes(parentElm, [oldVnode], 0, 0);
        return;
      }
      // 这两个vnode值得去patch
      // 咱们先patch vnode,patch的方法就是先调用全局的update hook
      // 而后调用vnode.data定义的update hook
      if (isDef(vnode.data)) {
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
        i = vnode.data.hook;
        if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
      }
      // patch两个vnode的text和children
      // 查看vnode.text定义
      // vdom中规定,具备text属性的vnode不该该具有children
      // 对于<p>foo:<b>123</b></p>的良好写法是
      // h('p', [ 'foo:', h('b', '123')]), 而非
      // h('p', 'foo:', [h('b', '123')])
      if (isUndef(vnode.text)) {
        // vnode不是text node,咱们再查看他们是否有children
        if (isDef(oldCh) && isDef(ch)) {
          // 两个vnode都有children,那么就调用updateChildren
          if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
        } else if (isDef(ch)) {
          // 只有新的vnode有children,那么添加vnode的children
          if (isDef(oldVnode.text)) api.setTextContent(elm, '');
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        } else if (isDef(oldCh)) {
          // 只有旧vnode有children,那么移除oldCh
          removeVnodes(elm, oldCh, 0, oldCh.length - 1);
        } else if (isDef(oldVnode.text)) {
          // 二者都没有children,而且oldVnode.text不为空,vnode.text未定义,则清空elm.textContent
          api.setTextContent(elm, '');
        }
      } else if (oldVnode.text !== vnode.text) {
        // vnode是一个text node,咱们改变对应的elm.textContent
        // 在这里咱们使用api.setText api
        api.setTextContent(elm, vnode.text);
      }
      if (isDef(hook) && isDef(i = hook.postpatch)) {
        i(oldVnode, vnode);
      }
    }

    patch的实现是否简单明了?甚至有以为“啊?这就patch完了”的感受。固然,咱们还差最后一个,这个是重头戏——updateChildren。

    最后阅读updateChildren*

    updateChildren的代码较长且密集,可是算法十分简单
    oldCh是一个包含oldVnode的children数组,newCh同理
    咱们先遍历两个数组(while语句),维护四个变量

    • 遍历oldCh的头索引 - oldStartIdx
    • 遍历oldCh的尾索引 - oldEndIdx
    • 遍历newCh的头索引 - newStartIdx
    • 遍历newCh的尾索引 - newEndIdx

    当oldStartIdx > oldEndIdx或者newStartIdx > newOldStartIdx的时候中止遍历。

    遍历过程当中有五种比较
    前四种比较

    • oldStartVnode和newStartVnode,二者elm相对位置不变,若值得(sameVnode)比较,这patch这两个vnode
    • oldEndVnode和newEndVnode,同上,elm相对位置不变,作相同patch检测
    • oldStartVnode和newEndVnode,若是oldStartVnode和newEndVnode值得比较,说明oldCh中的这- - oldStartVnode.elm向右移动了。那么执行api.insertBefore(parentElm,oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))调整它的位置
    • oldEndVnode和newStartVnode,同上,但这是oldVnode.elm向左移,须要调整它的位置

    最后一种比较

    利用vnode.key,在ul>li*n的结构里,咱们颇有可能使用key来标志li的惟一性,那么咱们就会来到最后一种状况。这个时候,咱们先产生一个index-key表(createKeyToOldIdx),而后根据这个表来进行更改。

    更改规则

    若是newVnode.key不在表中,那么这个newVnode就是新的vnode,将其插入
    若是newVnode.key在表中,那么对应的oldVnode存在,咱们须要patch这两个vnode,并在patch以后,将这个oldVnode置为undefined(oldCh[idxInOld] = undefined),同时将oldVnode.elm位置变换到当前oldStartIdx以前,以避免影响接下来的遍历

    遍历结束后,检查四个变量,对移除剩余的oldCh或添加剩余的newCh
    patch总结
    阅读完init函数return语句,patch,updateChildren,咱们能够理解整个diff和patch的过程

    有些函数createElm,removeVnodes并不重要

    lifecycle hook

    阅读完virtual dom diff算法实现后,咱们可能会奇怪,关于style、class、attr的patch在哪里?这些实现都在modules,并经过lifecycle发挥做用
    snabbdom的生命周期钩子函数定义在core doc - hook中。
    再查看modules里的class会发现,class module经过两个hook钩子来对elm的class进行patch。这两个钩子是create和update。
    回到init函数,这两个钩子在函数体开头注册

    for (i = 0; i < hooks.length; ++i) {
      cbs[hooks[i]] = [];
      for (j = 0; j < modules.length; ++j) {
        if (modules[j][hooks[i]] !== undefined)
         cbs[hooks[i]].push(modules[j][hooks[i]]);
      }
    }

    create hook在createElm中调用。createElm是惟一添加vnode的方法,因此insertedVnodeQueue.push只发生在createElm中。

    相关文章
    相关标签/搜索