看到会返回一个patch
函数。看到init内部有很多函数,这些函数大都都是用到api
进行DOM操作,而api
依赖入参domApi
(如果放在外侧,domApi需要作为参数传递)。 这里实际上通过闭包私有化这些函数作为方法存在。
看到在init
方法入口处从入参modules
中收集了指定的钩子回调。这样清楚了module
的构造和用意。modules是一个对象,键就是init中cbs的key有:create\update\remove\destroy\pre\post,而值是函数。这样就可以参与从虚拟DOM到真实DOM的过程。
export function init(modules, domApi, options) {
const cbs = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: [],
};
const api = domApi !== undefined ? domApi : htmlDomApi;
for (const hook of hooks) {
for (const module of modules) {
const currentHook = module[hook];
if (currentHook !== undefined) {
cbs[hook].push(currentHook);
}
}
}
function emptyNodeAt(elm) {
//...
}
function emptyDocumentFragmentAt(frag) {
//...
}
function createRmCb(childElm, listeners) {
return function rmCb() {
//...
};
}
function createElm(vnode, insertedVnodeQueue) {
//...
}
function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
//...
}
function invokeDestroyHook(vnode) {
//...
}
function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
//...
}
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
//...
}
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
//...
}
return function patch(oldVnode, vnode) {
//...
return vnode;
};
}
下面看下patch函数的实现
触发pre
钩子,相当于会执行module
提供的pre
指向的函数
如果是真实的DOM节点,并根据该DOM创建对应的虚拟节点,【注意】此时会忽略其所有的孩子节点。
function emptyNodeAt(elm: Element) {
const id = elm.id ? "#" + elm.id : "";
const classes = elm.getAttribute("class");
const c = classes ? "." + classes.split(" ").join(".") : "";
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm );
}
如果判断为相同节点(sameVnode
)则调用patchVnode
进行对比
sameVnode
:注意如果没有定义属性即为undefined
,undefined === undefined
是真值
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;
const isSameTextOrFragment =
!vnode1.sel && vnode1.sel === vnode2.sel
? typeof vnode1.text === typeof vnode2.text
: true;
return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}
patchVnode
比较重要,后面单独说下。
如果判断不是相同的vnode,则根据新的vnode直接创建真实DOM取代老的vnode。
insertedVnodeQueue
。另外:这个过程是递归的(如果有children) 钩子,有两类
cbs
的钩子(收集自init
入参传递的modules
),执行post
insertedVnodeQueue
,并执行vnode.data.hook.insert,显然这个钩子是当前vnode关联的。export function init(modules, domApi, options) {
// ...
return function patch(oldVnode, vnode) {
let i, elm, parent;
const insertedVnodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i)
cbs.pre[i]();
if (isElement(api, oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
else if (isDocumentFragment(api, oldVnode)) {
//... 实验特性,暂时不看
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
}
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);
}
}
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;
};
}
上面说过到如果新旧虚拟DOM被判断为相同节点,则会对这两个vnode进行对比,找出差异,并同步到界面上。 见patchVnode
函数
vnode.data.hook.prepatch
因为被判断是相同的vnode,会复用oldVnode关联的DOM,因此(vnode.elm = oldVnode.elm)
如果vnode和oldVnode是相同的对象,则返回
校正vnode/oldVnode.data,并执行 [cbs].update
和vnode.data.hook.update
// 怎么感觉应该是不需要第一个判断呢???
if (vnode.data !== undefined || (isDef(vnode.text) && vnode.text !== oldVnode.text)) {
vnode.data ??= {};
oldVnode.data ??= {};
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data?.hook?.update?.(oldVnode, vnode);
}
根据vnode.text是否存在(这一步是孩子的处理)
updateChildren
对孩子进行对比;addVnodes
创建新元素并挂载到界面上removeVnodes
删除孩子removeVnodes
删除孩子,调用setTextContent设置文本(新的孩子)调用vnode.data.hook.postpatch
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
if (oldVnode === vnode) return;
if (vnode.data !== undefined || (isDef(vnode.text) && vnode.text !== oldVnode.text)) {
vnode.data ??= {};
oldVnode.data ??= {};
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data?.hook?.update?.(oldVnode, vnode);
}
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as 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);
}
上面过程用到了removeVnodes
、addVnodes
创建DOM,通过insertBefore衔接父子节点(如updateChildren过程中)或挂载到界面。
function addVnodes(parentElm: Node, before: Node | null, vnodes: VNode[], startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
}
}
}
涉及destroy,remove钩子(cbs即模块上的、vnode.data.hook)
function removeVnodes(parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number;
let rm: () => void;
const ch = vnodes[startIdx];
if (ch != null) {
if (isDef(ch.sel)) {
invokeDestroyHook(ch);
listeners = cbs.remove.length + 1;
rm = createRmCb(ch.elm!, listeners);
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
removeHook(ch, rm);
} else {
rm();
}
} else if (ch.children) {
// Fragment node
invokeDestroyHook(ch);
removeVnodes(parentElm, ch.children as VNode[], 0, ch.children.length - 1);
} else {
// Text node
api.removeChild(parentElm, ch.elm!);
}
}
}
}
invokeDestroyHook
function invokeDestroyHook(vnode: VNode) {
const data = vnode.data;
if (data !== undefined) {
data?.hook?.destroy?.(vnode);
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
if (vnode.children !== undefined) {
for (let j = 0; j < vnode.children.length; ++j) {
const child = vnode.children[j];
if (child != null && typeof child !== "string") {
invokeDestroyHook(child);
}
}
}
}
}
patch函数的目的是打补丁,找出差异,然后将差异同步到界面上。另外就是应该尽可能的复用已有的DOM。updateChildren
的核心目的就是为了做这件事情:在考虑时间复杂度情况下去复用已有的DOM。
不考虑时间复杂度的情况下,给你新老两个vnode数组(oldCh: m 个元素,newCh: n 个元素)和samveVnode函数,你会如何实现呢?
我能想到的实现是两层遍历,外层是遍历 newCh,内层遍历oldCh,从oldCh中查找可以服用的节点。大致实现如下:
function patchEssential(oldCh,newCh) {
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
// 外层遍历 newCh
while (newStartVnode <= newEndIdx && oldStartIdx <= oldEndIdx) {
const newVnode = newCh[newStartVnode]
// 内层遍历 oldCh
let canReused = false; // 用来标识当前newVnode是否找打了可以被复用的oldVnode
for (; oldStartIdx <= oldEndIdx; oldStartIdx++) {
const oldVnode = oldCh[oldStartIdx]
// 如果被复用过
if (!oldVnode) {
continue
}
// 找到了可以被复用的oldVnode
if (sameVnode(newVnode, oldVnode)) {
canReused = true;
patchVnode(newVnode, oldVnode) // 递归对比
// 直接移动位置(避免了创建DOM的过程)
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) // 调整元素位置
// 设置为undefined,表示被复用过
oldCh[oldStartIdx] = undefined;
break;
}
}
// 如果没有找到可以复用的节点,则创建新的并挂载
!canReused && createElm(newVnode, ...)
}
//... 删除oldVnode未被复用的,添加newVnode尚未遍历到的
}
这里的流程不细说了,都在注释里了。另外这里的时间复杂度是O(m * n)
,在页面渲染这种高优的事情中,这个复杂度不能被接受。
所以snabbdom
的实现为了在时间复杂度和复用率上取了平衡。在没有提供key的情况下,snabbdom的双端对比做不到完全复用,key场景下当然是可以的。实际上这种取舍是合理的,在少量DOM场景下可能不会涉及到性能问题,如果有性能问题,添加key就可以解决。也就是说给你偷懒的机会,但是偷懒造成了严重后果,也提供解决方案让你弥补。
变量声明,没什么好说的
while循环,显然只有满足oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
才可能查找复用的节点
如果遍历的节点是空的,跳过看下一个节点(前面四个if);被复用的节点(oldVnode)可能会被置为undefined
,null == undefined
为true(vue-2.6.11 中只考虑了该场景)
// vue-2.6.11 updateChildren 片段
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
}
双端对比,首尾相互比对
降级的关键:
本来应该是要从所有的oldVnode中查找可以复用的节点,但是现在只考虑首尾两处,时间复杂度从O(n)降至o(1)。
实际上你可以随机挑常数个位置的节点进行对比,我理解首尾对比只是一种简单的随机策略,除非框架作者调研的结论是首尾对比能够较大概率的满足节点复用的场景。
sameVnode(oldEndVnode, newEndVnode)
sameVnode(oldEndVnode, newStartVnode)
sameVnode(oldStartVnode, newEndVnode)
sameVnode(oldStartVnode, newStartVnode)
最后一个else,是处理key场景的,用来提高性能的关键之处,逻辑显然,不赘述。
while循环退出后的两种情况
addVnodes
)removeVnodes
) 注意:
(index < oldStartIdx || index > oldEndIdx)的节点一定是被复用了的,只有[oldStartIdx, oldEndIdx]会存在未被复用的节点。因为只有被复用oldStartIdx和oldEndIdx才会向中间收缩,[oldStartIdx, oldEndIdx]中间的被复用的节点由于被重置为undefined,因此遍历该区间进行删除时原有的老节点不会被删除(因为没拿到) 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];
} 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 (newStartIdx <= newEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
if (oldStartIdx <= oldEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
老节点的宿命要么复用(原地保持,移动(左移、右移))要么删除。新节点都会被考虑到,要么复用老节点,要么新创建。
另外的细节之处是要注意:移动和插入的位置需要兄弟节点和父节点辅助
执行钩子:vnode.data.hook.init (可能会修改data,因此调用完后重新赋值了)
判断sel==='!'
时,则会创建一个注释节点,保存到vnode.elm中。注意: 卸载oldVnode,框架本身没有提供专门的api来删除,依然是基于patch能力来模拟(unmounting)
// patch -> createElm,在createElm中 ,而后新增该注释节点删除老的节点,实现老节点的删除。
patch(oldVnode, h("!", {}));
sel
不为undefined
:
从sel中解析出tag如div
createElement创建DOM
设置id
、class
。注意: createElement的可以传递一个对象选项
执行钩子:cbs.create(创建完DOM)
如果有孩子,则递归创建孩子DOM,并append当前DOM下(父子关系嘛):
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
vnode.text也算是孩子,如果有,createTextNode + appendChild
调用 vnode.data.hook.create
如果vnode.data.hook.insert存在则保存新创建的vnode到insertedVnodeQueue。后面挂载到界面上会触发。(createElm只是创建,并未挂载到界面
)
返回新创建的 vnode.elm
function 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, data)
: api.createElement(tag, data));
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)) {
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 if (options?.experimental?.fragments && vnode.children) {
//... 实验特性 暂不考虑
} else {
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
patch:对比整棵树(从根节点开始,也说明根节点只能有一颗) -> patchVnode:对比单个vnode -> updateChildren:对比孩子
打补丁的前提是:不考虑节点的跨层移动,只对比同层节点。
Snabbdom 提供了一系列丰富的生命周期函数,这些生命周期函数适用于拓展 Snabbdom 模块或者在虚拟节点生命周期中执行任意代码。
名称 | 触发节点 | 回调参数 |
---|---|---|
pre | patch 开始执行 | none |
init | vnode 被添加 | vnode |
create | 一个基于 vnode 的 DOM 元素被创建 | emptyVnode, vnode |
insert | 元素 被插入到 DOM | vnode |
prepatch | 元素 即将 patch | oldVnode, vnode |
update | 元素 已更新 | oldVnode, vnode |
postpatch | 元素 已被 patch | oldVnode, vnode |
destroy | 元素 被直接或间接得移除 | vnode |
remove | 元素 已从 DOM 中移除 | vnode, removeCallback |
post | 已完成 patch 过程 | none |
适用于模块(module.xxx):pre
, create
,update
, destroy
, remove
, post
适用于单个元素(vnode.data.hook.xxx):init
, create
, insert
, prepatch
, update
,postpatch
, destroy
, remove
虽然很多钩子的触发时机是一致,但是为什么还要区分这两类钩子呢?因为有些逻辑是共同的,这些逻辑收敛到模块中,而有些逻辑对于不同的vnode有差异,因此交个具体的vnode自己处理。
可以根据钩子的执行位置,回忆从data -> vnode -> dom -> 界面的过程。