likes
comments
collection
share

Vue3源码阅读——多图带你看懂diff算法

作者站长头像
站长
· 阅读数 10

前言

本文属于笔者Vue3源码阅读系列第七篇文章,往期精彩:

我们都知道,在 Vue 开发的 Web 应用中,它由非常多个 Vue 组件搭建组成,每一个组件又是由很多 dom 节点构成,并且这些 dom 节点并不是一直在网页中一成不变,它们会随着用户的操作动态变化,比如:

  • 节点的内容变化
  • 样式变化(style/class
  • 节点变化(变成了另外一个节点,或者另外一些节点)
  • 节点属性变化
  • 等等...

当然用户(开发者)并不直接操作 dom,用户操作的是与这些动态节点绑定的状态,当状态变更,Vue会帮我们更新 dom。更新 dom 并不难,难的是如何高效的更新 dom,接下来咱们就一起来学习一下 Vue是如何帮助我们高效(或者说尽可能高效)的更新 domdiff的意义所在)。

patchChildren

在上一篇文章中,主要介绍了触发更新的大致流程——其实就是基于组件的subTree(调用组件 render 得到的树形结构的 vnode)进行递归调用patch的过程,在这一过程中最关键的其实还是 patchElement,因为不管是普通html节点,还是组件的更新最终还是会转变为dom节点的更新

本文主要内容是 patchChildren,以这个方法为入口,我们将会学习到针对不同 children 的类型,Vue 是如何处理更新的。

先看下patchChildren(packages/runtime-core/src/renderer.ts)的逻辑: Vue3源码阅读——多图带你看懂diff算法

以上就是patchChildren的主干逻辑,笔者在每个分支上都标注了其相对应的注释,在源码的注释中提到了children可能有三种类型——text、vnode array、null,新旧children两两组合一共产生了9种可能性,笔者画了一个图,可以参考下:

Vue3源码阅读——多图带你看懂diff算法

快速通道?

值得注意的一点,在patchChildren的开头,有一个快速通道,如果进入了这个通道就return了,不会再走后面判断新旧children的类型执行相应的逻辑。这就是 Vue3compiler 的功劳了,就像在上一篇文章中讲 patchElement 讲到的那样。如果我们通过单文件组件 template 开发的组件(.vue)最终会编译转换成为 render 函数。而compiler就是负责这个动作,它在编译过程中会生成一些辅助信息,用于优化更新时的速度。

这样一来的话,就不难解释这个快速通道了,它就是专门给通过单文件组件 template 开发的组件用的。比如同样的一个功能,我们使用template开发就会进入这个快速通道,使用render函数开发就不会:

template开发

<template>
    <div>
      <button @click="onClick">changeText</button>
      <h1>{{text}}</h1>
    </div>
</template>

当点击按钮更新了text的值,这里甚至都不会走到patchChildren,而是走到了这个快速通道之前的快速通道——patchBlockChildren

render函数:

function onClick(){
  text.value = 'Text'
}

function renderText() {
  return h('div', null, [
    h('button', {onClick}, 'click'),
    h('h1', null, text.value)
  ])
}

而这样写的时候,就会走到patchChildren普通通道

好了,patchChildren的逻辑咱们就说到这里,接下来我们直接看diff的部分(其他的都只是简单的替换文本、清空节点、挂载新dom,笔者在此就不过多赘述了,感兴趣的可自己看源码哦。)

diff

当新旧子节点都是 vnode 数组的时候,新的 vnode 数组旧的 vnode 数组相比可能存在节点的删除、新增、移动等操作,如果完全不考虑优化的话,假如新的数组长度为 n, 旧的 vnode 数组长度为 m,那么更新时的一个最糟糕的思路就是把旧的 m 个节点全部移除,然后创建 n 个新的节点全部插入。这样一来,当n、m 越来越大,操作的 dom 次数就越来越多了,然后浏览器也就会出现卡顿现象,带来糟糕的体验。因此就需要进行新旧 vnode 数组的比较,尽可能少的操作 dom,提升网页性能。

Vue 针对绑定了key(或者部分绑定了key) 和 没有绑定 key 的调用不同的方法去处理。 咱们一个一个来看,先看没绑定key的,要简单一点。

没写key - patchUnkeyedChildren

patchUnkeyedChildren的逻辑如下: Vue3源码阅读——多图带你看懂diff算法

逻辑非常的清晰,当没有 key 的时候,Vue 不能进行太多的优化。看一个例子帮助理解吧:

Vue3源码阅读——多图带你看懂diff算法

在上面的例子中,原先数组为['a','b','c','d'],我们往数组中 push 了一个 e,如果我们都没有绑定 key,那么按照patchUnkeyedChildren的逻辑只涉及到1dom 的变更。

那假如我们是往数组中 unshift 了一个 e呢?那就会像下面这样: Vue3源码阅读——多图带你看懂diff算法

从图中能够看出来,一共进行了 5dom 操作,虽然我们都是往数组中添加了一个元素,但是更新时却有那么大的差别。那这种积少成多,对性能的影响无疑是巨大的。这就是 Vue 要求我们要给列表项绑定 key 的原因

那接下来咱们看看绑定了keyVue会帮我们如何优化更新。😌

写了key - patchKeyedChildren

这个 patchKeyedChildren 的逻辑有点多,咱们一点一点来看,先看第一步和第二步,我管它叫做首尾公共子序列查找patch

