写文章不容易,点个赞呗兄弟
专注 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工作原理,源码版助于了解内部详情,让我们一起学习吧
研究基于 Vue版本 【2.5.17】
如果你觉得排版难看,请点击 下面链接 或者 拉到 下面关注公众号也可以吧
今天终于要开始探索 Vue 更新DOM 的重点了,就是 Diff
Diff 的内容不算多,但是如果要讲得很详细的话,就要说很多了,而且要配很多图
这是 Diff 的最后一篇文章,最重要也是最详细的一篇了
所以本篇内容很多,先提个内容概览
1、分析 Diff 源码比较步骤
2、个人思考为什么如此比较
3、写个例子,一步步走个Diff 流程
文章很长,也非常详细,如果你对这内容有兴趣的话,也推荐边阅读源码边看,如果你对本内容暂时没有了解,可以先看不涉及源码的白话版 Diff - 白话版
下面开始我们的正文
在之前一篇文章 Diff - 源码版 之 从新建实例到开始diff ,我们已经探索了 Vue 是如何从新建实例到开始diff 的
你应该还有印象,其中Diff涉及的一个重要函数就是 createPatchFunciton
var patch = createPatchFunction();
Vue.prototype.__patch__ = patch
那么我们就来看下这个函数
createPatchFunction
function createPatchFunction() {
return function patch(
oldVnode, vnode, parentElm, refElm
) {
// 没有旧节点,直接生成新节点
if (!oldVnode) {
createElm(vnode, parentElm, refElm);
}
else {
// 且是一样 Vnode
if (sameVnode(oldVnode, vnode)) {
// 比较存在的根节点
patchVnode(oldVnode, vnode);
}
else {
// 替换存在的元素
var oldElm = oldVnode.elm;
var _parentElm = oldElm.parentNode
// 创建新节点
createElm(vnode, _parentElm, oldElm.nextSibling);
// 销毁旧节点
if (_parentElm) {
removeVnodes([oldVnode], 0, 0);
}
}
}
return vnode.elm
}
}
这个函数的作用就是
比较 新节点 和 旧节点 有什么不同,然后完成更新
所以你看到接收一个 oldVnode 和 vnode
处理的流程分为
1、没有旧节点
2、旧节点 和 新节点 自身一样(不包括其子节点)
3、旧节点 和 新节点自身不一样
速度来看下这三个流程了
1 没有旧节点
没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了
直接全部都是新建,所以只调用 createElm
2 旧节点 和 新节点 自身一样
通过 sameVnode 判断节点是否一样,这个函数在上篇文章中说过了
旧节点 和 新节点自身一样时,直接调用 patchVnode 去处理这两个节点
patchVnode 下面会讲到这个函数
在讲 patchVnode 之前,我们先思考这个函数的作用是什么?
当两个Vnode自身一样的时候,我们需要做什么?
首先,自身一样,我们可以先简单理解,是 Vnode 的两个属性 tag 和 key 一样
那么,我们是不知道其子节点是否一样的,所以肯定需要比较子节点
所以,patchVnode 其中的一个作用,就是比较子节点
3 旧节点 和 新节点 自身不一样
当两个节点不一样的时候,不难理解,直接创建新节点,删除旧节点
patchVnode
在上一个函数 createPatchFunction 中,有出现一个函数 patchVnode
我们思考了这个函数的其中的一个作用是 比较两个Vnode 的子节点
是不是我们想的呢,可以先来过一下源码
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) return
var elm = vnode.elm = oldVnode.elm;
var oldCh = oldVnode.children;
var ch = vnode.children;
// 更新children
if (!vnode.text) {
// 存在 oldCh 和 ch 时
if (oldCh && ch) {
if (oldCh !== ch)
updateChildren(elm, oldCh, ch);
}
// 存在 newCh 时,oldCh 只能是不存在,如果存在,就跳到上面的条件了
else if (ch) {
if (oldVnode.text) elm.textContent = '';
for (var i = 0; i <= ch.length - 1; ++i) {
createElm(
ch[i],elm, null
);
}
}
else if (oldCh) {
for (var i = 0; i<= oldCh.length - 1; ++i) {
oldCh[i].parentNode.removeChild(el);
}
}
else if (oldVnode.text) {
elm.textContent = '';
}
}
else if (oldVnode.text !== vnode.text) {
elm.textContent = vnode.text;
}
}
我们现在就来分析这个函数
没错,正如我们所想,这个函数的确会去比较处理子节点
总的来说,这个函数的作用是
1、Vnode 是文本节点,则更新文本(文本节点不存在子节点)
2、Vnode 有子节点,则处理比较更新子节点
更进一步的总结就是,这个函数主要做了两种判断的处理
1、Vnode 是否是文本节点
2、Vnode 是否有子节点
下面我们来看看这些步骤的详细分析
1 Vnode是文本节点
当 VNode 存在 text 这个属性的时候,就证明了 Vnode 是文本节点
我们可以先来看看 文本类型的 Vnode 是什么样子
所以当 Vnode 是文本节点的时候,需要做的就是,更新文本
同样有两种处理
1、当 新Vnode.text 存在,而且和 旧 VNode.text 不一样时
直接更新这个 DOM 的 文本内容
elm.textContent = vnode.text;
注:textContent 是 真实DOM 的一个属性, 保存的是 dom 的文本,所以直接更新这个属性
2、新Vnode 的 text 为空,直接把 文本DOM 赋值给空
elm.textContent = '';
2 Vnode存在子节点
当 Vnode 存在子节点的时候,因为不知道 新旧节点的子节点是否一样,所以需要比较,才能完成更新
这里有三种处理
1、新旧节点 都有子节点,而且不一样
2、只有新节点
3、只有旧节点
后面两个节点,相信大家都能想通,但是我们还是说一下
1 只有新节点
只有新节点,不存在旧节点,那么没得比较了,所有节点都是全新的
所以直接全部新建就好了,新建是指创建出所有新DOM,并且添加进父节点的
2 只有旧节点
只有旧节点而没有新节点,说明更新后的页面,旧节点全部都不见了
那么要做的,就是把所有的旧节点删除
也就是直接把DOM 删除
3 新旧节点 都有子节点,而且不一样
咦惹,又出现了一个新函数,那就是 updateChildren
预告一下,这个函数非常的重要,是 Diff 的核心模块,蕴含着 Diff 的思想
可能会有点绕,但是不用怕,相信在我的探索之下,可以稍微明白些
同样的,我们先来思考下 updateChildren 的作用
记得条件,当新节点 和 旧节点 都存在,要怎么去比较才能知道有什么不一样呢?
哦没错,使用遍历,新子节点和旧子节点一个个比较
如果一样,就不更新,如果不一样,就更新
下面就来验证下我们的想法,来探索一下 updateChildren 的源码
updateChildren
这个函数非常的长,但是其实不难,就是分了几种处理流程而已,但是一开始看可能有点懵
或者可以先跳过源码,看下分析,或者便看分析边看源码
function updateChildren(parentElm, oldCh, newCh) {
var oldStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newStartIdx = 0;
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
// 不断地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode
while (
oldStartIdx <= oldEndIdx &&
newStartIdx <= newEndIdx
) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
}
else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
}
// 旧头 和新头 比较
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
// 旧尾 和新尾 比较
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
// 旧头 和 新尾 比较
else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
// oldStartVnode 放到 oldEndVnode 后面,还要找到 oldEndValue 后面的节点
parentElm.insertBefore(
oldStartVnode.elm,
oldEndVnode.elm.nextSibling
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
// 旧尾 和新头 比较
else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
// oldEndVnode 放到 oldStartVnode 前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
// 单个新子节点 在 旧子节点数组中 查找位置
else {
// oldKeyToIdx 是一个 把 Vnode 的 key 和 index 转换的 map
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyToOldIdx(
oldCh, oldStartIdx, oldEndIdx
);
}
// 使用 newStartVnode 去 OldMap 中寻找 相同节点,默认key存在
idxInOld = oldKeyToIdx[newStartVnode.key]
// 新孩子中,存在一个新节点,老节点中没有,需要新建
if (!idxInOld) {
// 把 newStartVnode 插入 oldStartVnode 的前面
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
else {
// 找到 oldCh 中 和 newStartVnode 一样的节点
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
// 删除这个 index
oldCh[idxInOld] = undefined;
// 把 vnodeToMove 移动到 oldStartVnode 前面
parentElm.insertBefore(
vnodeToMove.elm,
oldStartVnode.elm
);
}
// 只能创建一个新节点插入到 parentElm 的子节点中
else {
// same key but different element. treat as new element
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
}
// 这个新子节点更新完毕,更新 newStartIdx,开始比较下一个
newStartVnode = newCh[++newStartIdx];
}
}
// 处理剩下的节点
if (oldStartIdx > oldEndIdx) {
var newEnd = newCh[newEndIdx + 1]
refElm = newEnd ? newEnd.elm :null;
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
createElm(
newCh[newStartIdx], parentElm, refElm
);
}
}
// 说明新节点比对完了,老节点可能还有,需要删除剩余的老节点
else if (newStartIdx > newEndIdx) {
for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {
oldCh[oldStartIdx].parentNode.removeChild(el);
}
}
}
首先要明确这个函数处理的是什么
处理的是 新子节点 和 旧子节点,循环遍历逐个比较
如何 循环遍历?
1、使用 while
2、新旧节点数组都配置首尾两个索引
新节点的两个索引:newStartIdx , newEndIdx
旧节点的两个索引:oldStartIdx,oldEndIdx
以两边向中间包围的形式 来进行遍历
头部的子节点比较完毕,startIdx 就加1
尾部的子节点比较完毕,endIdex 就减1
只要其中一个数组遍历完(startIdx<endIdx),则结束遍历
源码处理的流程分为两个
1、比较新旧子节点
2、比较完毕,处理剩下的节点
我们来逐个说明这两个流程
1 比较新旧子节点
注:这里有两个数组,一个是 新子Vnode数组,一个旧子Vnode数组
在比较过程中,不会对两个数组进行改变(比如不会插入,不会删除其子项)
而所有比较过程中都是直接 插入删除 真实页面DOM
我们明确一点,比较的目的是什么?
找到 新旧子节点中 的 相同的子节点,尽量以 移动 替代 新建 去更新DOM
只有在实在不同的情况下,才会新建
比较更新计划步骤
首先考虑,不移动DOM
其次考虑,移动DOM
最后考虑,新建 / 删除 DOM
能不移动,尽量不移动。不行就移动,实在不行就新建
下面开始说源码中的比较逻辑
五种比较逻辑如下
1、旧头 == 新头
2、旧尾 == 新尾
3、旧头 == 新尾
4、旧尾 == 新头
5、单个查找
来分析下这五种比较逻辑
1 旧头 == 新头
sameVnode(oldStartVnode, newStartVnode)
当两个新旧的两个头一样的时候,并不用做什么处理
符合我们的步骤第一条,不移动DOM完成更新
但是看到一句,patchVnode
就是为了继续处理这两个相同节点的子节点,或者更新文本
因为我们不考虑多层DOM 结构,所以 新旧两个头一样的话,这里就算结束了
可以直接进行下一轮循环
newStartIdx ++ , oldStartIdx ++
2 旧尾 == 新尾
sameVnode(oldEndVnode, newEndVnode)
和 头头 相同的处理是一样的
尾尾相同,直接跳入下个循环
newEndIdx ++ , oldEndIdx ++
3 旧头 == 新尾
sameVnode(oldStartVnode, newEndVnode)
这步不符合 不移动DOM,所以只能 移动DOM 了
怎么移动?
源码是这样的
parentElm.insertBefore(
oldStartVnode.elm,
oldEndVnode.elm.nextSibling
);
以 新子节点的位置 来移动的,旧头 在新子节点的 末尾
所以把 oldStartVnode 的 dom 放到 oldEndVnode 的后面
但是因为没有把dom 放到谁后面的方法,所以只能使用 insertBefore
即放在 oldEndVnode 后一个节点的前面
图示是这样的
然后更新两个索引
oldStartIdx++,newEndIdx--
4 旧尾 == 新头
sameVnode(oldEndVnode, newStartVnode)
同样不符合 不移动DOM,也只能 移动DOM 了
怎么移动?
parentElm.insertBefore(
oldEndVnode.elm,
oldStartVnode.elm
);
把 oldEndVnode DOM 直接放到 当前 oldStartVnode.elm 的前面
图示是这样的
然后更新两个索引
oldEndIdx--,newStartIdx++
5 单个遍历查找
当前面四种比较逻辑都不行的时候,这是最后一种处理方法
拿 新子节点的子项,直接去 旧子节点数组中遍历,找一样的节点出来
流程大概是
1、生成旧子节点数组以 vnode.key 为key 的 map 表
2、拿到新子节点数组中 一个子项,判断它的key是否在上面的map 中
3、不存在,则新建DOM
4、存在,继续判断是否 sameVnode
下面就详细说一下
1 生成map 表
这个map 表的作用,就主要是判断存在什么旧子节点
比如你的旧子节点数组是
[{
tag:"div", key:1
},{
tag:"strong", key:2
},{
tag:"span", key:4
}]
经过 createKeyToOldIdx 生成一个 map 表 oldKeyToIdx
{ vnodeKey: 数组Index }
属性名是 vnode.key,属性值是 该 vnode 在children 的位置
是这样(具体源码看上篇文章 Diff - 源码版 之 相关辅助函数)
oldKeyToIdx = {
1:0,
2:1,
4:2
}
2 判断 新子节点是否存在旧子节点数组中
拿到新子节点中的 子项Vnode,然后拿到它的 key
去匹配map 表,判断是否有相同节点
oldKeyToIdx[newStartVnode.key]
3 不存在旧子节点数组中
直接创建DOM,并插入oldStartVnode 前面
createElm(newStartVnode, parentElm, oldStartVnode.elm);
4 存在旧子节点数组中
找到这个旧子节点,然后判断和新子节点是否 sameVnode
如果相同,直接移动到 oldStartVnode 前面
如果不同,直接创建插入 oldStartVnode 前面
我们上面说了比较子节点的处理的流程分为两个
1、比较新旧子节点
2、比较完毕,处理剩下的节点
比较新旧子节点上面已经说完了,下面就到了另一个流程,比较剩余的节点,详情看下面
处理可能剩下的节点
在updateChildren 中,比较完新旧两个数组之后,可能某个数组会剩下部分节点没有被处理过,所以这里需要统一处理
1 新子节点遍历完了
newStartIdx > newEndIdx
新子节点遍历完毕,旧子节点可能还有剩
所以我们要对可能剩下的旧节点进行 批量删除!
就是遍历剩下的节点,逐个删除DOM
for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) {
oldCh[oldStartIdx]
.parentNode
.removeChild(el);
}
2旧子节点遍历完了
oldStartIdx > oldEndIdx
旧子节点遍历完毕,新子节点可能有剩
所以要对剩余的新子节点处理
很明显,剩余的新子节点不存在 旧子节点中,所以全部新建
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
createElm(
newCh[newStartIdx],
parentElm,
refElm
);
}
但是新建有一个问题,就是插在哪里?
所以其中的 refElm 就成了疑点,看下源码
var newEnd = newCh[newEndIdx + 1]
refElm = newEnd ? newEnd.elm :null;
refElm 获取的是 newEndIdx 后一位的节点
当前没有处理的节点是 newEndIdx
也就是说 newEndIdx+1 的节点如果存在的话,肯定被处理过了
如果 newEndIdx 没有移动过,一直是最后一位,那么就不存在 newCh[newEndIdx + 1]
那么 refElm 就是空,那么剩余的新节点 就全部添加进 父节点孩子的末尾,相当于
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
parentElm.appendChild(
newCh[newStartIdx]
);
}
如果 newEndIdx 移动过,那么就逐个添加在 refElm 的前面,相当于
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
parentElm.insertBefore(
newCh[newStartIdx] ,
refElm
);
}
如图
思考为什么这么比较
我们已经讲完了所有 Diff 的内容,大家也应该能领悟到 Diff 的思想
但是我强迫自己去思考一个问题,就是
为什么会这样去比较?
以下纯属个人意淫想法,没有权威认证,仅供参考
我们所有的比较,都是为了找到 新子节点 和 旧子节点 一样的子节点
而且我们的比较处理的宗旨是
1、能不移动,尽量不移动
2、没得办法,只好移动
3、实在不行,新建或删除
首先,一开始比较,肯定是按照我们的第一宗旨 不移动 ,找到可以不移动的节点
而 头头,尾尾比较 符合我们的第一宗旨,所以出现在最开始,嗯,这个可以想通
然后就到我们的第二宗旨 移动,按照 updateChildren 的做法有
旧头新尾比较,旧尾新头比较,单个查找比较
我开始疑惑了,咦?头尾比较为了移动我知道,但是为什么要出现这种比较?
明明我可以用 单个查找 的方式,完成所有的移动操作啊?
我思考了很久,头和尾的关系,觉得可能是为了避免极端情况的消耗??
怎么说?
比如当我们去掉头尾比较,全部使用单个查找的方式
如果出现头 和 尾 节点一样的时候,一个节点需要遍历 从头找到尾 才能找到相同节点
这样实在是太消耗了,所以这里加入了 头尾比较 就是为了排除 极端情况造成的消耗操作
当然,这只是我个人的想法,仅供参考,虽然这么说,我也的确做了个例子测试
子节点中加入了出现两个头尾比较情况的子项 b div
oldCh = ['header','span','div','b']
newCh = ['sub','b','div','strong']
使用 Vue 去更新,比较更新速度,然后更新十次,计算平均值
1、全用 单个查找,用时 0.91ms
2、加入头尾比较,用时 0.853ms
的确是快一些喔
走流程
我相信经过这么长的一篇文章,大家的脑海中还没有把所有的知识点集合起来,可能对整个流程还有点模糊
没事,我们现在就来举一个例子,一步步走流程,完成更新
以下的节点,绿色表示未处理,灰色表示已经处理,淡绿色表示正在处理,红色表示新插入,如下
现在Vue 需要更新,存在下面两组新旧子节点,需要进行比较,来判断需要更新哪些节点
1头头比较,节点一样,不需移动,只用更新索引
更新索引,newStartIdx++ , oldStartIdx++
开始下轮处理
一系列判断之后,【旧头 2】 和 【 新尾 2】相同,直接移动到 oldEndVnode 后面
更新索引,newEndIdx-- ,oldStartIdx ++
开始下轮处理
3一系列判断之后,【旧头 2】 和 【 新尾 2】相同,直接移动到 oldStartVnode 前面
更新索引,oldEndIdx-- ,newStartIdx++
开始下轮比较
4只剩一个节点,走到最后一个判断,单个查找
找不到一样的,直接创建插入到 oldStartVnode 前面
更新索引,newStartIdx++
此时 newStartIdx> newEndIdx ,结束循环
5 批量删除可能剩下的老节点
此时看 旧 Vnode 数组中, oldStartIdx 和 oldEndIdx 都指向同一个节点,所以只用删除 oldVnode-4 这个节点
ok,完成所有比较流程
耶,Diff 内容讲完了,谢谢大家的观看
最后
鉴于本人能力有限,难免会有疏漏错误的地方,请大家多多包涵,如果有任何描述不当的地方,欢迎后台联系本人,有重谢
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。