1.虚拟dom与diff算法
前言
在上一章节《vue3解读—setup与render》中,针对内容情节对vdom进行了介绍,而diff算法就是在vdom的基础上进行前后比对,针对有变化的vdom元素进行内容更新,以起到性能优化的目的。
vdom比对
为了提升每一遍文章的独立性,这里会再次展示当前的demo代码。
// App.js
import h from './core/h.js';
export default {
render(context) {
// 实际我们templete中的代码最终都会编译成render函数
return h(
'div',
{ id: 'appId'+context.state.count, class: 'name' },
[h('p', { class: 'text' }, String(context.state.count)),
h('span', null, 'span标签')]
);
},
setup() {
const state = reactive({
count: 0,
});
window.state = state; // 挂载全局,方便测试
return { state };
}
}
// core/h.js
export default function(tag, props, children) {
return {
tag,
props,
children,
};
}
// core/index.js
import { diff, mountElement } from "./renderer/index.js";
export function createApp(rootComponent) {
return {
mount(rootContainer) {
const context = rootComponent.setup(); // 将setup返回值拿到
let isMounted = false; // 是否首次挂载,不是则首次渲染,否则diff算法比对更新
let prevSubTree = null; // 储存老的vdom
effectWatch(() => {
if (!isMounted) {
isMounted = true; // 置为true
rootContainer.innerHTML = ``;
// 将setup返回值渲染到容器中
const subTree = rootComponent.render(context); // context值发生变化,effectWatch会重新执行
console.log('subTree----', subTree);
mountElement(subTree, rootContainer);
prevSubTree = subTree; // 储存当前vdom
} else {
const subTree = rootComponent.render(context);
diff(prevSubTree, subTree); // diff算法比对前后两次subTree的变化
prevSubTree = subTree; // 每次更新完毕后保存vdom
}
});
}
}
}
// core/render/index
export function mountElement(vnode, container) {
const { tag, props, children} = vnode; // 由h方法返回的对象
// 创建tag标签
const el = (vnode.el = document.createElement(tag));
// 创建props属性
if (props) {
for(const key in props) {
const val = props[key];
el.setAttribute(key, val);
}
}
// 创建children
// 1. 字符
if (typeof children === 'string') {
const textNode = document.createTextNode(children);
el.append(textNode);
} else if (Array.isArray(children)) {
// 2. 数组
children.forEach(child => {
mountElement(child, el);
})
}
// 插入根元素
container.append(el);
}
到这里我们已经能够渲染出我们想要的元素节点,关键地方的描述我已经写在了代码注释里结合代码更方便阅读理解。接下来我们就是要实现diff算法,使得非首次渲染时只渲染已经变化的类型,减少性能损耗。
diff算法
接下来是最重要的diff算法了,我们需要思考怎么才能找到前后两次内容变化的部分。
我们要理清所写的代码结构:首先由标签tag包裹子元素,标签有class等属性,所以我们只需要先判断标签tag,再判断标签属性,最后判断子元素是标签节点还是文本节点,是标签节点则递归回到第一步继续遍历,是文本节点则比对文本结束diff算法。
/**
* diff比对更新对应节点
* @param {*} n1 旧的节点
* @param {*} n2 新的节点
*/
export function diff(n1, n2) {
if (n1.tag !== n2.tag) {
// 前后两次的标签不一样,则更新标签
n1.el.replaceWith(document.createElement(n2.tag));
} else {
// 标签一样,则将旧的el赋给新的节点,再对节点的属性及子节点进行比对
n2.el = n1.el;
const { props: newProps, children: newChildren } = n2;
const { props: oldProps, children: oldChildren } = n1;
// 遍历比对节点属性是否变化或者新增
if (newProps && oldProps) {
Object.keys(newProps).forEach(key => {
const newVal = newProps[key];
const oldVal = oldProps[key];
if (newVal !== oldVal) {
n1.el.setAttribute(key, newVal);
}
});
}
if (oldProps) {
Object.keys(oldProps).forEach(key => {
if (!newProps[key]) {
n1.el.removeAttribute(key);
}
});
}
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
// 前后都是文本,且文本不相等
if (newChildren !== oldChildren) {
n2.el.textContent = newChildren;
}
} else if (Array.isArray(oldChildren)) {
// 新子节点问文本则直接替换
n2.el.textContent = newChildren;
}
} else if (Array.isArray(newChildren)) {
if (typeof oldChildren === 'string') {
// 清空文本,渲染新的子节点
n2.el.container = '';
mountElement(n2, n2.el);
} else if (Array.isArray(oldChildren)) {
// 这里是最复杂的,前后都为标签元素
const length = Math.min(newChildren.length, oldChildren.length);
// 处理公共vnode,取同一级的标签元素,进行比对
for (let index = 0;index < length;index ++) {
const newVnode = newChildren[index];
const oldVnode = oldChildren[index];
diff(oldVnode, newVnode);
}
if (newChildren.length > length) {
// 如果新的子节点大于旧的子节点,创建新节点中多余的
for (let index = length;index < newChildren.length;index ++) {
const newVnode = newChildren[index];
mountElement(newVnode);
}
}
if (oldChildren.length > length) {
// 如果旧的子节点大于新的子节点,删除旧节点中多余的
for (let index = length;index < oldChildren.length;index ++) {
const oldVnode = oldChildren[index];
oldVnode.el.parent.removeChild(oldVnode.el);
}
}
}
}
}
}
这里的难点主要在于子节点的比对,这里我考虑到了四种变化,第一种:子节点前后仍为文本节点;第二种:子节点由标签节点更新为文本节点;第三种和第四种就是前后都是标签节点,但是内容有删改,这两种也是实现起来比前两者更为复杂的两种变化。在代码中都已经进行了注释,结合代码更容易理解思路的实现哦。
如下图,我们删除子节点,实际上只是div#appId0发生了变化,子元素span是没有发生变化的。