头像

zjian

Just Code!

帖子照片墙关于

Vue2 的 patch 算法

2023-11-06 16:53

这次我们来讨论 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 节点,那么对于旧的节点,直接替换就行了,所以旧的节点是什么都无所谓,这里旧减少了两种。

然后,就是新空节点和旧空节点,这里可以稍微想下,其实两个都是同一个组件的节点,传入的参数变动了有响应式的依赖通知子组件更新,所以这里旧不需要什么操作了。

剩下六种:

  1. 第一种,新旧都是元素节点(带有 child 的),更新 children 就行了(updateChildren)
  2. 第二种,新 child 节点、旧 text 节点,那么,删除文本,然后把新的子节点 clone 到旧的父节点下。
  3. 第三种,新 child 节点、旧空节点,直接把新的子节点 clone 到旧的父节点下。
  4. 第四种,新空节点、旧 child 节点,只需要把旧的节点都移除就好了
  5. 第五种,新空节点、旧 text 节点,需要把节点的内容清空
  6. 第六种,新 text 节点,直接设置 text 的内容就好了

updateChildren 函数

上面第一种情况,调用 updateChildren 更新子节点,更新子节点列表的算法基本上是从两边向中间遍历的一个过程:

对于两个 vnode 列表,都定义一前一后的指针,共有四个: 新 vnode 前、新 vnode 后、旧 vnode 前、旧 vnode 后

两个前指针向右移、两个后指针向前移,当任意一个前后指针交叉后,结束循环,剩下的节点补充上去。

  1. 开始循环,判断旧前和旧后是否存在,如果不存在,则移动到存在的点为止。(为什么只判断旧列表,可以看第 8 步骤,有设置 undefined 的)
  2. 判断新前旧前是否是同一个节点,如果是,递归调用 patchVnode 更新,然后两个前指针都向右移动一步。
  3. 如果新前旧前不是同一个节点,那判断旧后跟新后,如果是同一节点,也是调用 patchVnode 更新,然后两个后指针向前移动一步
  4. 如果都不是上面两种情况,那么交叉比较,旧前和新后,如果是同一节点,则先更新旧节点,然后把旧前节点移动到旧后的后面。然后移动指针。
  5. 如果也不是上述,那再进行交叉,新前与旧后,如果是同一节点,则也是先更新旧后节点,然后把旧后节点,移动到旧前的前面。 然后移动指针。
  6. 如果都不是,会去找新前的 key,然后拿新前的 key,去找到这个 key 在旧节点列表的 idx(有存储 key 对应的 idx 下标)
  7. 如果没有 idx,则新建一个节点,然后插入到旧前的前面
  8. 如果有 idx,判断该旧 vnode 是否和新前是同一个,如果是,更新该节点,然后设置数组的这个 idx 下标为 undefined,然后把移动该节点到旧前的前面
  9. 如果有 idx 但是和新前不是同一个节点,则也是新建节点,插入到旧前的前面。
  10. 如果没有 idx,也是直接新建节点插入到旧前的前面

一个简单的例子

Untitled

如图:旧的 vnode 列表和新的 vnode 列表,已经 dom 结构。

首先定义旧前、新前、旧后、新后,然后发现新前和旧前是同一个节点,更新该节点的 dom,然后移动指针下一个循环。

Untitled

判断:旧前与新前、旧后与新后都不一致,然后交叉发现旧前和新后是同一个节点,更新旧前(B)的真实 dom 节点,然后移动到旧后(E)的后面。

Untitled

指针继续移动,交叉判断发现新前和旧后是同一个节点,更新旧后,然后移动旧后到旧前(C)的前面。

Untitled

继续移动指针,发现旧前和新前是同一个,直接更新旧前即可。

Untitled

继续发现新前节点不存在,新建一个 F 节点插入到旧前(D)的前面。

Untitled

继续移动指针,发现新后<新前,退出循环,退出后移除旧前到旧后的所有节点,移除 D 节点。

Untitled

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》还是很多作者的想法的,容易产生误导。