vue 2.x diff源码解析

582 阅读6分钟

背景

1. 为什么要用Diff算法
由于在浏览器中操作DOM的代价是非常“昂贵”的,所以才在Vue引入了Virtual DOM,Virtual DOM是对真实DOM的一种抽象描述,说到底就是个js对象树,即使使用了Virtual DOM来进行真实DOM的渲染,在页面更新的时候,也不能全量地将整颗Virtual DOM进行渲染,而是去渲染改变的部分,这时候就需要一个计算Virtual DOM树改变部分的算法了,这个算法就是Diff算法
同层比较
逐层进行比较,只比较同一层次的节点,大大降低了复杂度

主流程分析

1.源码入口文件:src/platforms/web/runtime/index.js

关键代码 :

Vue.prototype.__patch__ = inBrowser ? patch : noop

2./src/platforms/web/runtime/patch.js

关键代码:

//nodeOps 节点操作  modules属性操作
export const patch: Function = createPatchFunction({ nodeOps, modules })

nodeOps:platforms\web\runtime\node-ops.js
定义各种原生dom基础操作方法

modules:platforms\web\runtime\modules\index.js
modules 定义了属性更新实现

下一步找到createPatchFunction的文件位置 src/core/vdom/patch.js 在createPatchFunction函数的最后 会返回一个patch函数

1.在patch函数里面,首先进行树级别比较,可能有三种情况:增删改。

  1. new VNode不存在就删;
  2. old VNode不存在就增;
  3. 都存在就执行diff执行更新

重点关注 patchVnode函数
      在patchVnode中,比较两个VNode,包括三种类型操作:属性更新、文本更新、子节点更新,具体流程:
文本跟子节点一定是互斥的,俩者只能有其一

未命名文件 (1).png

再来关注下updateChildren函数

image.png

updateChildren主要作用是用一种较高效的方式比对新旧两个VNode的children得出最小操作补丁。执行一个双循环是传统方式,在新老两组VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。 当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。
1.首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode两两交叉比较,共有4种比较方法。
判断1:oldStartVnode是否为空,若为true则oldStartIdx向后移动,继续下一个节点的判断。判断代码如下

if (isUndef(oldStartVnode)) {
    // 更新哨兵
    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
}

判断2:oldEndVnode是否为空,若为true则oldEndIdx向前移动。判断代码如下:

else if (isUndef(oldEndVnode)) {
    oldEndVnode = oldCh[--oldEndIdx]
}

判断3:使用 sameVnode判断before和after未判断的头节点是否为相同节点,若为true,则按照上面思路说的,对相同类型节点进行节点的属性的更新并修改哨兵位置。

// sameVnode为判断节点是否相等的方法,包括key、tag、isComment等各个属性的相等才能算作相同节点
else if (sameVnode(oldStartVnode, newStartVnode)) {
    // 更新节点内容
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
    // 更新哨兵
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
}

判断4:使用上一步相同的方法对oldEndVnode和newEndVnode进行判断。并执行相同的更新操作

else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
    // 更新哨兵
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
}

判断5:使用sameVNode判断旧列表的头节点和新列表的尾节点进行判断, sameVnode(oldStartVnode, newEndVnode),若为true,更新相同节点,若该节点可以移动在真实DOM中将oldStartVnode,放到真实节点列表的最后

else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
    patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
    // 真实DOM移动到真实节点列表的最后面
    canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
    // 更新哨兵
    oldStartVnode = oldCh[++oldStartIdx]
    newEndVnode = newCh[--newEndIdx]
}

判断6:使用sameVnode比较旧列表的尾节点和新列表的头节点,若为true,和上面一样,更新相同节点,将oldEndVnode放到真实节点列表的最开始

else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
    patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
    // 真实DOM移动到真实节点列表最前面
    canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
    oldEndVnode = oldCh[--oldEndIdx]
    newStartVnode = newCh[++newStartIdx]
} 

通过这一系列的优先判断条件,一方面对于一些不需要做移动的DOM可以得到快速处理,另一方面使待处理节点变少,缩小了后续操作的处理范围,可以更快地完成同级节点的对比 若节点不满足上面的所有判断,则会进入到最后一个条件分支
判断7

else {
    // oldKeyToIdx为after列表中key和index的映射,可以加快查找速度
    if (isUndef(oldKeyToIdx)) {
        // 若不存在该映射则去初始化映射
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
    }
    // 若newStartVnode存在key的情况,则去映射中查找,若无则从oldStartIdx到oldEndIdx遍历after列表查找新节点是否存在
    idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    // 若新节点不存在于旧节点数组中,新建一个元素并插入真实DOM节点列表中
    if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    } else {
        // 若在旧列表中查找到新节点,则去判断两个节点是否相等
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
            // 更新节点内容和哨兵并进行节点的移动
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
    }
    newStartVnode = newCh[++newStartIdx]
}

最后当oldStartIdx > oldEndIdx || newStartIdx > newEndIdx,也就是新或旧节点数组有一个被查找完之后则退出判断循环。当循环结束时,旧节点数组中剩下的节点即为要删除的节点,新节点数组中剩下的即为要新增的节点。只需要进行简单的新增和删除操作即可,代码如下:

if (oldStartIdx > oldEndIdx) {
    // 新节点数组中有剩余,新增新节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
    // 旧节点数组中有剩余,删除旧节点
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}

就地复用
在Diff中会使用到一种就地复用的策略。就地复用是指Vue会尽可能复用之前的DOM,尽可能不发生DOM的移动。

Vue判断新旧节点是否为相同节点(也就是上面的sameVnode方法),这个相同节点的意思并不是两个完全相同的节点,实际上它仅判断是否为同类节点(同类节点为类型相同且节点数据一致,如前后两个span,span标签上的属性没有改变,但是里面的内容变了,这样就算作同类节点),如果是同类节点,那么Vue会直接复用旧DOM节点,只要更新节点中的内容即可。这样可以大大减少列表中节点的移动操作。

一些其他函数分析

createElm
他的作用是把vnode转成真实dom,然后挂载到dom树上。函数定义在core/vdom/patch.js里面

image.png

sameVnode

/*
  判断两个VNode节点是否是同一个节点,需要满足以下条件
  key相同
  tag(当前节点的标签名)相同
  isComment(是否为注释节点)相同
  是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义
  当标签是<input>的时候,type必须相同
*/
function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
  )
}

// Some browsers do not support dynamically changing type for <input>
// so they need to be treated as different nodes
/*
  判断当标签是<input>的时候,type是否相同
  某些浏览器不支持动态修改<input>类型,所以他们被视为不同类型
*/
function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB
}

当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。