key在vue中的作用与虚拟dom与diff算法

发布时间 2023-07-17 05:12:26作者: kyming

一。虚拟dom:(它是存在于内存中的)

VNode的全称是Virtual Node,也就是虚拟节点;事实上,无论是组件还是元素,它们最终在Vue中表示出来的都是一个个VNode。VNode的本质是一个JavaScript对象。

其实虚拟Dom是真实Dom的一种状态。当真实Dom发生变化后,虚拟Dom可以提供这个真实Dom变化之前和变化之后的状态,通过对比这两个状态,即可得出真实Dom真正需要更新的部分,即可实现最小量更新。在一些比较复杂的Dom变化场景中,通过对比虚拟Dom后更新真实Dom会比直接更新真实Dom的效率高,这也就是虚拟Dom和diff算法真正存在的意义。

既然虚拟dom存在于内存中即需要占用大量的空间,那它存在的意义是什么呢?

我们会发现当一个dom上的属性是有很多的直接的去操作dom这样是非常浪费性能的,所以才有了虚拟dom的出现,因为虚拟dom是真实dom的映射是一个js对象,我们可以用js的计算性能(内存)来换取减少操作dom所带来的性能,既然我们逃脱不了操作dom元素这道坎,那就减少dom的操作来换取性能

二。diff算法

简单来说就是一种比对算法,它可以找到新旧虚拟dom的差异,并根据对比结果更新真实dom。

二、h函数

了解diff算法原理前需要先了解一下h函数,因为是靠h函数生成虚拟Dom。

这个h函数就是render函数里面传入的那个h函数。

h函数可以接受多种类型的参数,但其实它只干了一件事,就是执行vnode函数。根据传入h函数的参数来决定执行vnode函数时传入的参数。

vnode函数又是干什么的呢?其实它也只干了一件事,就是把传入h函数的参数转化为一个对象,即虚拟Dom。

// vnode.js
export default function (sel, data, children, text, elm) {const key = data.key return {sel, data, children, text, elm, key}
}

执行h函数后,内部会通过vnode函数生成虚拟Dom,h函数把这个虚拟Dom再return出去。

三、diff算法对比规则

简单用h函数生成两个不同的虚拟Dom节点,通过一个简易版的diff算法代码介绍diff对比的具体流程。

// 第一个参数是sel 第二个参数是data 第三个参数是children
const myVnode1 = h("h1", {}, [h("p", {key: "a"}, "a"),h("p", {key: "b"}, "b"),
]);
​
const myVnode2 = h("h1", {}, [h("p", {key: "c"}, "c"),h("p", {key: "d"}, "d"),
]);

1、patch

比较的第一步就是执行patch,它相当于对比的入口。既然是对比两个虚拟Dom,那么就将两个虚拟Dom作为参数传入patch中。patch的主要作用是对比两个虚拟Dom的根节点,并根据对比结果操作真实Dom。

 

patch函数的核心代码如下

// patch.js
​
import vnode from "./vnode"
import patchDetails from "./patchVnode"
import createEle from "./createEle"
​
/*** @description 用来对比两个虚拟dom的根节点,并根据对比结果操作真实Dom* @param {*} oldVnode * @param {*} newVnode */
export function patch(oldVnode, newVnode) {// 1.判断oldVnode是否为虚拟节点,不是的话转化为虚拟节点if(!oldVnode.sel) {// 转化为虚拟节点oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)}
​// 2.判断oldVnode和newVnode是否为同一个节点if(oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {console.log('是同一个节点')// 比较子节点patchDetails(oldVnode, newVnode)}else {console.log('不是同一个节点')// 插入newVnode const newNode = createEle(newVnode) // 插入之前需要先将newVnode转化为domoldVnode.elm.parentNode.insertBefore(newNode, oldVnode.elm) // 插入操作// 删除oldVnodeoldVnode.elm.parentNode.removeChild(oldVnode.elm)}
}
​
// createEle.js
​
/*** @description 根据传入的虚拟Dom生成真实Dom* @param {*} vnode * @returns real node*/
export default function createEle (vnode) {const realNode = document.createElement(vnode.sel)
​// 子节点转换if(vnode.text && (vnode.children == undefined || (vnode.children && vnode.children.length == 0)) ) {// 子节点只含有文本realNode.innerText = vnode.text  }else if(Array.isArray(vnode.children) && vnode.children.length > 0) {// 子节点为其他虚拟节点 递归添加nodefor(let i = 0; i < vnode.children.length; i++) {const childNode = createEle(vnode.children[i])realNode.appendChild(childNode)}}
​// 补充vnode的elm属性vnode.elm = realNode
​return vnode.elm
}

