vue2 vdom和diff算法

1,326 阅读4分钟

一、原理介绍

Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。数据改变时,Vue能够计算出重新渲染组件的最小代价并应用到DOM操作上。

简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。

vdom是纯粹的js对象,所以对vdom的操作性能远高于真实的DOM。对两个vnode进行比较的patch方法最核心的算法是diff算法。diff算法是基于snabbdom改造过来的,仅在同级的vnode间做diff,递归地进行同级vnode的diff,最终实现整个DOM树的更新。因为跨层级的操作是非常少的,忽略不计,这样时间复杂度就从O(n3)变成O(n)。

diff 算法包括几个步骤:

  • 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中;
  • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异;
  • 把所记录的差异应用到所构建的真正的DOM树上,视图就更新了。

Virtual DOM的优势

  • 具备跨平台的优势

由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。

  • 操作 DOM 慢,js运行效率高。

我们可以将DOM对比操作放在JS层,提高效率。 因为DOM操作的执行速度远不如Javascript的运算速度快,因此,把大量的DOM操作搬运到Javascript中,运用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。 Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)

  • 提升渲染性能

Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

二、源码解读

Vue实例化时调用update方法(src\core\instance\lifecycle.js),第一个参数为render函数生成的vnode,将vnode赋值给实例vm的_vnode属性,用于更新时的diff运算。然后调用实例的__patch__方法将vnode转化为真实的dom。

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference
    if (prevEl) {
        prevEl.__vue__ = null
    }
    if (vm.$el) {
        vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
        vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }

__patch__方法在不同的平台上分别定义,以浏览器端为例,__pathch__方法被定义为patch方法(src\platforms\web\runtime\index.js),patch方法是createPatchFunction({ nodeOps, modules })的返回值。createPatchFunction方法在src\core\vdom\patch.js中定义,返回patch函数

function patch (oldVnode, vnode, hydrating, removeOnly) {
    // oldVnode存在,vnode不存在时,调用destroy钩子
    if (isUndef(vnode)) {
        if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
        return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
        // empty mount (likely as component), create new root element
        isInitialPatch = true
        createElm(vnode, insertedVnodeQueue)
    } else {
        const isRealElement = isDef(oldVnode.nodeType)
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // patch existing root node
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
        } else {
            // ……
            // replacing existing element
            const oldElm = oldVnode.elm
            const parentElm = nodeOps.parentNode(oldElm)
    
            // create new node
            createElm(
              vnode,
              insertedVnodeQueue,
              // extremely rare edge case: do not insert if old element is in a
              // leaving transition. Only happens when combining transition +
              // keep-alive + HOCs. (#4590)
              oldElm._leaveCb ? null : parentElm,
              nodeOps.nextSibling(oldElm)
            )

            // destroy old node
            if (isDef(parentElm)) {
              removeVnodes([oldVnode], 0, 0)
            } else if (isDef(oldVnode.tag)) {
              invokeDestroyHook(oldVnode)
            }
        }
    }
    //第一次渲染isInitialPatch为true
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

首次加载直接调用createElm(vnode,insertedVnodeQueue),之后的更新如果是同类型节点直接调用patchVnode进行更新,不同类型节点调用createElm (vnode, insertedVnodeQueue, parentElm, refElm)创建新节点,并插入到正确的位置。

function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) {
    // ……
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {      // 元素节点
        // ……
        vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode)
        setScope(vnode)

      /* istanbul ignore if */
        if (__WEEX__) {
            // ……
        } else {
            createChildren(vnode, children, insertedVnodeQueue)   // 递归创建子节点
            if (isDef(data)) {
                invokeCreateHooks(vnode, insertedVnodeQueue)
            }
            insert(parentElm, vnode.elm, refElm)
      }
    } else if (isTrue(vnode.isComment)) {  // 注释
        vnode.elm = nodeOps.createComment(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    } else {   // 文本节点
        vnode.elm = nodeOps.createTextNode(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    }
}

创建节点后调用insert()将生成的 DOM 元素挂载到页面中。

function insert (parent, elm, ref) {
    if (isDef(parent)) {
        if (isDef(ref)) {
            if (nodeOps.parentNode(ref) === parent) {
                nodeOps.insertBefore(parent, elm, ref)
            }
        } else {
            nodeOps.appendChild(parent, elm)
        }
    }
}

patchVnodeupdateChildren定义在src\core\vdom\patch.js中,其中用到的DOM操作在src\platforms\weex\runtime\node-ops.js中定义,其他条件判断的操作在src\shared\util.js中。关于patchVnode和updateChildren的过程,感兴趣的话可以查看下面的文章

vue 虚拟dom实现原理

vue虚拟DOVueM源码学习-vnode的挂载和更新流程

深入Vue2.x的虚拟DOM diff原理