Vue2 的 patch 算法
这次我们来讨论 Vue2 的 patch 的过程。
这篇文章介绍了 Vue2 的响应式实现,那么当我们数据发生变化的时候,Vue2 是怎么渲染页面的呢?
一种最简单的方法,就是每次执行 update 操作的时候,根据模板,生成新的 vnode,然后直接根据 vnode 生成新的真实 dom,然后直接替换。
但这样的话,每次跟新数据,都可能设计大量的真实 dom 操作,而且大部分很可能是不需要更新的,所以 Vue 为了性能,做了一种 patch 的操作,尽量减少直接操作 dom 的机会。
patchVnode 函数
其实入口函数实际上是patch(newVnode)
, 这个函数主要做的事比较少,实际就是兼容处理下 oldVnode 参数,然后调用 patchVnode(oldVnode, vnode)
, 所以这里暂时当 patchVnode 函数是入口。
patchVnode 的过程
部分核心代码:
const oldCh = oldVnode.children;
const ch = vnode.children;
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); //(新child节点、旧child节点)
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ""); //(新child节点、旧text节点)
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); //(新child节点、旧空节点)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1); // (新空节点、旧child节点)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, ""); // (新空节点、旧text节点)
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text); // (新text节点)
}
总的来说, 对于 vnode,可以分为三种情况:空节点(无 text、无 children)、text 节点(无 children、有 text)、child 节点(有 childre、无 text)
实际上,空节点其实对应的是组件节点的 vnode,text 节点对应的是注释节点或者文本节点(没有 tag),child 节点实际就是普通的元素节点(有 tag,有子节点)。
那么,很容易发现,新的 vnode,有这三种情况,那旧的 vnode,也会有这三种情况,应该有 3 * 3 = 9 种情况。
首先,新的 vnode 如果是 text 节点,那么对于旧的节点,直接替换就行了,所以旧的节点是什么都无所谓,这里旧减少了两种。
然后,就是新空节点和旧空节点,这里可以稍微想下,其实两个都是同一个组件的节点,传入的参数变动了有响应式的依赖通知子组件更新,所以这里旧不需要什么操作了。
剩下六种:
- 第一种,新旧都是元素节点(带有 child 的),更新 children 就行了(updateChildren)
- 第二种,新 child 节点、旧 text 节点,那么,删除文本,然后把新的子节点 clone 到旧的父节点下。
- 第三种,新 child 节点、旧空节点,直接把新的子节点 clone 到旧的父节点下。
- 第四种,新空节点、旧 child 节点,只需要把旧的节点都移除就好了
- 第五种,新空节点、旧 text 节点,需要把节点的内容清空
- 第六种,新 text 节点,直接设置 text 的内容就好了
updateChildren 函数
上面第一种情况,调用 updateChildren 更新子节点,更新子节点列表的算法基本上是从两边向中间遍历的一个过程:
对于两个 vnode 列表,都定义一前一后的指针,共有四个: 新 vnode 前、新 vnode 后、旧 vnode 前、旧 vnode 后
两个前指针向右移、两个后指针向前移,当任意一个前后指针交叉后,结束循环,剩下的节点补充上去。
- 开始循环,判断旧前和旧后是否存在,如果不存在,则移动到存在的点为止。(为什么只判断旧列表,可以看第 8 步骤,有设置 undefined 的)
- 判断新前旧前是否是同一个节点,如果是,递归调用 patchVnode 更新,然后两个前指针都向右移动一步。
- 如果新前旧前不是同一个节点,那判断旧后跟新后,如果是同一节点,也是调用 patchVnode 更新,然后两个后指针向前移动一步
- 如果都不是上面两种情况,那么交叉比较,旧前和新后,如果是同一节点,则先更新旧节点,然后把旧前节点移动到旧后的后面。然后移动指针。
- 如果也不是上述,那再进行交叉,新前与旧后,如果是同一节点,则也是先更新旧后节点,然后把旧后节点,移动到旧前的前面。 然后移动指针。
- 如果都不是,会去找新前的 key,然后拿新前的 key,去找到这个 key 在旧节点列表的 idx(有存储 key 对应的 idx 下标)
- 如果没有 idx,则新建一个节点,然后插入到旧前的前面
- 如果有 idx,判断该旧 vnode 是否和新前是同一个,如果是,更新该节点,然后设置数组的这个 idx 下标为 undefined,然后把移动该节点到旧前的前面
- 如果有 idx 但是和新前不是同一个节点,则也是新建节点,插入到旧前的前面。
- 如果没有 idx,也是直接新建节点插入到旧前的前面
一个简单的例子
如图:旧的 vnode 列表和新的 vnode 列表,已经 dom 结构。
首先定义旧前、新前、旧后、新后,然后发现新前和旧前是同一个节点,更新该节点的 dom,然后移动指针下一个循环。
判断:旧前与新前、旧后与新后都不一致,然后交叉发现旧前和新后是同一个节点,更新旧前(B)的真实 dom 节点,然后移动到旧后(E)的后面。
指针继续移动,交叉判断发现新前和旧后是同一个节点,更新旧后,然后移动旧后到旧前(C)的前面。
继续移动指针,发现旧前和新前是同一个,直接更新旧前即可。
继续发现新前节点不存在,新建一个 F 节点插入到旧前(D)的前面。
继续移动指针,发现新后<新前,退出循环,退出后移除旧前到旧后的所有节点,移除 D 节点。
patch 完成。
key 的作用
上面说明了,patch 的过程中,会比较旧前、新前、旧后、新后,然后会根据 key 来查找对应的 index,如果找到了,可以直接复用(移动到对应为止),如果没找到,会重新创建插入,所以,key 的主要作用就是 Dom 复用。
还有 vue 官方也说明了:
- Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。
比如: <input placeholder="Enter your email address">
变为 <input placeholder="Enter your username">
vue 不会重新创建元素,而是直接改 placeholder,如果需要重新创建,可以手动设置 key 值:
<input placeholder="Enter your email address" key="email-input">
总结
这里 Vue 的源码还是相对比较好看懂的,干扰比较少。 总的来说,原理的这些内容,个人认为有时间的话还是要实际去看源码才比较好,看完这实际的源码回去翻《深入浅出 Vue.js》还是很多作者的想法的,容易产生误导。