不会 Vue,但不影响我学 diff 算法

news/2024/3/19 13:27:36/文章来源:https://blog.csdn.net/qq_53225741/article/details/127251871

前言

现在社会各行各业大都面临着寒冬,互联网行业最近还出现了裁员潮,导致前端是越来越卷,普通学校的应届生不仅要跟985、211毕业的学生以及研究生进行竞争,甚至还需要和最近刚被裁的、有了几年工作经验的程序员竞争,害,趁我还年轻,赶紧努力考个公吧😭

玩笑归玩笑,但是现在就业形势确实严峻,前端面试时考查的难度也越来越大,面试官会问很多关于框架原理的知识点以区分候选人的水平和学习能力,而 diff 算法就属于面试中的高频问题,下面我们就来了解一下 diff 算法的核心思想,希望能让大家再被问到 diff 算法的时候能更加自信~

虚拟 DOM

在学习 diff 算法之前,我们必须要先清楚虚拟 DOM 的概念,因为 Vue 中的 diff 算法比对的不是别人,正是虚拟 DOM,那虚拟 DOM 到底是什么呢?(面试的时候可能会问)

虚拟 DOM 是用来表示真实 DOMJS 对象,该对象并没有真实 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' }, children: ['你好呀']},{tagName: 'li', props: { class: 'item2' }, children: ['我是xxx']},{tagName: 'li', props: { class: 'item3' }, children: ['我们一起学习前端吧~']}]
} 

果不其然,和我们说的一样,虚拟 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 版本

所以说,Vue2diff 算法就是在原有 Snabbdom 进行改造得到的,而 Vue3diff 算法又是在 Vue2 的基础上改良的,Reactdiff 算法又和 Vue 大同小异,有异曲同工之妙。搞懂了 Snabbdom 的原理,框架中的 diff 算法自然也就理解了,虽然有部分逻辑不一致,但核心思想还是很相似的;还有一个原因就是 Snabbdom 不仅涵盖了 diff 算法的核心思想,而且由于源码并不涉及框架中的额外操作,所以阅读起来会简单很多

以上就是这篇文章为什么这篇文章不去专门解读 VueReact 的源码,而是去研究 Snabbdom 源码的原因,这也对应了本篇文章的标题:不会 Vue,但不影响大家学习 diff 算法,因为其不是一个具体的东西,它只是一种思想,不一定只在 VueReact 这样的框架中才会应用到

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 函数创建成虚拟节点后赋值给 childrenundefined,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 函数来生成的,它接受三个参数:seldata 以及 childrenh 函数又依赖于 vnode 函数来返回最终的虚拟 DOM,可以看到其很明显返回的就是一个 JS 对象,并没有那么高大上

下面简单介绍一下 Snabbodom 中的虚拟 DOM 有哪些属性?(不同的框架虚拟 DOM 所具有的属性可能不同)

  • sel 表示的是由元素对应的标签名和选择器组成的字符串

  • data 里面是元素身上的属性,比如说 classNamestyle 等等

  • childrentext 都是节点的内容,所以这两个只会有一个属性有值

  • elm 是这个虚拟 DOM 所对应的真实 DOM

  • key 其实是从 data 中提取出来的,也就是我们在列表 list 中为每一项 item 添加的 key

patch 函数

上面我们已经说过了虚拟 DOM 是通过 h 函数创建出来的,那么虚拟 DOM 又是怎么过渡成真实 DOM 的呢?这就是 patch 函数的功劳了,因为在虚拟 DOM 变成真实 DOM 之前,肯定要先经过 diff 算法的比较,因为这样才能复用原先的节点,而我们今天所谈论的 diff 算法主要就是在patch 函数里面实现的。该函数共有两种使用场景:

1.第一种是页面首次渲染时,将 vnode 渲染到一个空的容器(该容器是一个真实的 DOM 元素)里面:patch(container, vnode)

2.第二种是用新的 vnode 替换老的 vnodepatch(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 是否相同的时候,比较的是 keysel,其中这个 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 算法中哪两个函数最关键,我的回答一定是 sameVnodeupdateChildren 函数,因为 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 rightpatchVnode(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 leftpatchVnode(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 函数移除旧孩子首尾指针之间的所有真实 DomremoveVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);}} 