2、patchVnode

patchVnode用来比较两个虚拟节点的子节点并更新其子节点对应的真实Dom节点。

// patchVnode.js
​
import updateChildren from "./updateChildren"
import createEle from "./createEle"
​
/*** @description 比较两个虚拟节点的子节点(children or text) 并更新其子节点对应的真实dom节点* @param {*} oldVnode * @param {*} newVnode * @returns */
export function patchDetails(oldVnode, newVnode) {// 判断oldVnode和newVnode是否为同一个对象, 是的话直接不用比了if(oldVnode == newVnode) return 
​// 默认newVnode和oldVnode只有text和children其中之一,真实的源码这里的情况会更多一些,不过大同小异。
​if(hasText(newVnode)) {// newVnode有text但没有children
​/***  newVnode.text !== oldVnode.text 直接囊括了两种情况*  1.oldVnode有text无children 但是text和newVnode的text内容不同*  2.oldVnode无text有children 此时oldVnode.text为undefined *  两种情况都可以通过innerText属性直接完成dom更新 *  情况1直接更新text 情况2相当于去掉了children后加了新的text*/if(newVnode.text !== oldVnode.text) {oldVnode.elm.innerText = newVnode.text}
​}else if(hasChildren(newVnode)) {// newVnode有children但是没有textif(hasText(oldVnode)) {// oldVnode有text但是没有childrenoldVnode.elm.innerText = '' // 删除oldVnode的text// 添加newVnode的childrenfor(let i = 0; i < newVnode.children.length; i++) {oldVnode.elm.appendChild(createEle(newVnode.children[i]))}
​}else if(hasChildren(oldVnode)) {// oldVnode有children但是没有text
​// 对比两个节点的children 并更新对应的真实dom节点updateChildren(oldVnode.children, newVnode.children, oldVnode.elm)}}
}
​
// 有children没有text
function hasChildren(node) {return !node.text && (node.children && node.children.length > 0)
} 
​
// 有text没有children
function hasText(node) {return node.text && (node.children == undefined || (node.children && node.children.length == 0))
} 

3、updateChildren

该方法是diff算法中最复杂的方法。对应上面patchVnode中oldVnode和newVnode都有children的情况。

 

首先介绍一下对比规则。

 

对比过程中会引入四个指针,分别指向oldVnode子节点列表中的第一个节点和最后一个节点(简称为旧前和旧后)以及指向newVnode子节点列表中的第一个节点和最后一个节点(简称为新前和新后)

 

对比时,每一次对比按照以下顺序进行命中查找

 

旧前与新前节点对比(1)

旧后与新后节点对比(2)

旧前与新后节点对比(3)

旧后与新前节点对比(4)

上述四种情况,如果某一种情况两个指针对应的虚拟Dom相同,那么称之为命中。命中后就不会接着查找了,指针会移动,(还有可能会操作真实Dom,3或者4命中时会操作真实Dom移动节点)之后开始下一次对比。如果都没有命中,则去oldVnode子节点列表循环查找当前新前指针所指向的节点,如果查到了,那么操作真实Dom移动节点,没查到则新增真实Dom节点插入。

 

这种模式的对比会一直进行,直到满足了终止条件。即旧前指针移动到了旧后指针的后面或者新前指针移动到了新后指针的后面,可以理解为旧子节点先处理完毕和新子节点处理完毕。那么可以预想到新旧子节点中总会有其一先处理完,对比结束后,会根据没有处理完子节点的那一对前后指针决定是要插入真实Dom还是删除真实Dom。

 

如果旧子节点先处理完了,新子节点有剩余,说明有要新增的节点。将根据最终新前和新后之间的虚拟节点执行插入操作

如果新子节点先处理完了,旧子节点有剩余,说明有要删除的节点。将根据最终旧前和旧后之间的虚拟节点执行删除操作

vue中key的作用

根据上面的分析,key的主要作用其实就是让h函数生成的虚拟dom中含有唯一标识符key,这样在进行diff算法对比两个虚拟Dom节点时,判断其是否为相同节点。加了key以后,可以更为明确的判断两个节点是否为同一个虚拟节点,是的话判断子节点是否有变更(有变更更新真实Dom),不是的话继续比。如果不加key的话,如果两个不同节点的标签名恰好相同,那么就会被判定为同一个节点(key都为undefined),结果一对比这两个节点的子节点发现不一样,这样会凭空增加很多对真实Dom的操作,从而导致页面更频繁得进行重绘和回流。

 

所以合理利用key可以有效减少真实Dom的变动,从而减少页面重绘和回流的频率,进而提高页面更新的效率。