“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情”
前言
现在社会各行各业大都面临着寒冬,互联网行业最近还出现了裁员潮,导致前端是越来越卷,普通学校的应届生不仅要跟985、211毕业的学生以及研究生进行竞争,甚至还需要和最近刚被裁的、有了几年工作经验的程序员竞争,害,趁我还年轻,赶紧努力考个公吧😭
玩笑归玩笑,但是现在就业形势确实严峻,前端面试时考查的难度也越来越大,面试官会问很多关于框架原理的知识点以区分候选人的水平和学习能力,而 diff
算法就属于面试中的高频问题,下面我们就来了解一下 diff
算法的核心思想,希望能让大家再被问到 diff
算法的时候能更加自信~
虚拟 DOM
在学习 diff
算法之前,我们必须要先清楚虚拟 DOM
的概念,因为 Vue
中的 diff
算法比对的不是别人,正是虚拟 DOM
,那虚拟 DOM
到底是什么呢?(面试的时候可能会问)
虚拟 DOM
是用来表示真实 DOM
的 JS
对象,该对象并没有真实 DOM
那么多的属性,它有的只是个别用来描述真实 DOM
的属性,比如真实 DOM
的元素类型、其对应的子元素(也是虚拟 DOM
)等等,下面我们先来看一下 Vue
中的虚拟 DOM
到底长什么样?
<ul id='text-list'>
<li class="item1">你好呀</li>
<li class="item2">我是xxx</li>
<li class="item3">我们一起学习前端吧~</li>
</ul>
上面是我们再熟悉不过的一段 html
段落,看看它们被转为虚拟 DOM
之后会变成什么样吧~🤩
{
tagName: 'ul',
props: {
id: 'text-list'
},
children: [{
tagName: 'li', props: { class: 'item1' }, text: '你好呀'
},
{
tagName: 'li', props: { class: 'item2' }, text: '我是xxx'
},
{
tagName: 'li', props: { class: 'item3' }, text: '我们一起学习前端吧~'
}]
}
果不其然,和我们说的一样,虚拟 DOM
就是一个 JS
对象,其上面的属性不多,都是用来描述真实 DOM
的结构的,比如说 tagName
是对应的元素名,props
是添加到元素上的属性,children
是由其子元素所组成的数组,每一个子元素也都是一个完整的虚拟 DOM
Diff 算法
介绍完了虚拟 DOM
之后,我们终于可以来讲讲什么是 diff
算法了,相信很多开发者都听过这个名词,但很多人会误认为 diff
算法是 React
或者 Vue
这种框架发明的,其实并不是,diff
很早就有了,它就是一种用来寻找两者差异的算法,那框架中的 diff
算法和传统的有什么区别呢?
1. 比较的目标不同
在框架中使用 diff
算法比对的目标是虚拟 DOM
,这是在前端框架中才有的概念,但是最终的目的都是一样的,就是为了找出两者的不同,所以也可以将 diff
算法理解成专门用来找不同的算法
2. 比较结果的用处不同
在其它应用场景中使用 diff
算法可能只是为了知道双方是否完全相同,但是在框架中,在虚拟 DOM
的基础上,diff
算法判定为类型相等的节点可以进行复用,判定为不同的节点则会删除重建,这也是 diff
算法最核心的思想—复用
3. 比较的方式不同
虚拟 DOM
从形状上来看就是一棵树, 新旧虚拟 DOM
进行比对的时候,每一层级的节点只会和同层级的节点进行比较,不会跨层级比较,但这样不就做不到完全复用了吗?其实这是出于性能考虑的最佳方案,如果旧虚拟 DOM
上面的一个节点要和新虚拟 DOM
的所有节点进行比较,虽然可以最大程度上的复用节点,但是同时也会因为比较次数过多而大量的消耗性能,为什么那么说呢?
假如旧树上有 n
个节点,每个节点都需要和新树上的 n
个节点进行比较,找到不同的节点之后还需要进行各种操作(替换、删除、增加,时间复杂度为 O(n)
),这样一来,新旧节点使用 diff
算法比较的时间复杂度就为 O(n^3)
,相当于三重 for
循环,这样太消耗性能了,于是框架对传统的 diff
算法进行了改造,选取了一种折中的方式——同级比较
如下图所示,只有在同一层的节点才会进行比较,而且只比较相同位置的节点,这样时间复杂度就降低到了 O(n)
。如果一个节点在比对的时候判定为不相同,并且它还有子节点,那该节点会被直接删除,而不深度遍历子节点进行比较
Snabbdom 源码分析
看到这个小标题你可能有点懵,这篇文章不应该是讲 diff
算法的吗?在介绍框架的 diff
算法之前,我们必须要知道 Vue2
并没有选择自己重新造一套 Virtual-DOM
的算法,而是在 Snabbdom
这个库的基础上构建了一个嵌入了框架本身的 fork
版本
所以说,Vue2
的 diff
算法就是在原有 Snabbdom
进行改造得到的,而 Vue3
的 diff
算法又是在 Vue2
的基础上改良的,React
的 diff
算法又和 Vue
大同小异,有异曲同工之妙。搞懂了 Snabbdom
的原理,框架中的 diff
算法自然也就理解了,虽然有部分逻辑不一致,但核心思想还是很相似的;还有一个原因就是 Snabbdom
不仅涵盖了 diff
算法的核心思想,而且由于源码并不涉及框架中的额外操作,所以阅读起来会简单很多
以上就是这篇文章为什么这篇文章不去专门解读 Vue
和 React
的源码,而是去研究 Snabbdom
源码的原因,这也对应了本篇文章的标题:不会 Vue
,但不影响大家学习 diff
算法,因为其不是一个具体的东西,它只是一种思想,不一定只在 Vue
或 React
这样的框架中才会应用到
h 函数
框架都有一个将开发者书写的代码转换为虚拟 DOM
的函数,比如在 React
中,jsx
语法是 React.createElement
的语法糖,虚拟 DOM
(在 React
中称为 react element
)就是该函数创建出来的,而 Snabbdom
创建虚拟 DOM
用的则是 h
函数,下面我们看看它是如何进行创建虚拟 DOM
的吧~
// h.ts
// 参数 sel 是一个由 元素标签和选择器组成的字符串,比如 'div.item' 表示元素为 div、类名为 item
function h(sel: any, b?: any, c?: any): VNode {
let data: VNodeData = {}; // 相当于添加在元素上的属性,比如 style、className 等
let children: any; // 当前节点下的文本,注意:文本和子元素只能存在一个
let text: any; // 当前节点对应的子元素
let i: number;
// 前面一堆操作都是在判断有没有正确传入对应的参数
if (c !== undefined) {
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
// 判断是否为基本数据类型
} else if (is.primitive(c)) {
text = c.toString(); // 如果子节点是纯文本的话,就赋值给text变量;其他c存在的情况都赋值给children变量,这就是为什么children变量和text变量只有一个有值或者两个都没有值的原因
} else if (c && c.sel) {
children = [c];
}
} else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b;
} else if (is.primitive(b)) {
text = b.toString();
} else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i]))
children[i] = vnode( // 将子节点用 vnode 函数创建成虚拟节点后赋值给 children
undefined,
undefined,
undefined,
children[i],
undefined
);
}
}
// svg 元素特殊处理
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
// 实际上调用的还是
return vnode(sel, data, children, text, undefined); // h函数创建虚拟节点其实是通过vnode函数来完成的,前面都是再处理传递进来的参数
}
// vnode.ts
function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | DocumentFragment | Text | undefined
): VNode {
const key = data === undefined ? undefined : data.key;
return {
sel,
data,
children,
text,
elm,
key
};
}
我们可以看到 vnode
也就是创建出来的虚拟 DOM
是通过 h
函数来生成的,它接受三个参数:sel
、data
以及 children
;h
函数又依赖于 vnode
函数来返回最终的虚拟 DOM
,可以看到其很明显返回的就是一个 JS
对象,并没有那么高大上
下面简单介绍一下 Snabbodom
中的虚拟 DOM
有哪些属性?(不同的框架虚拟 DOM
所具有的属性可能不同)
sel
表示的是由元素对应的标签名和选择器组成的字符串
data
里面是元素身上的属性,比如说className
、style
等等
children
和text
都是节点的内容,所以这两个只会有一个属性有值
elm
是这个虚拟DOM
所对应的真实DOM
key
其实是从data
中提取出来的,也就是我们在列表list
中为每一项item
添加的key
patch 函数
上面我们已经说过了虚拟 DOM
是通过 h
函数创建出来的,那么虚拟 DOM
又是怎么过渡成真实 DOM
的呢?这就是 patch
函数的功劳了,因为在虚拟 DOM
变成真实 DOM
之前,肯定要先经过 diff
算法的比较,因为这样才能复用原先的节点,而我们今天所谈论的 diff
算法主要就是在patch
函数里面实现的。该函数共有两种使用场景:
- 第一种是页面首次渲染时,将
vnode
渲染到一个空的容器(该容器是一个真实的DOM
元素)里面:patch(container, vnode)
- 第二种是用新的
vnode
替换老的vnode
:patch(vnode, newVnode)
// init.ts
function patch(
oldVnode: VNode | Element | DocumentFragment, // oldVnode 对应了三种类型,你可以传递一个 vnode 进去,也可以传一个真实 DOM 进去
vnode: VNode // 新的 vnode
): VNode {
let i: number, elm: Node, parent: Node;
// 如果是页面首次渲染,那么传入进来的 oldVnode 就是一个真实 DOM 元素,将会命中这个逻辑
if (isElement(api, oldVnode)) {
// 创建一个空的 Vnode,并关联与之对应的 DOM 元素
// 绑定 DOM 元素的作用:下次我们再做更新的时候,就知道要操作哪个 DOM 元素了
oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
oldVnode = emptyDocumentFragmentAt(oldVnode);
}
// 判断新旧节点是否相同,key 和 sel 相同我们就认为是同一个节点
if (sameVnode(oldVnode, vnode)) {
// 如果相同说明该节点可以复用,只需要执行 patchVnode 更新节点就可以了
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm; // 记录下旧的真实Dom元素
parent = api.parentNode(elm) as Node; // 找到当前节点真实 DOM 元素的父元素
// 创建出一个新的真实 DOM 元素并绑定到 vnode 上
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 只要父元素存在,则就将新创建的 Dom 元素插入到父元素下对应的位置
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
// 移除掉旧的真实Dom元素
removeVnodes(parent, [oldVnode], 0, 0);
}
}
return vnode; // 返回已经好真实Dom元素的节点
};
}
// sameVnode 函数;比较新旧虚拟 Dom 的 key 和 sel 是否相同,如果都相同的话就把它当成是同一个节点,否则就判定为不同的节点
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
return isSameSel && isSameKey && isSameIs;
}
patch
函数的逻辑其实并不复杂,不过在里面有个分水岭—sameVnode
,该函数是用来判别两个节点类型是否相同的,从上述的代码中可以看到 sameVnode
在比对两个 vnode
是否相同的时候,比较的是 key
和 sel
,其中这个 sel
包含了标签名、选择器(类名、id
名),该函数对应的流程图如下所示:
patchVnode 函数
sameVnode
函数并没有对比节点的内容,如果连内容也不对比就直接拿老的 vnode
对应的真实 DOM
复用,,那么很容易出现组件中两次状态明显不一致,但对应的 DOM
元素内容却没有更新的情况,所以当新旧节点类型一致时,我们还需要使用 patchVnode
函数对它们的子元素或文本进行更新,这样才能确保最终渲染的真实 DOM
是正确的
在 patchVnode
内部又分多种情况,看之前请大家记住一句话:更新过程中要以新的 Vnode 为标准,新旧节点所有的更新都是围绕着这样一句话来的,下面来看一下 patchVnode
函数的思维导图:
如果你想探索上面具体每一步是怎么做的,那么我们一起来看看它的源码就知道了,其实 Snabbdom
的源码真不难,只要用心看,我相信大家都能够看懂
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
// 取出旧节点绑定的真实 Dom 元素并让新 vnode 也与之绑定
// 因为执行了 patchVnode 函数的基础就是新旧节点类型相同,故对应的真实 Dom 元素不会再重建,只需更新即可,将旧的真实 Dom 关联到新的 vnode 上有利于后续操作 Dom 更新元素
const elm = vnode.elm = oldVnode.elm;
// 取出新旧节点的子元素
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
// 新旧 vnode 引用地址相等的话,则不需要做更新,说明该节点完全可以复用,直接返回即可
if (oldVnode === vnode) return;
// 新节点 text 不存在时
if (isUndef(vnode.text)) {
// 如果两个节点的子元素都存在且引用值不相同,则执行 updateChildren 函数进行更新
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
// 如果新的 vnode 有孩子,则老的 vnode 一定没有孩子,因为大前提是两个节点的孩子不能同时存在,但还不能确定老的 vnode 有没有 text
} else if (isDef(ch)) {
// 判断老节点的 text 存不存在,如果存在则清空掉
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
// 通过 addVnodes 方法将新 vnode 对应的孩子转化为真实 Dom 后添加到父元素上
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果老 vnode 的孩子存在的话,代码执行到这里说明新 vnode 既没有孩子也没有 text,所以只需删除原先 Dom 的子节点就行
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 老 vnode 的文本存在,但代码能执行到这里,说明新 vnode 就是一副空壳而已,只需要清除文本即可
api.setTextContent(elm, "");
}
// text存在时,新节点的子元素一定是不存在;新旧节点的text不相等,则需要更新文本
} else if (oldVnode.text !== vnode.text) {
// 注意:老的 vnode 是一定有关联真实 Dom 的。所以如果老节点的子元素存在,则需要先移除掉真实 Dom 元素的子节点后再设置文本
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 为新节点设置文本内容
api.setTextContent(elm, vnode.text!);
}
}
updateChildren 函数
如果要说 diff
算法中哪两个函数最关键,我的回答一定是 sameVnode
和 updateChildren
函数,因为 sameVnode
函数中包含了两个节点属于相同类型的条件,这是节点能够复用的门槛,diff
算法的核心不就是复用吗,可想而知该函数的重要程度;其次就是 updateChildren
函数,因为在项目中一棵组件树大量的节点都包含子元素,需要不停的调用 updateChildren
来进行子元素的更新,而且 diff
算法最核心、复杂的逻辑也在这个函数中,下面还是先看看逻辑的流程图,这样大家在看源码之前心里有个大概,阅读的时候会更容易理解
下图为首尾指针一开始的指针指向,方便大家理解
// init.ts
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
// 我们用首尾指针来追踪要比较的节点,指针就是他们在数组中的索引
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
// 指针对应的值就是子元素节点
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
// 当双指针不满足条件时退出循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果老孩子的指针指向的节点为空,那么说明该节点已经被第二阶段复用了,跳过该节点
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
// 当旧孩子首指针与新孩子首指针对应的 vnode 类型相同时,对节点做一个更新但是不需要移动真实 Dom 元素
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 递归调用 patchVnode 继续更新子节点,同时也说明 diff 算法是深度遍历的
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
// 两个指针都向右移动一位
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
// 当旧孩子尾指针与新孩子尾指针对应的 vnode 类型相同时,对节点做一个更新但是不需要移动真实 Dom 元素
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
// 当旧孩子首指针与新孩子尾指针对应的 vnode 类型相同时,既需要进行更新,还需要移动 Dom 元素的位置
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
// 将老节点首指针对应的 DOM 元素移动到老节点尾指针右侧对应的 DOM 元素之前
api.insertBefore(
parentElm,
oldStartVnode.elm,
// 老节点的首指针与新节点的尾指针相等,我们最终的 DOM 又是以新节点为准的,那肯定是需要插入未排序的最后一个位置,最终成为尾部的第一个元素
api.nextSibling(oldEndVnode.elm) // 此时 DOM 元素位置已经更新了!!!老孩子尾指针对应的 DOM 元素就是未排序的最后一个元素,它的下一个元素就是已排序好的结点中尾部的第一个元素
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
// 当旧孩子尾指针与新孩子首指针对应的vnode类型相同时,既需要进行更新,还需要移动Dom元素的位置
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
// 将老节点尾指针对应的 DOM 元素插入到老节点首指针对应的 DOM 元素前
// 老节点的尾指针与新节点的首指针相等,我们最终的 DOM 又是以新节点为准的,那肯定是需要插入未排序的第一个位置
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); // 此时老节点的首指针对应的节点就是未排序的第一个元素,插入到它前面去就成了首部的最后一个元素
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 如果上述条件都不符合,则通过 key 去寻找能够复用的Dom元素
} else {
// oldKeyToIdx 还未被赋值时,将旧孩子首尾指针之间的 key 和对应的索引下标以键值对的形式利用 createKeyToOldIdx 函数保存到 JS 对象中
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 通过新孩子首指针对应节点的 key 去对象中寻找对应老孩子的索引
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 在哈希表(就是刚刚说到的对象)中没有找到对应的索引值,则不存在复用 Dom 元素的机会了
if (isUndef(idxInOld)) {
// 现在相当于是将能复用的老节点放到未排序的首位,因为遍历新节点是从左到右的
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm // 此时老节点的首指针对应的节点就是未排序的第一个元素,插入到它前面去就成了首部的最后一个元素
);
//在对象中找到了对应的索引值,根据索引查询的老孩子中可能复用的 vnode
} else {
elmToMove = oldCh[idxInOld];
// 因为这条语句的大前提是 key 值相等,所以只需要通过比较选择器是否相同就可以确定两个 vnode 类型是否相同
if (elmToMove.sel !== newStartVnode.sel) {
// 选择器不同说明两个 vnode 的类型不一样,不能够复用,直接创建一个新的Dom元素插入到父元素中即可
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm
);
// 选择器相同说明两个 vnode 的类型一致,可以复用,利用 patchVnode 函数进行更新即可
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
// 老孩子复用完之后清空
oldCh[idxInOld] = undefined as any;
// 移动可以复用的 Dom 元素到指定的位置
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
}
}
// 当前的新孩子要么已经重新创建,要么已经复用,所以新孩子的首指针需要向右移动一位
newStartVnode = newCh[++newStartIdx];
}
}
// 退出 while 循环只代表着新旧孩子有一方的双指针不满足条件,但有一方的新旧指针可能还没有相遇
// 新孩子的双指针还没有相遇,说明老孩子都已经复用完毕了,但还有子元素没有被处理
if (newStartIdx <= newEndIdx) {
// before 是向父元素插入真实 Dom 元素时的参考节点,为已排序好的子元素尾部的第一个元素
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// 遍历创建新节点对应的 DOM 元素并插入到对应的位置
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
// 老孩子的双指针还没有相遇过,说明新孩子对应的 DOM 都已创建完全,但还有旧的真实 Dom 没有被移除
if (oldStartIdx <= oldEndIdx) {
// 通过 removeVnodes 函数移除旧孩子首尾指针之间的所有真实 Dom
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
可以看到,双端比较法并不是 updateChildren
函数的全部,因为可能会出现子元素还没有遍历完,但当前的四个指针指向的元素都不是同种类型时,如果没有后续的处理,一些夹杂在首尾指针之间还没有被遍历到但能够被复用的元素就被忽视了,这样就达不到最大限度复用 DOM
元素的目的了,那 diff
算法所带来的收益也会随之减弱
Snabbdom
会把老孩子首指针和尾指针之间子元素的 key
和其在子元素数组中的索引 index
以键值对的形式放到一个哈希表(这里是一个普通的 JS
对象,)中,然后通过遍历新孩子首指针和尾指针之间的元素,通过 key
去哈希表中寻找能够复用的节点,如果存在,更新后移动到相应的位置即可;如果不存在,则需要创建一个新的 DOM
元素并插入到对应的位置中
Diff 算法的区别
上述已经讲完了 Snabbdom
的源码,你可以理解为它是框架 diff
算法的源头,不过也说过其并不等同于框架中的 diff
算法,下面我们就来看看真正的框架中使用的 diff
算法和 Snabbdom
有哪些区别吧?
Vue2 与 Snabbdom
- 判定节点类型是否相等的函数不同
在 Snabbdom
中有一个 sameVnode
函数,它是用来判别两个节点是否属于同一类型的,判别的条件就是看两个节点的 key
和 sel
是否相等,简单来说就是比对了添加到元素上的属性 key
、标签名 tag
、选择器名 class
、id
Vue2
不变的是其仍将 key
作为首要的判定对象,但并没有判定选择器的名称,也就是说类名不同的元素在 Vue2
中也是可能复用的,举个🌰:
<div class='nav'>
与 <div class='title'>
在 Snabbdom
中会认为是类型不同的两个节点,因为它们对应的类名不同,但是在 React
和 Vue
中,会认为它们是类型一致的节点,会对旧节点进行更新复用
在此基础上,Vue2
还增加了对标签名 tag
的单独判断、是否为注释节点、是否为异步节点、元素为 input
时候 type 是否相同等等条件
// Vue2 中 sameVnode 源码
function sameVnode (a, b) {
return (
a.key === b.key && // key 值的判断
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag && // 标签名的判断
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // input 标签 type 的判断
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
Vue2 与 Vue3
updateChildren
中使用的核心算法不同
vue2
中的diff
算法采用了双端比较法,通过给子元素数组设置首尾指针从数组两端开始比较,然后一步步控制指针向中间移动,当指针指向的节点类型均不相同时,还会将剩余老孩子key
和索引index
映射到一个JS
对象中,借助key
寻找能够复用的节点
vue3
并没有延续 Vue2
的双端比较法,而是使用了 inferno 算法来进行子元素之间的比较,感兴趣的兄弟可以去看下它的源码,然后理解了和我说下哈哈~😁
Vue 与 React
diff
的对象不同
React
中进行 diff
的两个对象并不像 Vue
一样双方都是相同的结构,在 react
当中,一个对象是 oldFiber
,这是 react
特有的 fiber
对象,另一个要比对的是 react element
,其更像是 Vue
中的 vnode
,这两个对象的结构是不同的
React
通过 diff
算法比对是为了让新创建出的 react element
复用老的 fiber
对象,最终生成一个新的且完整的 fiber
对象
- 采用的核心算法不同
Vue2
中采用了双端比较法、Vue3
中采用了 inferno
算法,而 React
用的是单端比较法(这是我自己取的名哈哈),说白了就是 Vue2
设置的是双指针,是两端向中间逼近的,而 React
只设置了一个指针,相当于是从左到右进行遍历;如果有不满足条件的,则像 Vue2
中的第二阶段一样将老 fiber
对应的 key
和 index
放到一个对象中去,然后利用 key
查找能够复用的 oldFiber
对象
diff
第二阶段的key
和索引的映射方式不同
Vue
中存放 key
和索引时用的只是一个普通的 JS
对象,但 React
内部用的是 Map
结构
// Vue
function createKeyToOldIdx(children, beginIdx, endIdx) {
const map = {}
// ...
return map
}
// React
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber,
): Map<string | number, Fiber> { const existingChildren: Map<string | number, Fiber> = new Map();
// ...
return existingChildren;
}
思考
- 虚拟
DOM
一定比真实DOM
要快吗?
这也是面试中会经常问道的问题,因为我们都知道框架采用 diff
算法的目的肯定是为了提高性能,所以很自然的就认为使用虚拟 DOM + diff
算法一定就可以比直接操作真实 DOM
快,但事实不是这样的
框架中更新视图的流程是 数据更新 -> 虚拟 DOM + diff 算法 -> 操作 DOM
,而原生开发的流程则是 数据更新 -> 操作 DOM
,diff
算法主要就是用来帮助我们尽量复用旧的 DOM
元素,减少操作 DOM
的次数,从而减轻浏览器排版与重绘的压力,达到性能优化的目的。那如果新旧列表根本就毫无关联呢?
在这种情况下,即使使用了 diff
算法也找不到可以复用的元素,最终还是要像原生开发一样操作所有的 DOM
,并且相对于原生开发还多了创建虚拟 DOM
以及 diff
算法比对的步骤,这种场景下的性能还不如原生开发的呢~
事实也的确如此,很多时候虚拟 DOM
并不会带给我们收益,也不是最优的操作,但毕竟这些特殊场景还是较少出现的,更多的情况是组件中大部分的元素都可以复用,而且框架中使用虚拟 DOM
的做法大大减少了我们自己手动操作 DOM
的次数,加快了开发效率,在效率和可维护性之间达到平衡
- 为什么不建议用
index
作为key
?
相信看完源码之后,你的脑海中应该已经浮现出了大致的答案,因为使用 index
来作为 key
,很有可能会误导 diff
算法进行比对操作,导致原本应该被复用的节点复用不了,下面举一个经典的例子:
现在有一个列表为 A
、B
、C
、D
,它们的 key
值直接赋值为列表项的索引 index
,后续将这个列表对应的数据做一个翻转后重新渲染组件,也就是说现在想要渲染的列表为 D
、C
、B
、A
,key
值依然用的是 index
,下面我们来看看 diff
算法的结果:
我们期望的结果是每一个节点都能够复用且不需要做任何更新,只需要调整 DOM
元素的位置即可,因为我们只是将列表翻转了而已,并没有改变里面的内容,但由于我们采用 index
作为 key
,所以在第一阶段比对的时候会按照列表的顺序一一比对,虽然这些节点类型一致可以复用,但是里面的内容并不相同,导致列表的每一项都需要更新文本,与我们的期望相差甚远
如果我们的 key
用的是每一项唯一的标识呢?
那么 diff
算法在比较的时候就可以更加精确的选取要比对的对象,从而增大了复用节点的正确性与可能性,现在上述例子中所有的节点都可以复用且不需要做任何更新操作,只需要将原先 DOM
移动到对应的位置即可
总结一下,如果使用 index
作为 key
,那么很有可能会使 React
或 Vue
复用错误的旧节点,导致做很多额外的更新操作,这样会大大影响 diff
算法的效率,所以在开发中,我们尽量要使用唯一的 id
作为 key
,这样方便 diff
算法精确的找到最适合复用的节点
总结
diff
算法的核心思路至此已经讲完啦🎉,说不上难,也说不上简单,但是理解了之后可以让我们今后开发或者面试更加自信,文章里面很多都是我自己的理解,如果有不恰当的地方欢迎在评论区指正,希望这篇文章对大家有所帮助~