1. 虚拟DOM
1.1 虚拟DOM介绍
主流前端框架(Vue、React)的主要思想是数据驱动视图,以避免不必要DOM操作,从而提高Web应用程序的性能。如何高效的操作DOM,就需要使用虚拟DOM(Virtual DOM, vdom)技术。在Vue的实现中,虚拟DOM是以JavaScript对象的形式存在的,它与实际的DOM具有相同的结构和属性,但它不具有真正的DOM节点,因此可以避免进行昂贵的DOM操作。
Vue在内部使用vnode(Virtual Node)来管理虚拟DOM,vnode是虚拟DOM中的一个节点对象,包含了DOM节点的标签名、属性、子节点等信息。当应用程序状态发生变化时,Vue会创建新的vnode来描述新的虚拟DOM树,然后通过diff算法和渲染优化策略来计算出需要更新的DOM节点,并进行更新操作。
虚拟DOM实现原理如下图所示:
1.2 使用vnode模拟DOM结构
在Vue中,h函数
可以用来创建vnode,它是一个JavaScript对象,包含了描述DOM节点的各种属性和信息。
下表给出了vnode中常见的属性和它们的含义:
属性名 | 含义 |
---|---|
children | 包含节点的子节点,可以是一个vnode数组或一个字符串 |
data | 包含节点的属性、样式等信息的对象,可以使用data来设置节点的各种属性 |
elm | 当前节点对应的真实DOM元素,只有在节点被渲染到页面上后才会有值 |
key | 节点的唯一标识,用于优化DOM的更新性能 |
sel 或 tag | 当前节点的标签名,例如div、p、a |
text | 包含节点的文本内容,只有当节点没有子节点时才会有文本内容 |
- 使用vnode模拟DOM结构
<div id="div" class="container">
<p>abc</p>
<ul style="font-size: 20px">
<li>abc</li>
</ul>
</div>
以上DOM结构片段,可以通过vnode模拟创建,代码如下:
{
tag: 'div',
props: {
id: 'div1',
className: 'container'
},
children: [
{
tag: 'p',
children: 'abc'
},
{
tag: 'ul',
props: {style: 'font-size: 20px'},
children: [
{
tag: 'li',
children: 'abc'
}
]
}
]
}
1.3 通过Snabbdom实现vdom
Snabbdom 是一个轻量级的虚拟DOM库,它提供了一套简单的API,用于创建和操作虚拟DOM,并支持自定义模块,可以灵活地扩展其功能。Snbbdom的设计思路和Vue的虚拟DOM非常相似,都是采用虚拟DOM来提高Web应用程序的性能和可维护性。
以下代码是使用Snabbdom库操作虚拟DOM,通过下面这个案例可以很好的看到虚拟DOM是如何高效操作DOM元素的。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<button id="btn-change">change</button>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
<script>
const snabbdom = window.snabbdom
// 定义 patch
const patch = snabbdom.init([
snabbdom_class,
snabbdom_props,
snabbdom_style,
snabbdom_eventlisteners
])
// 定义 h
const h = snabbdom.h
const container = document.getElementById('container')
// 生成 vnode
const vnode = h('ul#list', {}, [
h('li.item', {}, 'Item 1'),
h('li.item', {}, 'Item 2')
])
patch(container, vnode)
document.getElementById('btn-change').addEventListener('click', () => {
// 生成 newVnode
const newVnode = h('ul#list', {}, [
h('li.item', {}, 'Item 1'),
h('li.item', {}, 'Item B'),
h('li.item', {}, 'Item 3')
])
patch(vnode, newVnode)
})
// vnode = newVnode // patch 之后,应该用新的覆盖现有的 vnode ,否则每次 change 都是新旧对比
</script>
</body>
</html>
效果:点击change按钮之后,会更改list中的第二项内容并增加了第三项,从下图可以看到,DOM结构中第一个list项的DOM元素并没有重新渲染,只是渲染了第二个和第三个list项的DOM元素。(新渲染的DOM元素闪红了一下)。
2. diff 算法
2.1 diff 算法概述
Vue中的diff算法是指在更新虚拟DOM时,对新旧虚拟DOM进行比较,并尽可能地减少DOM操作的算法。使用diff算法比较两棵树的时间复杂度为 O ( n 3 ) O\left(n^3\right) O(n3),如果一棵树有1000个节点,就要执行十亿次计算,那么算法是不可用的。Vue对diff算法进行了优化,使得算法的时间复杂度降到了 O ( n ) O\left(n\right) O(n)。
Vue的diff算法的优化策略如下:
- 只比较同一层级,不跨级比较。
- tag和key都相同,则认为是相同节点,递归地进行子节点的比较。
- tag不相同,则直接删掉重建,不再深度比较。
2.2 源码—h函数
h()
函数通过函数重载的方式定义,当传入函数的参数个数、参数类型不同时,函数会执行不同的内容。h()
函数的返回结果是执行 vnode()
函数。
vnode()
函数返回一个JS对象,也就是虚拟DOM。
2.3 源码—patch函数
patch()
函数用于比较两个虚拟DOM树的根节点是否相同。patch()函数中,传入两个参数,第一个参数可以是vnode也可以是普通DOM元素,代表旧的vnode;第二个参数是vnode,代表新的vnode。
- 当第一个参数是DOM元素时,函数会先创建一个空的vnode,然后将这个空的vnode关联到传入的DOM元素上。
- 如果传入的两个参数都是vnode,且两个vnode相同(它们有相同的 sel 和 key),此时会调用
patchVnode()
函数,用于比较这两个vnode的子节点。 - 如果传入的两个vnode不相同,那么会删除旧的vnode,然后根据新的vnode,重建这个删除掉的旧的vnode。
2.4 源码—patchVnode函数
patchVnode()函数
用于比较相同vnode节点的子集(text、children)。实现函数的流程图如下图所示:
- 源码:
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 执行 prepatch hook 生命周期
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
// 设置 vnode.elem,将旧的 vnode 的 elem,赋给新的 vnode
const elm = vnode.elm = oldVnode.elm!;
// 旧 children
let oldCh = oldVnode.children as VNode[];
// 新 children
let ch = vnode.children as VNode[];
if (oldVnode === vnode) return;
// hook 相关
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
vnode.data.hook?.update?.(oldVnode, vnode);
}
// 如果新的 vnode.text === undefined (意味着新 vnode.children 有值)
if (isUndef(vnode.text)) {
// 新旧都有 children,此时调用 updateChildren 函数
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
// 新 children 有,旧 children 无 (旧 text 有)
} else if (isDef(ch)) {
// 清空 text
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 添加 children
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
// 旧 child 有,新 child 无
} else if (isDef(oldCh)) {
// 移除 children
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
// 旧 text 有
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
// else : vnode.text !== undefined (意味着 vnode.children 无值)
// 新的 vnode 的 text 有值,且和旧的 vnode 的 text 值不一样,那么就删除旧 vnode 的 children,
// 设置为新 vnode 的 text 值。
} else if (oldVnode.text !== vnode.text) {
// 移除旧 children
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置新 text
api.setTextContent(elm, vnode.text!);
}
hook?.postpatch?.(oldVnode, vnode);
}
2.5 源码—updateChildren函数
updateChildren()函数
采用了双端比较策略,即同时从新旧vnode子节点的头部和尾部进行进步,直到两端的指针相遇。
- 源码
function updateChildren (parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue) {
let oldStartIdx = 0, 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];
// 旧开始 和 新开始 对比
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
// 旧结束 和 新结束 对比
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
// 旧开始 和 新结束 对比
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
// 旧结束 和 新开始 对比
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 以上四个都未命中
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 拿新节点 key ,能否对应上 oldCh 中的某个节点的 key
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 没对应上,直接插入节点
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
newStartVnode = newCh[++newStartIdx];
// 对应上了
} else {
// 对应上 key 的节点
elmToMove = oldCh[idxInOld];
// sel 是否相等(sameVnode 的条件)
if (elmToMove.sel !== newStartVnode.sel) {
// New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
// sel 相等,key 相等
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
3. 总结
Vue框架使用MVVM模式,以动态渲染组件的方式代替了传统的静态渲染,避免了不必要的DOM操作,从而提高了渲染性能。在MMVM模式中,通过Vue的响应式机制来监听状态的变化从而更新视图界面,视图界面的变化通过Vue指令和事件来改变状态。
如何高效的操作DOM,就需要使用虚拟DOM(Virtual DOM, vdom)技术。通过比对两棵新旧虚拟DOM树上的vnode节点,找出最小的更新范围,从而减少DOM操作。diff算法就是用来比较新旧DOM树并完成DOM更新的算法,diff算法的核心包括四个函数,即h函数、patch函数、patchVnode函数、updateChildren函数。
- h函数在diff算法初始化时,新建vnode节点。
- patch函数用于比较新旧虚拟DOM树的根节点,当根节点的sel和key不相同时,直接删除旧的vnode,创建新的vnode,不再比较下面的子节点;当根节点的sel和key相同时,此时会调用patchVnode函数,比较该节点的子集(text和children)。
- patchVnode函数是用来比较两个vnode并更新DOM的核心函数,它会比较新旧vnode节点的text和children。
- updateChildren函数是当新旧vnode节点都存在children时,对两个vnode的子节点进行比对。使用双端比较策略。如果新旧子节点存在sel和key相等的清空,就会递归调用patchVnode函数,比较其text和children。