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

前言

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

  1. 生成vnode到渲染vnode的过程是怎样的
  2. 组件创建及其初始化过程
  3. 响应式实现------reactive篇
  4. 响应式是如何实现的(ref + ReactiveEffect篇)
  5. 响应式是如何实现的(track + trigger篇)
  6. 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)的逻辑:

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

快速通道?

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

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

template开发

vue 复制代码
<template>
    <div>
      <button @click="onClick">changeText</button>
      <h1>{{text}}</h1>
    </div>
</template>

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

render函数:

js 复制代码
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的逻辑如下:

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

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

那假如我们是往数组中 unshift 了一个 e呢?那就会像下面这样:

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

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

写了key - patchKeyedChildren

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

首尾公共子序列查找patch

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

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

看文字可能会有点懵,那还是看图吧:

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

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

如上图所示,当满足 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

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

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

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

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

还是之前的例子,这次咱们移除了b节点,更新过程如下所示。

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

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

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

创建新节点数组key -> indexmap

对应源码如下:

遍历旧节点数组 s1e1

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

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

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

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

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

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

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

总结

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

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

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

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

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

Vue2的diff可戳这里

最后,感谢阅读!

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

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

相关推荐
程序媛小果3 分钟前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
小光学长7 分钟前
基于vue框架的的流浪宠物救助系统25128(程序+源码+数据库+调试部署+开发环境)系统界面在最后面。
数据库·vue.js·宠物
阿伟来咯~34 分钟前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端39 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱41 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai1 小时前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨1 小时前
在JS中, 0 == [0] 吗
开发语言·javascript
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云2 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js