Vue源码02 详解 vue 的 diff 算法
详解 vue 的 diff 算法。
本文由 简悦 SimpRead 转码, 原文地址 https://juejin.im/post/5affd01551882542c83301da
本文由 简悦 SimpRead 转码, 原文地址 https://juejin.im/post/5affd01551882542c83301da
前言
目标是写一个非常详细的关于 diff 的干货,所以本文有点长。也会用到大量的图片以及代码举例,一起来 get 吧。
先来了解几个点…
1. 当数据发生变化时,vue 是怎么更新节点的?
要知道渲染真实 DOM 的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实 dom 上会引起整个 dom 树的重绘和重排,有没有可能我们只更新我们修改的那一小块 dom 而不要更新整个 dom 呢?diff 算法能够帮助我们。
我们先根据真实 DOM 生成一颗virtual DOM
,当virtual DOM
某个节点的数据改变后会生成一个新的Vnode
,然后Vnode
和oldVnode
作对比,发现有不一样的地方就直接修改在真实的 DOM 上,然后使oldVnode
的值为Vnode
。
diff 的过程就是调用名为patch
的函数,比较新旧节点,一边比较一边给真实的 DOM 打补丁。
2. virtual DOM 和真实 DOM 的区别?
virtual DOM 是将真实的 DOM 的数据抽取出来,以对象的形式模拟树形结构。比如 dom 是这样的:
1 | <div> |
对应的 virtual DOM(伪代码):
1 | var Vnode = { |
(温馨提示:VNode
和oldVNode
都是对象,一定要记住)
3. diff 的比较方式?
在采取 diff 算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。
1 | <div> |
上面的代码会分别比较同一层的两个 div 以及第二层的 p 和 span,但是不会拿 div 和 span 作比较。在别处看到的一张很形象的图:
diff 流程图
当数据发生改变时,set 方法会让调用Dep.notify
通知所有订阅者 Watcher,订阅者就会调用patch
给真实的 DOM 打补丁,更新相应的视图。
具体分析
patch
来看看patch
是怎么打补丁的(代码只保留核心部分)
1 | function patch(oldVnode, vnode) { |
patch
函数接收两个参数oldVnode
和Vnode
分别代表新的节点和之前的旧节点
- 判断两节点是否值得比较,值得比较则执行
patchVnode
1 | function sameVnode(a, b) { |
- 不值得比较则用
Vnode
替换oldVnode
如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode
完全被改变了,就可以直接替换oldVnode
。
虽然这两个节点不一样但是他们的子节点一样怎么办?别忘了,diff 可是逐层比较的,如果第一层不一样那么就不会继续深入比较第二层了。(我在想这算是一个缺点吗?相同子节点不能重复利用了…)
patchVnode
当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode
方法。那么这个方法做了什么呢?
1 | patchVnode(oldVnode, vnode) { |
这个函数做了以下事情:
- 找到对应的真实 dom,称为
el
- 判断
Vnode
和oldVnode
是否指向同一个对象,如果是,那么直接return
- 如果他们都有文本节点并且不相等,那么将
el
的文本节点设置为Vnode
的文本节点。 - 如果
oldVnode
有子节点而Vnode
没有,则删除el
的子节点 - 如果
oldVnode
没有子节点而Vnode
有,则将Vnode
的子节点真实化之后添加到el
- 如果两者都有子节点,则执行
updateChildren
函数比较子节点,这一步很重要
其他几个点都很好理解,我们详细来讲一下 updateChildren
updateChildren
代码量很大,不方便一行一行的讲解,所以下面结合一些示例图来描述一下。
1 | updateChildren(parentElm, oldCh, newCh) { |
先说一下这个函数做了什么
- 将
Vnode
的子节点Vch
和oldVnode
的子节点oldCh
提取出来 oldCh
和vCh
各有两个头尾的变量StartIdx
和EndIdx
,它们的 2 个变量相互比较,一共有 4 种比较方式。如果 4 种比较都没匹配,如果设置了key
,就会用key
进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx
表明oldCh
和vCh
至少有一个已经遍历完了,就会结束比较。
图解 updateChildren
终于来到了这一部分,上面的总结相信很多人也看得一脸懵逼,下面我们好好说道说道。(这都是我自己画的,求推荐好用的画图工具…)
粉红色的部分为 oldCh 和 vCh
我们将它们取出来并分别用 s 和 e 指针指向它们的头 child 和尾 child
现在分别对oldS、oldE、S、E
两两做sameVnode
比较,有四种比较方式,当其中两个能匹配上那么真实 dom 中的相应节点会移到 Vnode 相应的位置,这句话有点绕,打个比方
- 如果是 oldS 和 E 匹配上了,那么真实 dom 中的第一个节点会移到最后
- 如果是 oldE 和 S 匹配上了,那么真实 dom 中的最后一个节点会移到最前,匹配上的两个指针向中间移动
- 如果四种匹配没有一对是成功的,分为两种情况
- 如果新旧子节点都存在 key,那么会根据
oldChild
的 key 生成一张 hash 表,用S
的 key 与 hash 表做匹配,匹配成功就判断S
和匹配节点是否为sameNode
,如果是,就在真实 dom 中将成功的节点移到最前面,否则,将S
生成对应的节点插入到 dom 中对应的oldS
位置,oldS
和S
指针向中间移动。 - 如果没有 key, 则直接将
S
生成新的节点插入真实DOM
(ps:这下可以解释为什么 v-for 的时候需要设置 key 了,如果没有 key 那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)
- 如果新旧子节点都存在 key,那么会根据
再配个图(假设下图中的所有节点都是有 key 的,且 key 为自身的值)
- 第一步
1 | oldS = a, oldE = d; |
oldS
和S
匹配,则将 dom 中的 a 节点放到第一个,已经是第一个了就不管了,此时 dom 的位置为:a b d
- 第二步
1 | oldS = b, oldE = d; |
oldS
和E
匹配,就将原本的 b 节点移动到最后,因为E
是最后一个节点,他们位置要一致,这就是上面说的:当其中两个能匹配上那么真实 dom 中的相应节点会移到 Vnode 相应的位置,此时 dom 的位置为:a d b
- 第三步
1 | oldS = d, oldE = d; |
oldE
和E
匹配,位置不变此时 dom 的位置为:a d b
- 第四步
1 | oldS++; |
遍历结束,说明oldCh
先遍历完。就将剩余的vCh
节点根据自己的的 index 插入到真实 dom 中去,此时 dom 位置为:a c d b
一次模拟完成。
这个匹配过程的结束有两个条件:
oldS > oldE
表示oldCh
先遍历完,那么就将多余的vCh
根据 index 添加到 dom 中去(如上图)S > E
表示 vCh 先遍历完,那么就在真实 dom 中将区间为[oldS, oldE]
的多余节点删掉
下面再举一个例子,可以像上面那样自己试着模拟一下
当这些节点sameVnode
成功后就会紧接着执行patchVnode
了,可以看一下上面的代码
1 | if (sameVnode(oldStartVnode, newStartVnode)) { |
就这样层层递归下去,直到将 oldVnode 和 Vnode 中的所有子节点比对完。也将 dom 的所有补丁都打好啦。那么现在再回过去看 updateChildren 的代码会不会容易很多呢?
总结
以上为 diff 算法的全部过程,放上一张文章开始就发过的总结图,可以试试看着这张图回忆一下 diff 的过程。
欢迎在评论区多多交流。
参考文章