虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attch 到 DOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象(虚拟 DOM)上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。
虚拟 dom 与 diff 算法
虚拟 DOM 和真实 DOM
我是这样理解的虚拟 dom 的,虚拟 dom 就是将真实 dom 抽象成一个对象。当我们要多次操 作 dom 的时候,我们可以将真实 dom 转换为虚拟 dom,在虚拟 dom 中完成相应内容的更改再批量同步到真实 dom 上去。这样子可以减小操作 dom 引起的重排次数。其中,虚拟 dom 的更新会遵循最小量更新,这就是 diff 算法。
diff 算法
diff 算法就是比较两个虚拟 dom 的算法,他是广度优先的,时间复杂度是 O(n)。
在比较两棵树时,如果两颗树的顶级节点的标签名或者 key 值不一样,diff 算法就会认为这是两颗不一样的树,因此他会销毁旧的 dom 树,再重新创建一棵树。
对于同一层次的节点,会以 key 值作为标识来进行,同一节点只移动,不会销毁后再创建新节点。
下面我们一起来看一个diff算法库snabbdom,vue2.x也是基于这个库的思想实现diff算法的
snabbdom 库主要函数解析
vode
我们可以看一下 vnde 的源代码
export default function(sel,data,children,text,elm){
const key = data === undefined ? undefined : data.key;
return { sel, data, children, text, elm, key };
}
可以看到他是将一些参数包装成一个虚拟 dom 这,主要的属性有:选择器,key值,子节点,文本节点,真实dom的映射等等。
h 函数
import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'
export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {
data.ns = 'http://www.w3.org/2000/svg'
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
const childData = children[i].data
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
}
}
}
}
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
if (c !== undefined) {
if (b !== null) {
data = b
}
if (is.array(c)) {
children = c
} else if (is.primitive(c)) {
text = c
} else if (c && c.sel) {
children = [c]
}
} else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b
} else if (is.primitive(b)) {
text = b
} else if (b && b.sel) {
children = [b]
} else { data = b }
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
addNS(data, children, sel)
}
return vnode(sel, data, children, text, undefined)
};
虽然 h 函数代码比较多,但是我们看到它无非做了一件事情,将传进来的参数构建成一个虚拟 dom
patch 函数
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// 判断老节点是不是虚拟dom,不是就先将它转换为虚拟dom
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
// 判断是不是同一个虚拟节点,这里只是判断了是不是sel和key相同
if (sameVnode(oldVnode, vnode)) {
// 进行最小量更新
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else { // 不同虚拟节点
// 这里执行的就是创建新节点,销毁旧节点
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// 将虚拟dom转换为真实dom
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
}
}
createElm 函数
unction createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any
let data = vnode.data
if (data !== undefined) {
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
const children = vnode.children
const sel = vnode.sel
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// Parse selector
const hashIdx = sel.indexOf('#')
const dotIdx = sel.indexOf('.', hashIdx)
const hash = hashIdx > 0 ? hashIdx : sel.length
const dot = dotIdx > 0 ? dotIdx : sel.length
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag)
: api.createElement(tag)
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/./g, ' '))
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
if (is.array(children)) { // 有children节点 递归将children节点转换为真实dom再将其添加
for (i = 0; i < children.length; ++i) {
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
} else if (is.primitive(vnode.text)) { // 文本节点,添加文本
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
insertedVnodeQueue.push(vnode)
}
}
} else {
vnode.elm = api.createTextNode(vnode.text!)
}
return vnode.elm
}
可以看到最后将创建的 dom 添加到了虚拟 dom 的 lelm 属性上
patchNode 函数
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
// 获取data的属性
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
if (isUndef(vnode.text)) { // 有文本节点
if (isDef(oldCh) && isDef(ch)) {// 新旧节点都有子节点
// 新旧节点的子节点不相同,进行最小量更新
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 只有新节点有子节点,即老节点是文本节点,子节点替换文本节点即可
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 只有老节点有子节点,移出子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 只有老节点有文本,新节点没有,删除文本
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
//新老节点都有文本,且不一样
if (isDef(oldCh)) { // 移出老节点的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 新节点文本替换老节点文本
api.setTextContent(elm, vnode.text!)
}
hook?.postpatch?.(oldVnode, vnode)
}
updateChildren 函数(更新子节点)
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 // 一个map保存旧节点的值,键是旧节点的key,值是旧节点的下标
let idxInOld: number // 在旧节点的下标
let elmToMove: VNode // 要移动的旧节点
let before: any // 插入标杆
// 当新节点或旧节点遍历完,循环结束
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 为null的跳过
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)
}
idxInOld = oldKeyToIdx[newStartVnode.key as string] // 新前节点在旧节点中对应的下标
if (isUndef(idxInOld)) { // New element
// 没有对应上,进行插入,插入到旧前前面
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 在旧节点集合中找到了
elmToMove = oldCh[idxInOld] // 要被移动的节点
if (elmToMove.sel !== newStartVnode.sel) {
// 不同一标签
// 执行插入,将新前节点插入到旧前节点之前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} 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)
}
}
}
可以看到,更新子节点是比较麻烦的,利用了四个指针,依次按照旧前结点和新前结点,旧后结点与新后结点,旧前结点与新后结点,旧后结点与新前结点来进行比较。这就是 sanbbdom 的核心 diff 算法的大概流程了