可以看到,双端比较法并不是 updateChildren 函数的全部,因为可能会出现子元素还没有遍历完,但当前的四个指针指向的元素都不是同种类型时,如果没有后续的处理,一些夹杂在首尾指针之间还没有被遍历到但能够被复用的元素就被忽视了,这样就达不到最大限度复用 DOM 元素的目的了,那 diff 算法所带来的收益也会随之减弱

Snabbdom 会把老孩子首指针和尾指针之间子元素的 key 和其在子元素数组中的索引 index 以键值对的形式放到一个哈希表(这里是一个普通的 JS 对象,)中,然后通过遍历新孩子首指针和尾指针之间的元素,通过 key 去哈希表中寻找能够复用的节点,如果存在,更新后移动到相应的位置即可;如果不存在,则需要创建一个新的 DOM 元素并插入到对应的位置中

Diff 算法的区别

上述已经讲完了 Snabbdom 的源码,你可以理解为它是框架 diff 算法的源头,不过也说过其并不等同于框架中的 diff 算法,下面我们就来看看真正的框架中使用的 diff 算法和 Snabbdom 有哪些区别吧?

Vue2 与 Snabbdom

  • 判定节点类型是否相等的函数不同

Snabbdom 中有一个 sameVnode 函数,它是用来判别两个节点是否属于同一类型的,判别的条件就是看两个节点的 keysel 是否相等,简单来说就是比对了添加到元素上的属性 key、标签名 tag、选择器名 classid

Vue2 不变的是其仍将 key 作为首要的判定对象,但并没有判定选择器的名称,也就是说类名不同的元素在 Vue2 中也是可能复用的,举个🌰:

<div class='nav'><div class='title'>Snabbdom 中会认为是类型不同的两个节点,因为它们对应的类名不同,但是在 ReactVue 中,会认为它们是类型一致的节点,会对旧节点进行更新复用

在此基础上,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 中使用的核心算法不同

1.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 对应的 keyindex 放到一个对象中去,然后利用 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;} 

思考

1.虚拟 DOM 一定比真实 DOM 要快吗?

这也是面试中会经常问道的问题,因为我们都知道框架采用 diff 算法的目的肯定是为了提高性能,所以很自然的就认为使用虚拟 DOM + diff 算法一定就可以比直接操作真实 DOM 快,但事实不是这样的

框架中更新视图的流程是 数据更新 -> 虚拟 DOM + diff 算法 -> 操作 DOM,而原生开发的流程则是 数据更新 -> 操作 DOMdiff 算法主要就是用来帮助我们尽量复用旧的 DOM 元素,减少操作 DOM 的次数,从而减轻浏览器排版与重绘的压力,达到性能优化的目的。那如果新旧列表根本就毫无关联呢?

在这种情况下,即使使用了 diff 算法也找不到可以复用的元素,最终还是要像原生开发一样操作所有的 DOM,并且相对于原生开发还多了创建虚拟 DOM 以及 diff 算法比对的步骤,这种场景下的性能还不如原生开发的呢~

事实也的确如此,很多时候虚拟 DOM 并不会带给我们收益,也不是最优的操作,但毕竟这些特殊场景还是较少出现的,更多的情况是组件中大部分的元素都可以复用,而且框架中使用虚拟 DOM 的做法大大减少了我们自己手动操作 DOM 的次数,加快了开发效率,在效率和可维护性之间达到平衡

2.为什么不建议用 index 作为 key

相信看完源码之后,你的脑海中应该已经浮现出了大致的答案,因为使用 index 来作为 key,很有可能会误导 diff 算法进行比对操作,导致原本应该被复用的节点复用不了,下面举一个经典的例子:

现在有一个列表为 ABCD,它们的 key 值直接赋值为列表项的索引 index,后续将这个列表对应的数据做一个翻转后重新渲染组件,也就是说现在想要渲染的列表为 DCBAkey 值依然用的是 index,下面我们来看看 diff 算法的结果:

我们期望的结果是每一个节点都能够复用且不需要做任何更新,只需要调整 DOM 元素的位置即可,因为我们只是将列表翻转了而已,并没有改变里面的内容,但由于我们采用 index 作为 key,所以在第一阶段比对的时候会按照列表的顺序一一比对,虽然这些节点类型一致可以复用,但是里面的内容并不相同,导致列表的每一项都需要更新文本,与我们的期望相差甚远

