写文章不容易,点个赞呗兄弟
专注 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工作原理,源码版助于了解内部详情,让我们一起学习吧
研究基于 Vue 版本 【2.5.17】
如果你觉得排版难看,请点击 下面链接 或者 拉到 下面 关注公众号 也可以吧
【Vue 原理】Diff – 源码版 之 Diff 流程
今天终于要开始探索 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 内容讲完了,谢谢大家的观看
最后
鉴于本人能力有限,难免会有疏漏错误的地方,请大家多多包涵,如果有任何描述不当的地方,欢迎后台联系本人,有重谢