本文主要从源码层深入分析Vue2中的Diff规则。不过在这之前还是需要回顾下虚拟Dom。
虚拟Dom概念
虚拟DOM(Virtual DOM)是对DOM的JS抽象表示,它们是JS对象,能够描述DOM结构和关系。应用
的各种状态变化会作用于虚拟DOM,最终映射到DOM上。
这里我画了一个简单的描述Vue中虚拟Dom和真实Dom,以及响应式数据之间的关系。我们通过JS操作响应式数据驱动虚拟Dom,虚拟Dom在patch方法下生成对应的真实Dom。而在Vue中抽象出虚拟Dom除了更高效的执行更新之外 ,而且patch这一层也可以根据不同的平台去做一些兼容性和跨平台的处理。
Vue中就是通过新旧虚拟DOM比对可以得到最小DOM操作量,配合异步更新策略减少刷新频率,从而提升性能。
patch(vnode, h('div', obj.foo))
- Vue 1中有细粒度的数据变化侦测,它是不需要虚拟DOM的,但是细粒度造成了大量开销。下面不再用代号区分,本文中的Vue都指Vue2版本。
- Vue中的虚拟Dom是基于snabbdom来实现的。
Patch
接下来就跟踪一下Dom/组件更新的时候,Vue是如何操作的。对Vue源码目录和结构还不熟悉的可以关注我的另一篇文章《阅读Vue源码的准备工作》。
1.patch被调用
找到组件更新的生命周期函数,找到patch调用的地方。目录是src>core>instance>lifecycle.js,在Vue.prototype._update中可以看到这段代码
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)
}
vm.__patch__就是打补丁的过程,通过vm.__patch__获取真实的Dom节点。我们继续深入,看下这个__patch__方法的出处。之前我们说过__patch__中会根据平台做不一样的处理。我们不难找到在src\platforms\web\runtime中的index.js中,Vue的原型被加上了补丁方法。
import { patch } from './patch'
...
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop
同时这里也有一个平台的判断,如果处于浏览器环境就是patch。
2.patch方法被创建
直接进入到同级目录下的patch.js中,我们就找到了一个工厂函数createPatchFunction。
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })
这里很容易理解createPatchFunction的作用就是,传入平台特有的节点操作和属性操作,得到一个平台专属的patch方法。有兴趣的可以去对应的平台下面的runtime文件夹里面去看下节点操作,这里就不展开描述了(removeChild,removeChild,insertBefore,nextSibling……)
3.找到createPatchFunction定义的地方。
src\core\vdom\patch.js,这个文件有800多行,核心4个方法。sameVnode,sameInputType,createKeyToOldIdx和createPatchFunction。
最终我们根据平台的节点操作得到的path方法就在createPatchFunction中返回的,大概在第700行。
return function patch (oldVnode, vnode, hydrating, removeOnly) {}
4.patch的实现
进入patch函数,我们看下参数oldVnode和vnode分别代表新老节点。下面我分别用简单的图例(后面补充)和源码片段与场景对应起来,更容易理解。关键位置我已添加注释。
其中exp1和exp2的vnode的判断在大部分是不会走的,我们写vue的代码不会这么去写this.patch(oldVnode, vnode)……,所以平时我们写new Vue()的时候,走的是exp3的地方。
exp3的位置执行了isRealElement的判断,如果是真实节点就走的是初始化的流程。这里因为是不属于dom diff的流程所以不做深入分析。直接进入到 patchVnode。这里开始就是当新老节点都存在的时候,进行的patch操作。
// exp1:新节点不存在,说明要执行删除节点操作。
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
return;
}
// exp2:老节点不存在,说明要执行创建节点操作。
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
// exp3:平时框架走这里
const isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
// 初始化流程 真实Dom节点
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR);
hydrating = true;
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true);
return oldVnode;
} else if (process.env.NODE_ENV !== "production") {
warn(
"The client-side rendered virtual DOM tree is not matching " +
"server-rendered content. This is likely caused by incorrect " +
"HTML markup, for example nesting block-level elements inside " +
"<p>, or missing <tbody>. Bailing hydration and performing " +
"full client-side render."
);
}
}
// 将真实Dom转化为Vnode
oldVnode = emptyNodeAt(oldVnode);
}
// replacing existing element
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);
// create new node
// 创建整颗树,将它追加到body里面,parentElm的旁边
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent;
const patchable = isPatchable(vnode);
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor);
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert;
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}
// destroy old node
if (isDef(parentElm)) {
// 删掉原来的模板内容
removeVnodes([oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
}
\
5.patchVnode(树级别diff)
patchVnode方法的定义在patch.js中。这里就不把整段源码贴出来了,节省篇幅。我们直接把patchVnode方法中比较关键的片段提出来分析。跳过前面一些关于缓存优化的代码,我们专注于diff的部分。
function patchVnode(
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) { ... }
patchVnode的作用就是分析当前的2个节点(oldVnode和vnode)的类型。
- 如果是元素,更新双方的属性和特性。同时比较双方的子元素,这个递归的过程叫做深度优先。
- 如果双方是文本,则直接更新文本
先获取双方的孩子,下面的双方都是指oldVnode和newVnode。exp1位置,比较双方属性的代码在vue2中还是比较简单粗暴的,不管需不需要都会走一遍更新。Vue3中在这块做了很大的改进。
const oldCh = oldVnode.children;
const ch = vnode.children;
// exp1:比较双方属性
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
}
接下来就到了diff的第一层 树节点级的处理,紧接上面的if。
// 分情况处理 根据新老节点的类型
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// exp1:双方都有子元素 重排
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
} else if (isDef(ch)) {
// exp2:新节点有子节点 老的没有 则批量创建
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(ch);
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
// 批量创建
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// exp3:老节点有子节点 新的没有 则批量删除
removeVnodes(oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// exp4:老节点有文本 新节点没有文本 则把节点文本清空
nodeOps.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// exp5:都有文本 直接更新文本
nodeOps.setTextContent(elm, vnode.text);
}
这里我画了一个简单的示意图,可以对着exp1-exp4去理解。
- 如果老节点不存在子节点,但是新节点有,说明需要批量这些新出现的节点。
- 如果老节点存在子节点,新节点没有,则代表着需要批量删除这些节点。
- 如果双方都有子节点 ,则执行重排操作,updateChildren()。即图中蓝色线框和橙色线框部分。从updateChildren()开始就进入到子节点的diff流程。
6.updateChildren(节点级diff)
到了节点级的diff,就属于高频次执行的算法了。所以这里必须用一种高效的方式去比较和打补丁。在这里,Vue还针对web平台做了特殊处理。
先用一些图来说明一下这个diff的规则。
- 在新老两组VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。 当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。
- 如果oldStartVnode与newEndVnode满足sameVnode。说明oldStartVnode已经跑到了oldEndVnode后面去了,进行patchVnode的同时还需要将真实DOM节点移动到oldEndVnode的后面。
- 如果oldEndVnode与newStartVnode满足sameVnode,说明oldEndVnode跑到了oldStartVnode的前面,进行patchVnode的同时要将oldEndVnode对应DOM移动到oldStartVnode对应DOM的前面。
- 如果以上情况均不符合,则在old VNode中找与newStartVnode相同的节点,若存在执行patchVnode,同时将elmToMove移动到oldStartIdx对应的DOM的前面。
- 当然也有可能newStartVnode在old VNode节点中找不到一致的sameVnode,这个时候会调用createElm创建一个新的DOM节点。
以上判断的位置已经在源码中加上注释,有兴趣的可以仔细研究。
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
// 创建4个游标和4个节点
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
// 后续查找需要的变量
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
const canMove = !removeOnly;
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(newCh);
}
// 首位游标交叉 循环结束
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 校正
// start为空 ++
// end为空 --
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 开头游标的vnode相同
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 结束游标的vnode相同
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 首尾游标的vnode相同 赋值的同时要移动游标
// Vnode moved right
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 尾首游标的vnode相同 赋值的同时要移动游标
// Vnode moved left
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 首尾都没有找到符合sameNode的节点
// 拿出新数组开头第一个,去老数组中查找
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
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,
newCh,
newStartIdx
);
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];
}
}
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(oldCh, oldStartIdx, oldEndIdx);
}
}
当结束时oldStartIdx > oldEndIdx,这个时候旧的VNode节点已经遍历完了,但是新的节点还没有。说明了新的VNode节点实际上比老的VNode节点多,需要将剩下的VNode对应的DOM插入到真实DOM
中,此时调用addVnodes(批量调用createElm接口)。