虚拟DOM
DOM操作非常耗费性能,在操作DOM时,会出现DOM的回流(Reflow:元素的大小或者位置发生了变化) 和重绘(Repaint:元素样式的改变),重新渲染DOM,可以看看我以前的文章----浏览器渲染过程
现在的框架Vue和react很少直接操作DOM,因为Vue和React是数据驱动视图,我们只会对数据进行增删改的处理,那么框架是如何控制DOM操作的呢?
react和vue使用虚拟DOM(vdom)的来解决这个问题,主要的原理是:用JS模拟DOM结构,把DOM的计算转移为js的计算,使用diff算法计算出最小的变更,然后根据变更操作DOM,减小计算量。
例如:使用js对象表示html的树形结构:
下面我们通过snabbdom这个vdom库的源码来学习vdom和diff算法,Vue也是参考它实现的vdom和diff
首先看一下官方的例子
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: anotherEventHandler } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// Second ` patch ` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
有两个关键函数:
h
函数返回一个vnode,他是是使用js对象表示的虚拟DOM结构。接受sel
(选择器),data
(对DOM的js描述),children
(这个虚拟DOM的子vnode元素)patch
函数的作用一是用来将vnode渲染为真实DOM挂载到页面,二是使用diff算法对比两个vnode的不同,然后对DOM进行重新渲染。
返回的vNode结构如下:
总结:
- DOM操作非常耗费性能,因为回流重绘
- Vue和React是数据驱动视图,使用JS模拟DOM结构(vnode),把DOM的计算转移为js的计算,使用diff算法比较新旧vnode,计算出最小的变更,然后根据变更更新DOM,减小计算量。
- snabbdom库中的
h
函数生成一个vnode,patch
函数进行DOM渲染和使用diff算法更新DOM
diff算法
概述
比较两个新旧vNode的diff的过程主要是在 patch
函数中进行,
如果正常情况下两棵树之间作对比,那么第一,遍历tree1 ;第二,遍历tree2 ;第三,排序 ,一共要遍历三次,所以树diff的时间复杂度O(n^3)。假设有1000个DOM节点,要计算1亿次,算法不可用
框架中的diff算法优化如下:
- 只比较同一层级,不跨级比较
- tag不相同,则直接删掉重建,不再深度比较(有可能tag不相同但是tag下面的子元素还是相同的,但是我们不管了,只要tag不相同就删掉,因为深度比较复杂度太高)
- tag和key ,两者都相同,则认为是相同节点,不再深度比较 优化时间复杂度到O(n)
接下来解读源码的核心的函数,来了解一下diff的大致流程
生成vnode
h.ts
文件里的 h
函数用来生成vNode,我们来看一下这个源码
//h.ts
...
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
...//细节忽略不用细看
// 返回 vnode
return vnode(sel, data, children, text, undefined);
};
export default h;
然后看看 vnode
函数:
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
//返回一个js对象结构的虚拟DOM
return { sel, data, children, text, elm, key };
}
- 返回一个js对象结构的虚拟DOM(vNode)。
children
和text
是不能共存的,要么里面是纯text文本,要么是子元素elm
就是vnode对应的那个DOM元素key
就相当于v-for
里面的key
,是我们在使用v-for
的时候需要自己手动加上
patch
函数
patch
函数是 init
返回的,具体源码在snabbdom.ts里
解析如下:
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
...
// 第一个参数不是 vnode
if (!isVnode(oldVnode)) {
// 创建一个空的 vnode ,关联到这个 DOM 元素
oldVnode = emptyNodeAt(oldVnode);
}
// 相同的 vnode(key 和 sel 都相等)
if (sameVnode(oldVnode, vnode)) {
// vnode 对比
patchVnode(oldVnode, vnode, insertedVnodeQueue);
// 不同的 vnode ,直接删掉重建
} else {
elm = oldVnode.elm!;
parent = api.parentNode(elm);
// 重建
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
...//其余代码先不看
return vnode;
};
- 如果是相同的 vnode(判断方法是
key
和sel
都相等),执行patchvNode
函数,继续进行对比 - 不同的 vnode ,直接删掉重建
sameVnode
的函数如下:
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
// key 和 sel 都相等
// undefined === undefined // true
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
可见,sameVnode
原理如下:
如果都不传,都没有 key
(不是 v-for
),那就比较两个vNode的 sel
选择器来判断是不是同一个 vnode
了,如果有 key
,就一起比较 key
和 sel
总结:
- 第一次执行
patch
,patch(container, vnode);
,创建一个空的vnode,关联传进来的dom,让传入的两个参数都变为vnode,然后执行下面的逻辑 - 如果是相同的vnode,tag(sel)和key两者都相同,则认为是相同节点。执行
patchVnode
方法。 - 如果是不同的vnode,tag(sel)不相同,则直接删掉重建,不再深度比较
patchVnode
函数
上面的patch
函数中,如果两个vnode相同,就执行 patchVnode
方法,对比vnode。
patchVnode
具体过程如下
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 设置 vnode.elem,新vnode可能没有elm,所以把旧的赋给他,因为要知道需要更新哪个真实的dom元素
const elm = vnode.elm = oldVnode.elm!;
// 旧 children
let oldCh = oldVnode.children as VNode[];
// 新 children
let ch = vnode.children as VNode[];
if (oldVnode === vnode) return;
// vnode.text === undefined (vnode.children 一般有值)
if (isUndef(vnode.text)) {
// 新旧都有 children
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 无值)
} else if (oldVnode.text !== vnode.text) {
// 移除旧 children
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置新 text
api.setTextContent(elm, vnode.text!);
}
}
总结:
- 如果新的vnode有
children
,没有text
(vnode.text === undefined
(text
和children
不能同时存在)):- 如果新旧vnode都有
children
,调用updateChildren()
方法,再继续进行更新 - 如果新
children
有,旧children
无,就调用addVnodes()
方法,把新的children
添加到elm
上, - 如果新
children
无,旧children
有,就调用removeVnodes()
方法移除旧的vnode的children
- 最后还剩一种情况,新旧都没
children
,旧的vnode有text
,新的vnode没有text
,那么就把elm
的text
设置为空
- 如果新旧vnode都有
- 如果新的vnode没有
children
只有text
,(vnode.text !== undefined
(vnode.children
无值)),而且新旧text
还不一样 (oldVnode.text !== vnode.text
) ,就移除旧的vnode的children
,替换成新vnode的text
其中只有新旧vnode都有 children
,的情况下,需要调用 updateChildren()
方法, update
方法比较复杂,其余的情况都是简单的调用DOM 的api新增,移除DOM元素。下面说说 updateChildren()
方法
updateChildren
函数
updateChildren
比较复杂,可以只去理解流程。
传入元素elm
,旧的children
,新的chlidren
上图字幕代表vnode的 key
和tag(sel)的组合,用来区分是不是同一个vnode。
过程如上图所示
原理:
- 针对新旧
ch
我们定义四个index,oldStartIdx
,oldEndIdx
,newStartIdx
,newEndIdx
,然后进行一个循环,在循环过程中,idx会一边累加或者一边累减,startIdx会累加,endIdx会累减,在这个过程中,指针会慢慢地往中间去移动,当指针重合的时候,说明遍历结束了,循环结束。
在每一轮循环过程中的具体的对比过程是:
- 如果出现下面四种情况中的一种相同的情况:开始和开始节点去对比,结束和结束节点对比,结束和开始节点对比,那么就执行
patchVnode()
函数,进行递归比较,并且指针累加或者累减,往中间移动。 进行下一轮循环的时候,指针就指到下一个了children
- 如果都没有上面的四种情况,首先会拿新节点
key
,能否对应上 oldCh 中的某个节点的key
。- 如果没有对应上,说明这个节点是新的,找个地方插入进去新的就好。
- 如果对应上了,还要判断
sel
是否相等,如果sel
不相等,那还是没对应上,说明节点是新的,那也找地方插入新的。 - 如果
sel
相等,key
相等,那么继续对这两个相同的节点执行patchVnode
方法,递归比较。
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);
}
}
}
diff总结
- patchVnode
- addVnodes removeVnodes
- updateChildren ( key的重要性)
为什么v-for要使用key
从上面 updateChildren
函数可以看出,key
是一个比较新旧两个vNode是否相等的关键条件
根据源码可以知道:
-
如果不使用
key
,diff发现没有key
能对应上,会认为所有的节点都更新了,算法会销毁所有vnode,重新渲染新的元素。 -
如果检测出来新节点中的
key
对应旧节点中的某个key
,比如进行进行互换位置的操作,就没有必要销毁重新渲染,仅仅互换位置。可提高性能
如果key使用随机数是没有作用的,因为一更新随机数就变成新的了,所有的key
都对应不上,所以就和没有写一样,就会重新渲染
如果key
使用数组的index
,如果原来的元素从1的位置换到0,那么diff之后,会出现问题,算法会误认为两个元素没有变换,然后就不更新
总结DOMdiff算法的过程
vue的虚拟DOM和diff算法是参考snabbdom这个库来实现的,那么我就通过这个库来说明一下
库里有一个关键函数patch
函数,入两个新旧虚拟DOM,用diff算法对比两个vnode的不同,然后对DOM进行重新渲染。
为了防止时间复杂度变为n^3,框架中的diff算法优化如下:
- 只比较同一层级,不跨级比较
- tag不相同,则直接删掉重建,不再深度比较
优化时间复杂度到O(n)
第一步patch
函数的逻辑
这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签
判断的关键为sameVnode()
方法,key和标签名是都相同
- 如果是相同的 vnode(判断方法是
key
和sel
都相等),执行patchvNode
函数,继续进行对比 - 不同的 vnode ,直接删掉重建,换成新的虚拟节点
第二步patchVnode
函数
具体逻辑是判断逻辑是
- 如果新旧的的vnode都有
children
,那么就要使用updateChildren()
方法进行更复杂的比较 - 剩下的情况就是新对旧的覆盖,使用api来替换不同的DOM,或文本节点。如oldVnode有子节点newVnode没有,用
removeVnodes()
。odVnode没子节点newVnode有,用addVnodes()
,只有文本节点就进行直接的替换
第三步updateChildren
函数
updateChildren
函数传入旧的children
,新的chlidren
,行新旧虚拟节点的子节点对比。
原理:
使用首尾指针法,一共标注四个指针,分别是新旧子节点的开始和结束。然后循环进行比较。在循环过程中,startIdx会累加,endIdx会累减,指针会慢慢地往中间去移动,当指针重合的时候,说明遍历结束了,循环结束。
在每一轮循环过程中的具体的对比过程是:
-
使用
sameVnode()
方法进行四种情况的对比:开始和开始节点去对比,结束和结束节点对比,结束和开始节点对比,如果相同,说明对应上了,那么就执行patchVnode()
函数,继续进行递归地替换或者对比,用新节点的信息覆盖旧节点的信息。进行下一轮循环的时候,指针就指到下一个了children
-
如果都没有上面的四种情况,首先会拿新节点
key
,能否对应上 oldCh 中的某个节点的key
。- 如果没有对应上,说明这个节点是新的,找个地方插入进去新的就好。
- 如果对应上了,就这两个相同的节点执行
patchVnode
方法,递归比较。