如果我们的 key 用的是每一项唯一的标识呢?

那么 diff 算法在比较的时候就可以更加精确的选取要比对的对象,从而增大了复用节点的正确性与可能性,现在上述例子中所有的节点都可以复用且不需要做任何更新操作,只需要将原先 DOM 移动到对应的位置即可

总结一下,如果使用 index 作为 key,那么很有可能会使 ReactVue 复用错误的旧节点,导致做很多额外的更新操作,这样会大大影响 diff 算法的效率,所以在开发中,我们尽量要使用唯一的 id 作为 key,这样方便 diff 算法精确的找到最适合复用的节点

最后

整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_398875.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

page.json

uni-app需要给page.json文件需要进行配置路由,否则会不报错,也跳转不过去

【数模/启发式算法】蚁群算法

文章目录简介符号说明核心思想流程图文章使用到的测试函数基本步骤蚁群算法代码简介 蚁群算法是一种用来寻找优化路径的概率型算法。它由Marco Dorigo于1992年在他的博士论文中提出&#xff0c;其灵感来源于蚂蚁在寻找食物过程中发现路径的行为。 这种算法具有分布计算、信息正…

Arduino播放声音

玩软件有点虚无&#xff0c;没有实际东西&#xff0c;所以接下来要体验下硬件与软件结合。 1 Arduino Arduino是一种包含硬件&#xff08;各种型号的Arduino板&#xff09;和软件&#xff08;Arduino IDE&#xff09;的开源电子平台。硬件部分是可以用来做电路连接的Arduino电…

小白学习Java第四十三天

Git概述 &#xff08;一&#xff09;什么是Git Git是一个开源的分布式版本控制系统&#xff0c;可以有效、高速地处理从很小到非常大的项目版本管理。版本控制是指对软件开发过程中各种程序代码、配置文件及说明文档等文件变更的管理&#xff0c;是软件配置管理的核心思想之一…

设计模式学习笔记(五) - 观察者模式 Observer

目录 观察者模式 Observer 一、背景描述 Version 1 (面向过程) Version 2 (面向对象) Version 3 (单个观察者) Version 4 (多个观察者) Version 5 (分离观察者与被观察者) 二、不同事件下的观察者模式 三、事件本身也可以形成继承体系 四、观察者常用场景 观察者模式…

Selenium基础 — 鼠标操作

1、鼠标事件介绍 前面例子中我们已经学习到可以用click()来模拟鼠标的单击操作&#xff0c;而我们在实际的web产品测试中发现&#xff0c;有关鼠标的操作&#xff0c;不单单只有单击&#xff0c;有时候还要用到右击&#xff0c;双击&#xff0c;拖动等操作&#xff0c;这些操作…

【Nginx】认识与基本使用 Nginx 实现反向代理、配置负载均衡

文章目录1. Nginx 概述1.1 Nginx 介绍1.2 Nginx 下载和安装1.3 Nginx 目录结构2. Nginx 命令3. Nginx 配置文件结构4. Nginx 具体应用4.1 部署静态资源4.2 反向代理4.2.1 介绍4.2.2 配置反向代理4.3 负载均衡4.3.1 介绍4.3.2 配置负载均衡4.3.3 负载均衡策略1. Nginx 概述 1.1…

Ubuntu开机界面出现“error found when loading /root/.profile”

原因 今天一开始按照一篇文章&#xff0c;想把普通用户的权限提高到最高权限&#xff0c;修改了**/etc/passwd**文件&#xff0c;然后重启&#xff0c;发现之前的用户进不去了&#xff0c;一开机就出现如下信息 解决方法 1、重启虚拟机进入recovery模式&#xff08;长按shi…

计算机网络-第一章 | 王道考研

目录 一、基本介绍 定义 功能 组成 分类 标准化工作 标准的分类 标准化工作相关组织 二、性能指标 ※ 速率 带宽 ※吞吐量 时延 时延带宽积 往返时延RTT 利用率 三、分层结构 ※ 分层基本规则 正式认识分层 7层OSI参考模型 怎么来的 怎么分的 怎么传的…

<特殊类设计与单例模式>——《C++高阶》

