一,前言
上篇,diff 算法问题分析与 patch 方法改造,主要涉及以下几点:
- 初始化与更新流程分析;
- 问题分析与优化思路;
- 新老虚拟节点比对模拟;
- patch 方法改造;
下篇,diff 算法-节点比对
二,diff 算法
上一篇,已经完成了patch方法的改造;
接下来,开始编写“视图更新时,新老虚拟节点比对”的diff算法;
在开始之前,先简单介绍一下diff算法:
1,diff 算法简介
diff算法也叫做“同层比较“算法;
首先,dom是一个树型结构,参考下图:
在日常开发中,很少会将B和A或是D和A的位置进行调换,即:很少发生将父亲节点和儿子节点进行交换的场景
而且,进行跨层的节点对比会非常麻烦;所以,diff算法权衡考虑了实际应用场景与性能瓶颈,仅对同层节点进行比对;
2,diff 算法的比较方式
diff算法,将新、老虚拟节点这"两棵树"进行比对;
从树的根节点,即 LV1 层开始比较:
A比较完成后,查看A节点是否有儿子节点,即B和C,优先比较B:
B比较完成后,查看B节点是否有儿子节点,即D和E,优先比较D,
D比较完成后,D 不再有儿子节点;继续比较E,当前层处理完成,返回上层继续处理;
继续比较C,C有儿子F,继续比较F,最后全部比较完成,结束;
所以,从比对方式来看,diff比对是“深度优先遍历”的递归比对;
Vue2与Vue3在节点更新时的性能对比:
- 递归比对是
vue2的性能瓶颈,当组件树庞大时会产生性能问题;- 在
vue3中,会收集动态节点,并对他们的变化进行标记,根据标记进行更新,而无需使用diff递归比对一遍Vue3是线性比对,而Vue2是两棵树的比对,效率上会比vue2高出很多;
3,diff 算法的节点复用
问题1:如何确定两个节点可以复用?
- 一般来说,相同标签的元素即可进行复用;
问题2:标签相同就必须要复用吗?
- 当然,在实际应用场景中,也存在即使标签相同,也不希望被复用的情况,这时,可以使用
key属性对节点进行标记; - 如果 key 值不相同,即便标签名相同的两个元素,也不会进行复用;
场景举例:两个
input切换时(v-if),由于节点复用导致切换后仍显示前还前input的值,可以对两个input设置不同key值解决此问题;
判断节点是否复用的标准
所以,在编写代码时,相同节点的复用标准如下:
- 标签名和
key值均相同,即可判断为相同节点; - 若标签名和
key不完全相同,则不是相同节点;
判断节点是否复用 isSameVnode 方法
比对新老虚拟节点是否能够复用,所以,此方法应从属于vdom模块;
在vdom模块,创建isSameVnode方法,用于判断是否为相同节点:
// src/vdom/index.js
/**
* 判断两个虚拟节点是否是同一个虚拟节点
* @param {*} newVnode 新虚拟节点
* @param {*} oldVnode 老虚拟节点
* @returns
*/
export function isSameVnode(newVnode, oldVnode){
// 判断逻辑:tag 标签名 和 key 完全相同
return (newVnode.tag === oldVnode.tag)&&(newVnode.key === oldVnode.key);
}
当新老虚拟节点的标签和key值均相同时(即isSameVnode方法返回true),复用老节点,仅更新其中的属性即可;
三,虚拟节点比对
1,新老节点相同的情况
模拟不同节点的更新
创建两个虚拟节点,模拟视图的更新:
// 1,模拟初渲染-oldVnode
let vm1 = new Vue({
data() {
return { name: 'Brave' }
}
})
let render1 = compileToFunction('<div>{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);
// 2,模拟新的虚拟节点-newVnode
let vm2 = new Vue({
data() {
return { name: 'BraveWang' }
}
})
let render2 = compileToFunction('<p>{{name}}</p>');
let newVnode = render2.call(vm2);
// diff:新老虚拟节点对比
setTimeout(() => {
patch(oldVnode, newVnode);
}, 1000);
由于新老虚拟节点的标签名tag不同(相当于模拟了v-if、v-else的情况),
所以不是相同节点,不考虑阶段复用,直接使用新的真实节点替换掉旧的真实节点;
这里的节点复用,指的是同层节点的复用,不考虑跨层节点复用的情况;
即只比较同层节点,如果节点不可复用,儿子就不用再比了,全部放弃复用,重新创建节点;
由于diff 算法是同层比对,算法的复杂度是
On;
在patch方法中,打印新老虚拟节点:
节点替换的逻辑分析
由于父节点的标签名不同,导致了节点不可被复用,
此时,即便子节点中存在可复用节点,也不再进行不低;
直接会根据新的虚拟节点生成新的真实节点,并替换掉老的真实节点;
1,使用新的虚拟节点创建出新的真实节点:
createElm(vnode);
2,要替换掉老节点,先要获取到老的真实节点:
之前,根据vnode虚拟节点生成真实节点时,通过vnode.el将真实节点与虚拟节点进行了映射;
所以,此时就能够轻松地通过oldVnode.el获取到老的真实节点了;
备注:这里获取真实节点不能使用
$el,$el是指整棵树,所以在此处不可用;
3,综合以上分析的结论:
- 新的真实节点:
createElm(vnode); - 老的真实节点:
oldVnode.el;
节点替换的代码实现
// src/vdom/patch.js
export function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
// oldVnode 为真实节点,初渲染流程
if(isRealElement){
const elm = createElm(vnode);
const parentNode = oldVnode.parentNode;
parentNode.insertBefore(elm, oldVnode.nextSibling);
parentNode.removeChild(oldVnode);
return elm;
// oldVnode 为虚拟节点,更新流程,执行新老虚拟节点比对
} else {
console.log(oldVnode, vnode)
if(!isSameVnode(oldVnode, vnode)){
// 不是相同节点,不考虑复用直接替换
// 使用新的虚拟节点生成新的真实节点并替换掉老的真实节点
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
}
}
备注:
- 当前
Demo相当于对整棵树进行了更新;- 当树中的节点包含子组件时,由于每个组件都拥有独立的渲染
watcher,会通过diff进行局部更新,因此并不会对整个树进行更新;- 所以,只要组件拆分合理,一般不会出现性能问题;
1,新老节点不同的情况
如果两个元素的tag标签名和key都相同,即isSameVnode方法返回true,则判定为相同节点;对节点进行复用,只更新其中不同的地方即可;
这里“不同的地方”:指文本、
2-1 文本的处理
文本节点的特点:
- 文本节点,没有标签名;
- 文本节点,没有儿子;
因此,对于文本的处理,由于文本节点没有儿子,所以直接更新即可;
文本的复用逻辑:
- 1,复用老文本:
vnode.el = oldVnode.el将老节点el赋值给新节点el - 2,更新文本内容:
el.textContent = vnode.text;
在组件
Vue.component('xxx')中,组件的tag标签名就是xxx;
// 节点复用:将老节点 el 赋值给新节点 el
let el = vnode.el = oldVnode.el;
// 老节点没有标签名,说明是文本(之前已通过`isSameVnode`方法判定新老节点为相同节点)
if(!oldVnode.tag){
// 更新文本内容
if(oldVnode.text !== vnode.text){
return el.textContent = vnode.text;
} else{
return;
}
}
处理完tag不存在时的文本节点后,继续处理tag存在的元素节点
2-2 元素的处理
相同节点且新老节点不都是文本时,会对元素进行处理,
更新元素的属性,需要对updateProperties方法进行功能改造:
重构前的updateProperties方法:
- 比较暴力,直接使用
data属性值替换掉真实元素vnode.el中对应的属性值; - 仅具有渲染功能,不具有更新功能;
重构前的渲染功能:
// src/vdom/patch.js
export function createElm(vnode) {
let{tag, data, children, text, vm} = vnode;
if(typeof tag === 'string'){
vnode.el = document.createElement(tag)
// 初渲染,使用 data 赋值
updateProperties(vnode.el, data)
children.forEach(child => {
vnode.el.appendChild(createElm(child))
});
} else {
vnode.el = document.createTextNode(text)
}
return vnode.el;
}
// 直接使用 data 属性值替换掉真实元素 vnode.el 中对应的属性值;
function updateProperties(el, props = {} ) {
for(let key in props){
el.setAttribute(key, props[key])
}
}
updateProperties方法的重构思路:
重构后的updateProperties方法,需要同时具备“渲染”功能和“更新”功能:
- 初次渲染:使用
oldProps给vnode的el赋值即可; - 更新渲染:拿到老的
props和新的vnode中的data比对属性差异;
综上,将初次渲染与更新渲染的逻辑进行合并,得到重构逻辑:
- 第一个参数:新的虚拟节点
vnode(通过vnode.data可以拿到新的数据) - 第二个参数:老的数据(需要对新老数据进行
diff比对,因此需要传入老数据) - 将传入的新数据
vnode.data和老数据oldVnode.data两个数据对象进行比对,并更新数据对象;
重构后的updateProperties方法:
// src/vdom/patch.js
function updateProperties(vnode, oldProps = {} ) {
// 获取 dom 上的真实节点(在复用老节点时已经赋值)
let el = vnode.el;
// 获取到新的数据
let newProps = vnode.data || {};
// 新老数据比对:比对两个对象的差异
for(let key in newProps){
// 更新数据:直接使用新值覆盖老值
el.setAttribute(key, newProps[key])
}
// 老数据中存在的 key,新数据中可能没有,这部分数据需要被删除
for(let key in oldProps){
if(!newProps[key]){
el.removeAttribute(key)
}
}
}
重构后的更新流程:
let el = vnode.el = oldVnode.el;
if(!oldVnode.tag){
if(oldVnode.text !== vnode.text){
return el.textContent = vnode.text;
} else{
return;
}
}
// 更新流程:传入新的虚拟节点和老的数据
// 执行逻辑:从新的虚拟节点vnode中获取到新数据,进行新老数据对象的合并,并将合并后的数据更新到已复用的真实节点上;
updateProperties(vnode, oldVnode.data);
重构后的渲染流程:
// src/vdom/patch.js
export function createElm(vnode) {
let{tag, data, children, text, vm} = vnode;
if(typeof tag === 'string'){
vnode.el = document.createElement(tag)
// 渲染流程:传入虚拟节点和数据
// 执行逻辑:从虚拟节点vnode(此时没有新老的问题,因为没有老的,只有新的)中获取到新数据,进行新老数据对象的合并(新老数据是一样的),并将合并后的数据更新到已复用的真实节点上;
updateProperties(vnode, data)
children.forEach(child => {
vnode.el.appendChild(createElm(child))
});
} else {
vnode.el = document.createTextNode(text)
}
return vnode.el;
}
通过对重构后的执行逻辑分析:
实际上,就是使用更新逻辑去兼容渲染逻辑,从而实现
updateProperties属性更新方法的复用,使之既具有渲染功能,又有更新功能;
测试:节点的元素名相同,属性不同的情况:
let vm1 = new Vue({
data() {
return { name: 'Brave' }
}
})
let render1 = compileToFunction('<div id="a">{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);
let vm2 = new Vue({
data() {
return { name: 'BraveWang' }
}
})
let render2 = compileToFunction('<div class="b">{{name}}</div>');
let newVnode = render2.call(vm2);
setTimeout(() => {
patch(oldVnode, newVnode);
}, 1000);
测试结果:
更新前后,相同元素节点被复用,仅将id="a"更新为class="b";
除了属性需要更新外,还有其他一些特殊地属性也需要更新,比如:style样式;
内层的
name属性并未更新,后续对儿子节点进行比对后,才能实现此功能;
2-3 style的处理
对于style样式属性,需要再执行一些特殊的逻辑处理:
let vm1 = new Vue({
data() {
return { name: 'Brave' }
}
})
let render1 = compileToFunction('<div style="color:blue">{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);
let vm2 = new Vue({
data() {
return { name: 'BraveWang' }
}
})
let render2 = compileToFunction('<div style="color:red">{{name}}</div>');
let newVnode = render2.call(vm2);
setTimeout(() => {
patch(oldVnode, newVnode);
}, 1000);
实例中,新老元素都具有style属性,因此不能使用当前逻辑el.setAttribute(key, newProps[key])直接处理;
style的属性值为字符串类型,不能直接进行替换,需要对样式属性进行收集,再进行比较和更新;
function updateProperties(vnode, oldProps = {} ) {
let el = vnode.el;
let newProps = vnode.data || {};
let newStyly = newProps.style || {}; // 新样式对象
let oldStyly = oldProps.style || {}; // 老样式对象
// 老样式对象中有,新样式对象中没有,删掉多余样式
for(let key in oldStyly){
if(!newStyly[key]){
el.style[key] = ''
}
}
// 新样式对象中有,覆盖到老样式对象中
for(let key in newProps){
// 对 style 样式做处理
if(key == 'style'){
for(let key in newStyly){
el.style[key] = newStyly[key]
}
}else{
el.setAttribute(key, newProps[key])
}
}
for(let key in oldProps){
if(!newProps[key]){
el.removeAttribute(key)
}
}
}
更新前:
更新后:
至此,外层的div已经实现了diff更新,但内层的name属性还并没有更新;
接下来,继续比对儿子节点,实现子节点的更新;
四,结尾
本篇,介绍了diff算法-节点比对,主要涉及以下几点:
- 介绍了 diff 算法、对比方式、节点复用;
- 实现了外层节点的 diff 算法;
- 不同节点如何做替换更新;
- 相同节点如何做复用更新:文本、元素、样式处理;
下篇,diff算法-比对优化;
维护日志:
- 20210806:调整文章的排版布局;
- 20230217:调整部分内容描述,添加 2 个问题;
- 20230218:调整文章目录和排版;补充了大量细节说明;补充了对 vue2 节点更新 diff 算法的性能问题说明;调整了代码注释的描述、换行和缩进,使思路更加清晰、便于理解;更新文章摘要;