前言
本文属于笔者Vue3源码阅读系列第七篇文章,往期精彩:
- 生成
vnode
到渲染vnode
的过程是怎样的 - 组件创建及其初始化过程
- 响应式实现——reactive篇
- 响应式是如何实现的(ref + ReactiveEffect篇)
- 响应式是如何实现的(track + trigger篇)
- Vue3源码阅读——组件更新的流程是怎样的
我们都知道,在 Vue
开发的 Web
应用中,它由非常多个 Vue
组件搭建组成,每一个组件又是由很多 dom
节点构成,并且这些 dom
节点并不是一直在网页中一成不变,它们会随着用户的操作动态变化,比如:
- 节点的内容变化
- 样式变化(
style/class
) - 节点变化(变成了另外一个节点,或者另外一些节点)
- 节点属性变化
- 等等...
当然用户(开发者)并不直接操作 dom
,用户操作的是与这些动态节点绑定的状态,当状态变更,Vue
会帮我们更新 dom
。更新 dom
并不难,难的是如何高效的更新 dom
,接下来咱们就一起来学习一下 Vue
是如何帮助我们高效(或者说尽可能高效)的更新 dom
(diff
的意义所在)。
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
的类型执行相应的逻辑。这就是 Vue3
的 compiler
的功劳了,就像在上一篇文章中讲 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
的逻辑如下:
逻辑非常的清晰,当没有 key
的时候,Vue
不能进行太多的优化。看一个例子帮助理解吧:
在上面的例子中,原先数组为['a','b','c','d']
,我们往数组中 push
了一个 e
,如果我们都没有绑定 key
,那么按照patchUnkeyedChildren
的逻辑只涉及到1
次 dom
的变更。
那假如我们是往数组中 unshift
了一个 e
呢?那就会像下面这样:
从图中能够看出来,一共进行了 5
次 dom
操作,虽然我们都是往数组中添加了一个元素,但是更新时却有那么大的差别。那这种积少成多,对性能的影响无疑是巨大的。这就是 Vue
要求我们要给列表项绑定 key
的原因。
那接下来咱们看看绑定了key
,Vue
会帮我们如何优化更新。😌
写了key - patchKeyedChildren
这个 patchKeyedChildren
的逻辑有点多,咱们一点一点来看,先看第一步和第二步,我管它叫做首尾公共子序列查找patch。
首尾公共子序列查找patch
上图中的是 patchKeyedChildren
的第一步和第二步操作:
- 索引
i
为0
,从头部开始比较,如果新旧子节点数组中相同索引的节点类型相同,则执行patch
,索引i
加1
,直到新旧子节点数组中相同索引的节点类型不相同 或者i
不满足i<= e1 &&i<= e2
为止。 - 从尾部开始比较,如果新旧子节点数组中对应节点的类型相同则执行
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
数组中有节点需要unmount
,while
循环判断是否满足条件,然后调用unmount
。同样的,咱们还是来看例子。
还是之前的例子,这次咱们移除了b
节点,更新过程如下所示。
上面这两种情况,i、e1、e2
的关系要么满足i > e2 && i <= e1
(需要移除节点);要么满足i > e1 && i <= e2
(需要挂载节点)。那当这两种情况都不满足的时候该如何处理呢?这种情况 Vue
叫它未知子序列,接下来我们看下对于这种情况的处理过程。
首尾公共子序列查找patch + 未知子序列
在处理未知子序列的更新时,源码中还是标注了很清晰的步骤,那接下来咱们一步一步的分析。
创建新节点数组key -> index
的map
对应源码如下:
遍历旧节点数组 s1
到 e1
在上图的代码中,就是处理未知子序列的第二步操作,遍历旧节点数组 s1
到 e1
的每一个节点,执行的逻辑可看图中标记的注释。其主要的目的如下:
- 将旧子节点数组中存在,但新子节点数组中不存在的的节点
unmount
;存在的则调用patch
。 - 记录
patch
的次数。 - 跟踪是否存在节点移动。
- 生成 新节点在旧节点数组中对应的索引(用于确定最长连续的子序列)。
如何理解最长连续的子序列?
最长连续的子序列意思就是:找到未知子序列中的一个顺序是按照旧节点数组中排列、连续、最长的子序列(包括它本身)。这个连续的子序列中的节点不需要移动,只需要移动其他不连续的节点即可,最长意味着后续需要移动的节点越少,操作dom
次数也就越少,效率越高。
move & mount(移动和挂载节点)
这一步就是处理未知子序列更新的最后一步了,根据第二步得到的——新节点在旧节点数组中对应的索引,可以得到最长连续的子序列,然后就是遍历新节点数组,进行节点的移动和挂载。具体逻辑如下图所示,可参考注释。
到此diff的全过程就完了,dom
更新也结束了。接下来咱们通过一个例子来回味一遍整个流程。
总结
到此,diff
的内容已全部完结,最后来概括一下大致内容:
patchChildren
逻辑解读- 没有绑定
key
的diff
算法 - 绑定了
key
的diff
算法
很明显,Vue
为了更新时的效率,做了很多工作。我们后续在使用Vue
开发过程中,渲染列表一定要记得绑定key
、并且尽量不要使用索引,只有这样,Vue
才能更高效的更新dom
。
学习Vue
源码,我觉得应该有以下几个方面的目标:
- 熟悉
Vue
的运行原理 - 通过学习源码,能更好的使用
Vue
(最佳实践) - 学习源码中的编码规范,比如:
- 变量、函数、文件的命名
- 项目模块的划分
- 编码的技巧
- 编码规范及优化
- 等等...
最后,感谢阅读!
这是笔者第七篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞
】都是我创作的最大动力 ^_^
。