目录 1.请设计一个类&#xff0c;不能被拷贝 2. 请设计一个类&#xff0c;只能在堆上创建对象 3. 请设计一个类&#xff0c;只能在栈上创建对象 4. 请设计一个类&#xff0c;不能被继承 5. 请设计一个类&#xff0c;只能创建一个对象(单例模式) 后记&#xff1a;●由于…

GD32F307VC+WIN10+VSCODE+GCC+JLINK环境build

为了构建Cortex M系列单片机免费开源的开发环境&#xff0c;网络上了解来看VSCODEGCCJLINK是一套比较高效的组合方式&#xff0c;下面记录环境搭建的流程。 我这边的PC环境为 WIN10专业版64bit。 工具准备 1. arm-none-eabi-gcc下载及安装 官网下载链接&#xff1a;Downloa…

c++数据结构:数组和向量

线性表&#xff1a; 在数据元素的非空有限集中 存在唯一的一个被叫做“第一个”的数据元素存在唯一的一个被叫做“最后一个”的数据元素除第一个之外&#xff0c;集合中的每个数据元素均只有一个前驱除最后一个之外&#xff0c;每个集合元素均只有一个后继数据结构中线性结构指…

文字识别检测入门(1)

CTPN 优点&#xff1a;对水平文字检测效果超级好 缺点&#xff1a;对扭曲的文字不好 RRPN 在faster的基础上改进 RPN改为RRPN ROI pooling改进为RROI pooling 能解决旋转&#xff0c;但是解决不了弯曲的曲面问题 EAST Anchor free 特征合并&#xff0c;检测不同尺度文本 检测各…

刷爆leetcode第三期 0007~0010

刷爆leetcode第三期 0007~0010 题目一 反转链表解法一解法二题目二 链表的中间节点题目三 链表的倒数第K个节点题目四 合并两个有序链表题目一 反转链表 解法一 给定单链表的头节点 head &#xff0c;请反转链表&#xff0c;并返回反转后的链表的头节点。 示例 1&#xff1a…

python与人工智能:线性回归和逻辑回归

线性回归 线性回归是利用数理统计中回归分析&#xff0c;来确定两种或两种以上变量间相互依赖的定量关系的一种统计分析方法&#xff0c;运用 十分广泛。梯度下降&#xff1f; 梯度下降法的基本思想可以类比为一个下山的过程。 假设这样一个场景&#xff1a;一个人被困在山上&a…

零拷贝总结

数据交互模式 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dqLJVb5U-1665401648551)(en-resource://database/1074:1)] 读数据过程&#xff1a; 应用程序要读取磁盘数据&#xff0c;调用 read()函数从而实现用户态切换内核态&#xff0c;这是第 …

论文/机器学习笔记:SENet (Squeeze-and-Excitation Networks)

Image 2017 挑战赛夺冠paper 1 motivation 希望显式地建模特征通道&#xff08;channel&#xff09;之间的相互依赖关系 通过学习的方式来自动获取到每个特征通道的重要程度依照这个重要程度去提升有用的特征并抑制对当前任务用处不大的特征 2 模型 给定一个输入 x&#xff…

利用phpstudy导入mysql文件

1.创建mysql文件 mysql 常用命令&#xff1a; 打开mysql: mysql -u root -p 查看数据库&#xff1a; show databases; 创建 数据库: create database baseName (数据库名称) 使用数据库&#xff1a; use baseName(数据库名称) 显示表&#xff1a;show tables; 创建表&#xff…

C#【高级篇】 IntPtr是什么?怎么用?

C#学习汇总 - 总目录 C#【高级篇】 IntPtr是什么&#xff1f;怎么用&#xff1f;前言一、IntPtr&#xff08;IntPointer&#xff09;的由来二、IntPtr&#xff08;属于结构体&#xff09;的说明三、IntPtr的使用示例1、int类型与IntPtr类型之间的转换2、string类型与IntPtr之间…

Java collection集合的体系特点

Java collection集合的体系特点 集合类体系结构 collection单列集合&#xff0c;每个元素&#xff08;数据&#xff09;只包含一个值。 Map双列集合&#xff0c;每个元素包含两个值&#xff08;键值对&#xff09;。 collection集合体系&#xff08;常见&#xff09; colle…