前言
本文属于笔者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
开发
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
的逻辑只涉及到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
(最佳实践) - 学习源码中的编码规范,比如:
- 变量、函数、文件的命名
- 项目模块的划分
- 编码的技巧
- 编码规范及优化
- 等等...
最后,感谢阅读!
这是笔者第七篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞
】都是我创作的最大动力 ^_^
。