为啥会有VDOM?
真实DOM是怎么工作的
真实DOM的渲染大致分为5步,创建DOM树——创建StyleRules——创建Render树——布局——绘制。
- 用HTML分析器,分析HTML元素,构建一颗DOM树(标记化和树构建)。
- 用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
- 第三步,将DOM树和样式表,关联起来,构建一颗Render树(这一过程又称为Attachment)。每个DOM节点都有attach方法,接受样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
- 第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
- 第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。
真实DOM的更新
我们用jQuery操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作DOM的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。
于是乎,VDOM就应运而生了。用JS去模拟DOM结构,计算出最小的变更,然后再操作DOM。
用VNode模拟DOM结构
<div id='div1' class='container'>
<p>vdom</p>
<ul style='font-size: 20px;'>
<li>a</li>
</ul>
</div>
VNode模拟
{
tag: 'div',
props: {
id: 'div1',
className: 'container',
},
children: [
{
tag: 'p',
children: 'vdom',
},
{
tag: 'ul',
props: {
style: 'font-size: 20px'
},
children: [
{
tag: 'li',
children: 'a'
},
]
}
]
}
diff算法
用VDOM来模拟DOM之后,我们可以用diff算法计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。
传统diff算法
diff算法是一个广泛的概念,比如linux diff命令,git diff等。两个js对象也可以做diff。还有两棵树做diff,比如这里的vdom diff。
我们拿树的diff算法来说,通过对两棵树循环递归对每个节点进行对比,算法的复杂度就达到了O(n^3),n是数的节点数。所以,如果我们要展示1000个节点,我们就需要执行1亿次比较。。。这个就比较恐怖了。。。
优化后的diff算法
优化后的diff算法将O(n^3)复杂度转化为O(n),通过下面几个策略:
- 只比较同一层级,不跨级比较
- tag不相同,则直接删掉重建,不再深度比较
- tag和key,两者都相同,则认为是相同节点,不再深度比较。
VNode 和 diff算法相结合
vue在官方文档中提到与react的渲染性能对比中,因为其使用了snabbdom而有更优异的性能。
JavaScript 开销直接与求算必要 DOM 操作的机制相关。尽管 Vue 和 React 都使用了 Virtual Dom 实现这一点,但 Vue 的 Virtual Dom 实现(复刻自 snabbdom)是更加轻量化的,因此也就比 React 的实现更高效。
下面咱们就通过snabbdom源码来看看吧~
VNode
在文章一开始,我们就用VNode来模拟DOM,那什么是VNode呢?
Snabbdom 的 Virtual Node 则是纯数据对象,通过 vnode 模块来创建,对象属性包括:
-
sel
-
data
-
children
-
text
-
elm
-
key 可以看到 Virtual Node 用于创建真实节点的数据包括:
-
元素类型
-
元素属性
-
元素的子节点
//VNode函数,用于将输入转化成VNode
/**
*
* @param sel 选择器
* @param data 绑定的数据
* @param children 子节点数组
* @param text 当前text节点内容
* @param elm 对真实dom element的引用
* @returns {{sel: *, data: *, children: *, text: *, elm: *, key: undefined}}
*/
function vnode(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return { sel: sel, data: data, children: children,
text: text, elm: elm, key: key };
}
但是呢,Snabbdom并没有把vnode直接暴露给我们用,而是用了h包装起,h的主要功能是处理参数:
h(sel,[data],[children],[text]) => vnode
从Snabbdom的源码可以看出,其实就是这几种函数:
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, text: string): VNode;
export function h(sel: string, children: Array<VNode | undefined | null>): VNode;
export function h(sel: string, data: VNodeData, text: string): VNode;
export function h(sel: string, data: VNodeData, children: Array<VNode | undefined | null>): VNode;
patch
创建vnode之后,接下来就是调用patch()渲染成真实dom。
patch是snabbdom的init函数返回的。snabbdom.init传入modules数组,module用来扩展snabbdom创建复杂dom的能力。
上patch源码
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
//执行callback pre hook(dom的生命周期)
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
//第一个参数不是vnode
if (!isVnode(oldVnode)) {
//创建一个空的vnode,关联到这个DOM元素
oldVnode = emptyNodeAt(oldVnode);
}
//相同的vnode(key 和 sel 都相同)
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
}
//不同的vnode,直接删除重建
else {
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
//重建
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
先判断oldVnode和vnode是否是相同的,如果是才可以执行patchVnode,否则创建新的dom删除旧的dom。 判断是否相同的源码很简单,根据优化后的diff算法策略1和3,只在同一级比较key和tag:
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;
}
如果相同,则调用patchVnode进行对比。
graph TB
start[patch函数被调用] --> conditionA{oldVnode是虚拟节点还是DOM节点}
conditionA -- 是DOM节点 --> operationA[将oldVnode包装成虚拟节点]
operationA --> conditionB{判断oldVnode和newVnode是同一个节点}
conditionA -- 是虚拟节点 --> conditionB
conditionB -- 不是 --> operationB[暴力删除oldVnode,插入newVnode]
conditionB -- 是 --> operationC[调用patchVnode]
patchVnode
下面开始讲patchVnode
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
//执行prepatch hook,类似于生命周期的钩子
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
//设置vnode element,赋值成和旧的一样就可以
const elm = (vnode.elm = oldVnode.elm)!;
//old children
const oldCh = oldVnode.children as VNode[];
//new children
const 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 !== undefined
if (isUndef(vnode.text)) {
//新旧都有children
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
}
//旧children没有,新children有
else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
}
//旧children有,新children没有
else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
//旧的text有值,新的没有
else if (isDef(oldVnode.text)) {
api.setTextContent(elm, "");
}
}
// vnode.text !== undefined 意味着 vnode.children === undefined
else if (oldVnode.text !== vnode.text) {
//移除旧的text
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
//设置新的text
api.setTextContent(elm, vnode.text!);
}
hook?.postpatch?.(oldVnode, vnode);
}
graph TB
condition1{oldVnode和newVnode是否是同一个对象} -- 是 --> op1[什么都不做]
condition1 -- 不是 --> condition2{newVnode有没有text属性}
condition2 -- 有 --> condition3{newVnode和oldVnode的text属性是否相同}
condition3 -- 一样 --> op1
condition3 -- 不一样 --> op2[更新text]
condition2 -- 没有 --> condition4{oldVnode有没有children}
condition4 -- 没有 --> op3[清空text添加children]
condition4 -- 有 --> op4[调用updateChildren]
updateChildren
patchVnode最关键最核心的是updateChildren().这个方法是实现diff算法的主要地方。
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];
}
// 新旧 start vnode的对比
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
//新旧 end vnode的对比
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
//旧的start vnode和新的end vnode对比
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];
}
//旧的end vnode和新的start vnode对比
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,能否对应上old children中某个节点的key
idxInOld = oldKeyToIdx[newStartVnode.key as string];
//没有对应上,需要创造新元素
if (isUndef(idxInOld)) {
// New element
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
}
//对应上了
else {
elmToMove = oldCh[idxInOld];
//比较selector,不相等
if (elmToMove.sel !== newStartVnode.sel) {
//new element
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
}
//selector相等
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);
}
}
}
代码比较复杂,上图讲可能更容易理解。
调用updateChildren()时oldCh值和newCh值。
定义oldStartIdx,oldEndIdx,newStartIdx, newEndIdx
先做4种情况的比较,
- oldVnode的start和newVnode的start,为true则patch之后就移动头指针 oldStartIdx++ newStartIdx++
- oldVnode的end和newVnode的end,为true则patch之后移动尾指针 oldEndIdx-- newEndIdx--
- oldVnode的start和newVnode的end,为true则newEndIdx 指向的节点移动到 oldStartIdx 之后 oldStartIdx++ newEndIdx--
- oldVnode的end和newVnode的start,为true则newStartIdx 指向的节点到 oldEndIdx 前面 oldEndIdx-- newStartIdx++
当然了,不排除以上4种情况都不符合的时候,这个时候就需要遍历key了。如果找到了,就patchVNode(),如果没有找到,就会新增。
比较结束后,会做一个判断。
- newVnode中还有剩余。新节点中剩余的都 插入 旧节点oldEnd后面 或 oldStart之前
- oldVnode中还有剩余节点,直接删除。
整个流程图就是如下
graph TD
start[patch函数被调用] --> conditionA{oldVnode是虚拟节点还是DOM节点}
conditionA -- 是DOM节点 --> operationA[将oldVnode包装成虚拟节点]
operationA --> conditionB{判断oldVnode和newVnode是同一个节点}
conditionA -- 是虚拟节点 --> conditionB
conditionB -- 不是 --> operationB[暴力删除oldVnode,插入newVnode]
conditionB -- 是 --> condition1{oldVnode和newVnode是否是同一个对象}
condition1 -- 是 --> op1[什么都不做]
condition1 -- 不是 --> condition2{newVnode有没有text属性}
condition2 -- 有 --> condition3{newVnode和oldVnode的text属性是否相同}
condition3 -- 一样 --> op1
condition3 -- 不一样 --> op2[更新text]
condition2 -- 没有 --> condition4{oldVnode有没有children}
condition4 -- 没有 --> op3[清空text添加children]
condition4 -- 有 --> op4[diff算法]
op4 --> op5[几种情况的命中] --> condition5{是否还有剩余项}
condition5 -- 旧节点全部结束 --> 创建新元素插入
condition5 -- 新节点全部结束 --> 剩余旧节点全部删除