首尾公共子序列查找patch

Vue3源码阅读——多图带你看懂diff算法

上图中的是 patchKeyedChildren 的第一步和第二步操作:

  1. 索引 i0,从头部开始比较,如果新旧子节点数组中相同索引的节点类型相同,则执行 patch,索引 i1,直到新旧子节点数组中相同索引的节点类型不相同 或者 i 不满足 i<= e1 &&i<= e2 为止。
  2. 从尾部开始比较,如果新旧子节点数组中对应节点的类型相同则执行 patch新的结束索引、旧的结束索引 同时 减 1,直到新旧子节点数组中对应节点的类型不相同 或者 i 不满足 i<= e1 &&<= e2 为止。

看文字可能会有点懵,那还是看图吧: Vue3源码阅读——多图带你看懂diff算法

还是前面章节的unshift的例子,从图中我们能够看到执行完第一步和第二步之后的 i = 0; e1 = -1; e2 = 0。好的,咱们接着看源码要怎么处理。

首尾公共子序列查找patch + mount

Vue3源码阅读——多图带你看懂diff算法

如上图所示,当满足 i > e1 && i <= e2 这个条件,说明新 vnode 数组中有节点需要mount, 先找到要插入节点的下一个节点——c2[e2+1].el, while循环判断是否满足条件,然后调用 patch 执行挂载,最终会调用insertBefore(c2[i], c2[e2+1].el)

那接着看上面的例子,i = 0; e1 = -1; e2 = 0 满足条件 i > e1 && i <= e2,找到nextPos = 1,anchor = a.el,将c2[0] = e 挂载到anchor前面完成更新,只操作了一次dom

Vue3源码阅读——多图带你看懂diff算法

对比一下没有绑定 key 的时候,优化的效果很明显吧!

那当我们首尾公共子序列查找过后,如果不满足i > e1 && i <= e2 这个条件,又该怎么做呢?接着看。

首尾公共子序列查找patch + unmount

Vue3源码阅读——多图带你看懂diff算法

如上图所示,如果不满足i > e1 && i <= e2 这个条件,就会接着判断是否满足 i > e2 && i <= e1 这个条件,如果满足说明 旧 vnode 数组中有节点需要unmountwhile循环判断是否满足条件,然后调用unmount。同样的,咱们还是来看例子。

还是之前的例子,这次咱们移除了b节点,更新过程如下所示。 Vue3源码阅读——多图带你看懂diff算法

上面这两种情况,i、e1、e2 的关系要么满足i > e2 && i <= e1(需要移除节点);要么满足i > e1 && i <= e2(需要挂载节点)。那当这两种情况都不满足的时候该如何处理呢?这种情况 Vue 叫它未知子序列,接下来我们看下对于这种情况的处理过程。

首尾公共子序列查找patch + 未知子序列

在处理未知子序列的更新时,源码中还是标注了很清晰的步骤,那接下来咱们一步一步的分析。

创建新节点数组key -> indexmap

对应源码如下: Vue3源码阅读——多图带你看懂diff算法

遍历旧节点数组 s1e1
Vue3源码阅读——多图带你看懂diff算法

在上图的代码中,就是处理未知子序列的第二步操作,遍历旧节点数组 s1e1的每一个节点,执行的逻辑可看图中标记的注释。其主要的目的如下:

  1. 将旧子节点数组中存在,但新子节点数组中不存在的的节点 unmount;存在的则调用 patch
  2. 记录 patch 的次数。
  3. 跟踪是否存在节点移动
  4. 生成 新节点旧节点数组中对应的索引(用于确定最长连续的子序列)。

如何理解最长连续的子序列?

最长连续的子序列意思就是:找到未知子序列中的一个顺序是按照旧节点数组中排列连续最长子序列(包括它本身)。这个连续的子序列中的节点不需要移动,只需要移动其他不连续的节点即可,最长意味着后续需要移动的节点越少,操作dom次数也就越少,效率越高。

move & mount(移动和挂载节点)

这一步就是处理未知子序列更新的最后一步了,根据第二步得到的——新节点旧节点数组中对应的索引,可以得到最长连续的子序列,然后就是遍历新节点数组,进行节点的移动挂载。具体逻辑如下图所示,可参考注释。 Vue3源码阅读——多图带你看懂diff算法

到此diff的全过程就完了,dom更新也结束了。接下来咱们通过一个例子来回味一遍整个流程。

Vue3源码阅读——多图带你看懂diff算法

总结

到此,diff的内容已全部完结,最后来概括一下大致内容:

  1. patchChildren逻辑解读
  2. 没有绑定keydiff算法
  3. 绑定了keydiff算法

很明显,Vue为了更新时的效率,做了很多工作。我们后续在使用Vue开发过程中,渲染列表一定要记得绑定key、并且尽量不要使用索引,只有这样,Vue才能更高效的更新dom

学习Vue源码,我觉得应该有以下几个方面的目标:

  1. 熟悉Vue的运行原理
  2. 通过学习源码,能更好的使用 Vue(最佳实践)
  3. 学习源码中的编码规范,比如:
  • 变量、函数、文件的命名
  • 项目模块的划分
  • 编码的技巧
  • 编码规范及优化
  1. 等等...

Vue2的diff可戳这里

最后,感谢阅读!

这是笔者第七篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!

如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【】都是我创作的最大动力 ^_^