概述
React 和 Vue 中都有虚构 DOM,咱们应该如何了解和把握虚构 DOM 的精华呢?我举荐大家学习 Snabbdom 这个我的项目。Snabbdom 是一个虚构 DOM 实现库,举荐的起因一是代码比拟少,外围代码只有几百行;二是 Vue 就是借鉴此我的项目的思路来实现虚构 DOM 的;三是这个我的项目的设计 / 实现和扩大思路值得参考。
snabb /snab/,瑞典语,意思是疾速的。
调整好难受的坐姿,打起精神咱们要开始啦~ 要学习虚构 DOM,咱们得先晓得 DOM 的基础知识和用 JS 间接操作 DOM 的痛点在哪里。
DOM 的作用和类型构造
DOM(Document Object Model)是一种文档对象模型,用一个对象树的构造来示意一个 HTML/XML 文档,树的每个分支的起点都是一个节点(node),每个节点都蕴含着对象。DOM API 的办法让你能够用特定形式操作这个树,用这些办法你能够扭转文档的构造、款式或者内容。
DOM 树中的所有节点首先都是一个 Node
,Node
是一个基类。Element
,Text
和 Comment
都继承于它。
换句话说,Element
,Text
和 Comment
是三种非凡的 Node
,它们别离叫做 ELEMENT_NODE
,TEXT_NODE
和 COMMENT_NODE
,代表的是元素节点(HTML 标签)、文本节点和正文节点。其中 Element
还有一个子类是 HTMLElement
,那 HTMLElement
和 Element
有什么区别呢?HTMLElement
代表 HTML 中的元素,如:<span>
、<img>
等,而有些元素并不是 HTML 规范的,比方 <svg>
。能够用上面的办法来判断这个元素是不是 HTMLElement
:
document.getElementById('myIMG') instanceof HTMLElement;
为什么须要虚构 DOM?
浏览器创立 DOM 是很“低廉”的。来一个经典示例,咱们能够通过 document.createElement('div')
创立一个简略的 div 元素,将属性都打印进去康康:
能够看到打印进去的属性十分多,当频繁地去更新简单的 DOM 树时,会产生性能问题。虚构 DOM 就是用一个原生的 JS 对象去形容一个 DOM 节点,所以创立一个 JS 对象比创立一个 DOM 对象的代价要小很多。
VNode
Vnode 就是 Snabbdom 中形容虚构 DOM 的一个对象构造,内容如下:
type Key = string | number | symbol;
interface VNode {
// CSS 选择器,比方:'div#container'。sel: string | undefined;
// 通过 modules 操作 CSS classes、attributes 等。data: VNodeData | undefined;
// 虚构子节点数组,数组元素也能够是 string。children: Array<VNode | string> | undefined;
// 指向创立的实在 DOM 对象。elm: Node | undefined;
/**
* text 属性有两种状况:* 1. 没有设置 sel 选择器,阐明这个节点自身是一个文本节点。* 2. 设置了 sel,阐明这个节点的内容是一个文本节点。*/
text: string | undefined;
// 用于给已存在的 DOM 提供标识,在同级元素之间必须惟一,无效防止不必要地重建操作。key: Key | undefined;
}
// vnode.data 上的一些设置,class 或者生命周期函数钩子等等。interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: any[]; // for thunks
is?: string; // for custom elements v1
[key: string]: any; // for any other 3rd party module
}
例如这样定义一个 vnode 的对象:
const vnode = h(
'div#container',
{class: { active: true} },
[h('span', { style: { fontWeight: 'bold'} }, 'This is bold'),
'and this is just normal text'
]);
咱们通过 h(sel, b, c)
函数来创立 vnode 对象。h()
代码实现中次要是判断了 b 和 c 参数是否存在,并解决成 data 和 children,children 最终会是数组的模式。最初通过 vnode()
函数返回下面定义的 VNode
类型格局。
Snabbdom 的运行流程
先来一张运行流程的简略示例图,先有个大略的流程概念:
diff 解决是用来计算新老节点之间差别的处理过程。
再来看一段 Snabbdom 运行的示例代码:
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from 'snabbdom';
const patch = init([
// 通过传入模块初始化 patch 函数
classModule, // 开启 classes 性能
propsModule, // 反对传入 props
styleModule, // 反对内联款式同时反对动画
eventListenersModule, // 增加事件监听
]);
// <div id="container"></div>
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(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!"),
]
);
// 再次调用 patch(),将旧节点更新为新节点。patch(vnode, newVnode);
从流程示意图和示例代码能够看出,Snabbdom 的运行流程形容如下:
- 首先调用
init()
进行初始化,初始化时须要配置须要应用的模块。比方classModule
模块用来应用对象的模式来配置元素的 class 属性;eventListenersModule
模块用来配置事件监听器等等。init()
调用后会返回patch()
函数。 - 通过
h()
函数创立初始化 vnode 对象,调用patch()
函数去更新,最初通过createElm()
创立真正的 DOM 对象。 -
当须要更新时,创立一个新的 vnode 对象,调用
patch()
函数去更新,通过patchVnode()
和updateChildren()
实现本节点和子节点的差别更新。Snabbdom 是通过模块这种设计来扩大相干属性的更新而不是全副写到外围代码中。那这是如何设计与实现的?接下来就先来康康这个设计的核心内容,Hooks——生命周期函数。
Hooks
Snabbdom 提供了一系列丰盛的生命周期函数也就是钩子函数,这些生命周期函数实用在模块中或者能够间接定义在 vnode 上。比方咱们能够在 vnode 上这样定义钩子的执行:
h('div.row', {
key: 'myRow',
hook: {insert: (vnode) => {console.log(vnode.elm.offsetHeight);
},
},
});
全副的生命周期函数申明如下:
名称 | 触发节点 | 回调参数 |
---|---|---|
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 |
其中实用于模块的是:pre
, create
,update
, destroy
, remove
, post
。实用于 vnode 申明的是:init
, create
, insert
, prepatch
, update
,postpatch
, destroy
, remove
。
咱们来康康是如何实现的,比方咱们以 classModule
模块为例,康康它的申明:
import {VNode, VNodeData} from "../vnode";
import {Module} from "./module";
export type Classes = Record<string, boolean>;
function updateClass(oldVnode: VNode, vnode: VNode): void {
// 这里是更新 class 属性的细节,先不论。// ...
}
export const classModule: Module = {create: updateClass, update: updateClass};
能够看到最初导出的模块定义是一个对象,对象的 key 就是钩子函数的名称,模块对象 Module
的定义如下:
import {
PreHook,
CreateHook,
UpdateHook,
DestroyHook,
RemoveHook,
PostHook,
} from "../hooks";
export type Module = Partial<{
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}>;
TS 中 Partial
示意对象中每个 key 的属性都是能够为空的,也就是说模块定义中你关怀哪个钩子,就定义哪个钩子就好了。钩子的定义有了,在流程中是怎么执行的呢?接着咱们来看 init()
函数:
// 模块中可能定义的钩子有哪些。const hooks: Array<keyof Module> = [
"create",
"update",
"remove",
"destroy",
"pre",
"post",
];
export function init(
modules: Array<Partial<Module>>,
domApi?: DOMAPI,
options?: Options
) {
// 模块中定义的钩子函数最初会存在这里。const cbs: ModuleHooks = {create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: [],};
// ...
// 遍历模块中定义的钩子,并存起来。for (const hook of hooks) {for (const module of modules) {const currentHook = module[hook];
if (currentHook !== undefined) {(cbs[hook] as any[]).push(currentHook);
}
}
}
// ...
}
能够看到 init()
在执行时先遍历各个模块,而后把钩子函数存到了 cbs
这个对象中。执行的时候能够康康 patch()
函数外面:
export function init(
modules: Array<Partial<Module>>,
domApi?: DOMAPI,
options?: Options
) {
// ...
return function patch(
oldVnode: VNode | Element | DocumentFragment,
vnode: VNode
): VNode {
// ...
// patch 开始了,执行 pre 钩子。for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// ...
}
}
这里以 pre
这个钩子举例,pre
钩子的执行机会是在 patch 开始执行时。能够看到 patch()
函数在执行的开始处去循环调用了 cbs
中存储的 pre
相干钩子。其余生命周期函数的调用也跟这个相似,大家能够在源码中其余中央看到对应生命周期函数调用的中央。
这里的设计思路是 观察者模式。Snabbdom 把非核心性能散布在模块中来实现,联合生命周期的定义,模块能够定义它本人感兴趣的钩子,而后 init()
执行时解决成 cbs
对象就是注册这些钩子;当执行工夫到来时,调用这些钩子来告诉模块解决。这样就把外围代码和模块代码拆散了进去,从这里咱们能够看出观察者模式是一种代码解耦的罕用模式。
patch()
接下来咱们来康康外围函数 patch()
,这个函数是在 init()
调用后返回的,作用是执行 VNode 的挂载和更新,签名如下:
function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode {
// 为简略起见先不关注 DocumentFragment。// ...
}
oldVnode
参数是旧的 VNode 或 DOM 元素或文档片段,vnode
参数是更新后的对象。这里我间接贴出整顿的流程形容:
- 调用模块上注册的
pre
钩子。 -
如果
oldVnode
是Element
,则将其转换为空的vnode
对象,属性外面记录了elm
。这里判断是不是
Element
是判断(oldVnode as any).nodeType === 1
是实现的,nodeType === 1
表明是一个 ELEMENT_NODE,定义在 这里。 -
而后判断
oldVnode
和vnode
是不是雷同的,这里会调用sameVnode()
来判断:function sameVnode(vnode1: VNode, vnode2: VNode): boolean { // 同样的 key。const isSameKey = vnode1.key === vnode2.key; // Web component,自定义元素标签名,看这里:// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement const isSameIs = vnode1.data?.is === vnode2.data?.is; // 同样的选择器。const isSameSel = vnode1.sel === vnode2.sel; // 三者都雷同即是雷同的。return isSameSel && isSameKey && isSameIs; }
- 如果雷同,则调用
patchVnode()
做 diff 更新。 - 如果不同,则调用
createElm()
创立新的 DOM 节点;创立结束后插入 DOM 节点并删除旧的 DOM 节点。
- 如果雷同,则调用
- 调用上述操作中波及的 vnode 对象中注册的
insert
钩子队列,patchVnode()
createElm()
都可能会有新节点插入。至于为什么这样做,在createElm()
中会说到。 - 最初调用模块上注册的
post
钩子。
流程根本就是雷同的 vnode 就做 diff,不同的就创立新的删除旧的。接下来先看下 createElm()
是如何创立 DOM 节点的。
createElm()
createElm()
是依据 vnode 的配置来创立 DOM 节点。流程如下:
- 调用 vnode 对象上可能存在的
init
钩子。 -
而后分一下几种状况来解决:
- 如果
vnode.sel === '!'
,这是 Snabbdom 用来删除原节点的办法,这样会新插入一个正文节点。因为在createElm()
后会删除老节点,所以这样设置就能够达到卸载的目标。 -
如果
vnode.sel
选择器定义是存在的:- 解析选择器,失去
id
、tag
和class
。 - 调用
document.createElement()
或document.createElementNS
创立 DOM 节点,并记录到vnode.elm
中,并依据上一步的后果来设置id
、tag
和class
。 - 调用模块上的
create
钩子。 -
解决
children
子节点数组:- 如果
children
是数组,则递归调用createElm()
创立子节点后,调用appendChild
挂载到vnode.elm
下。 - 如果
children
不是数组但vnode.text
存在,阐明这个元素的内容是个文本,这个时候调用createTextNode
创立文本节点并挂载到vnode.elm
下。
- 如果
- 调用 vnode 上的
create
钩子。并将 vnode 上的insert
钩子退出到insert
钩子队列。
- 解析选择器,失去
- 剩下的状况就是
vnode.sel
不存在,阐明节点自身是文本,那就调用createTextNode
创立文本节点并记录到vnode.elm
。
- 如果
- 最初返回
vnode.elm
。
整个过程能够看出 createElm()
是依据 sel
选择器的不同设置来抉择如何创立 DOM 节点。这里有个细节是补一下:patch()
中提到的 insert
钩子队列。须要这个 insert
钩子队列的起因是须要等到 DOM 真正被插入后才执行,而且也要等到所有子孙节点都插入实现,这样咱们能够在 insert
中去计算元素的大小地位信息才是精确的。联合下面创立子节点的过程,createElm()
创立子节点是递归调用,所以队列会先记录子节点,再记录本身。这样在 patch()
的结尾执行这个队列时就能够保障这个程序。
patchVnode()
接下来咱们来看 Snabbdom 如何用 patchVnode()
来做 diff 的,这是虚构 DOM 的外围。patchVnode()
的解决流程如下:
- 首先执行 vnode 上
prepatch
钩子。 - 如果 oldVnode 和 vnode 是同一个对象援用,则不解决间接返回。
- 调用模块和 vnode 上的
update
钩子。 -
如果没有定义
vnode.text
,则解决children
的几种状况:- 如果
oldVnode.children
和vnode.children
均存在并且不雷同。则调用updateChildren
去更新。 vnode.children
存在而oldVnode.children
不存在。如果oldVnode.text
存在则先清空,而后调用addVnodes
去增加新的vnode.children
。vnode.children
不存在而oldVnode.children
存在。调用removeVnodes
移除oldVnode.children
。- 如果
oldVnode.children
和vnode.children
均不存在。如果oldVnode.text
存在则清空。
- 如果
- 如果有定义
vnode.text
并且与oldVnode.text
不同。如果oldVnode.children
存在则调用removeVnodes
革除。而后通过textContent
来设置文本内容。 - 最初执行 vnode 上的
postpatch
钩子。
从过程能够看出,diff 中对于本身节点的相干属性的扭转比方 class
、style
之类的是依附模块去更新的,这里不过多开展了大家有须要能够去看下模块相干的代码。diff 的次要外围解决是集中在 children
上,接下来康康 diff 解决 children
的几个相干函数。
addVnodes()
这个很简略,先调用 createElm()
创立,而后插入到对应的 parent 中。
removeVnodes()
移除的时候会先调用 destory
和 remove
钩子,这里重点讲讲这两个钩子的调用逻辑和区别。
destory
,首先调用这个钩子。逻辑是先调用 vnode 对象上的这个钩子,再调用模块上的。而后对vnode.children
也依照这个程序递归调用这个钩子。remove
,这个 hook 只有在以后元素从它的父级中删除才会触发,被移除的元素中的子元素则不会触发,并且模块和 vnode 对象上的这个钩子都会调用,程序是先调用模块上的再调用 vnode 上的。而且比拟非凡的是期待所有的remove
都会调用后,元素才会真正被移除,这样做能够实现一些提早删除的需要。
以上能够看出这两个钩子调用逻辑不同的中央,特地是 remove
只在间接脱离父级的元素上才会被调用。
updateChildren()
updateChildren()
是用来解决子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思维是对 oldCh
和 newCh
各设置头、尾一共四个指针,这四个指针别离是 oldStartIdx
、oldEndIdx
、newStartIdx
和 newEndIdx
。而后在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
循环中对两个数组进行比照,找到雷同的局部进行复用更新,并且每次比拟解决最多挪动一对指针。具体的遍历过程按以下程序解决:
- 如果这四个指针有任何一个指向的 vnode == null,则这个指针往两头挪动,比方:start++ 或 end–,null 的产生在前面状况有阐明。
- 如果新旧开始节点雷同,也就是
sameVnode(oldStartVnode, newStartVnode)
返回 true,则用patchVnode()
执行 diff,并且两个开始节点都向两头前进一步。 - 如果新旧完结节点雷同,也采纳
patchVnode()
解决,两个完结节点向两头后退一步。 - 如果旧开始节点与新完结节点雷同,先用
patchVnode()
解决更新。而后须要挪动 oldStart 对应的 DOM 节点,挪动的策略是挪动到oldEndVnode
对应 DOM 节点的下一个兄弟节点之前。为什么是这样挪动呢?首先,oldStart 与 newEnd 雷同,阐明在以后循环解决中,老数组的开始节点是往右挪动了;因为每次的解决都是首尾指针往两头挪动,咱们是把老数组更新成新的,这个时候 oldEnd 可能还没解决,但这个时候 oldStart 已确定在新数组的以后解决中是最初一个了,所以挪动到 oldEnd 的下一个兄弟节点之前是正当的。挪动结束后,oldStart++,newEnd–,别离向各自的数组两头挪动一步。 - 如果旧完结节点与新开始节点雷同,也是先用
patchVnode()
解决更新,而后把 oldEnd 对应的 DOM 节点挪动oldStartVnode
对应的 DOM 节点之前,挪动理由同上一步一样。挪动结束后,oldEnd–,newStart++。 -
如果以上状况都不是,则通过 newStartVnode 的 key 去找在
oldChildren
的下标 idx,依据下标是否存在有两种不同的解决逻辑:- 如果下标不存在,阐明 newStartVnode 是新创建的。通过
createElm()
创立新的 DOM,并插入到oldStartVnode
对应的 DOM 之前。 -
如果下标存在,也要分两种状况解决:
- 如果两个 vnode 的 sel 不同,也还是当做新创建的,通过
createElm()
创立新的 DOM,并插入到oldStartVnode
对应的 DOM 之前。 - 如果 sel 是雷同的,则通过
patchVnode()
解决更新,并把oldChildren
对应下标的 vnode 设置为 undefined,这也是后面双指针遍历中为什么会呈现 == null 的起因。而后把更新结束后的节点插入到oldStartVnode
对应的 DOM 之前。
- 如果两个 vnode 的 sel 不同,也还是当做新创建的,通过
- 以上操作完后,newStart++。
- 如果下标不存在,阐明 newStartVnode 是新创建的。通过
遍历完结后,还有两种状况要解决。一种是 oldCh
曾经全副解决实现,而 newCh
中还有新的节点,须要对 newCh
剩下的每个都创立新的 DOM;另一种是 newCh
全副解决实现,而 oldCh
中还有旧的节点,须要将多余的节点移除。这两种状况的解决在 如下:
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
// 双指针遍历过程。// ...
// newCh 中还有新的节点须要创立。if (newStartIdx <= newEndIdx) {
// 须要插入到最初一个解决好的 newEndIdx 之前。before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
// oldCh 中还有旧的节点要移除。if (oldStartIdx <= oldEndIdx) {removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
咱们用一个理论例子来看一下 updateChildren()
的处理过程:
- 初始状态如下,旧子节点数组为 [A, B, C],新节点数组为 [B, A, C, D]:
- 第一轮比拟,开始和完结节点都不一样,于是看 newStartVnode 在旧节点中是否存在,找到了在 oldCh[1] 这个地位,那么先执行
patchVnode()
进行更新,而后把 oldCh[1] = undefined,并把 DOM 插入到oldStartVnode
之前,newStartIdx
向后挪动一步,解决完后状态如下:
- 第二轮比拟,
oldStartVnode
和newStartVnode
雷同,执行patchVnode()
更新,oldStartIdx
和newStartIdx
向两头挪动,解决完后状态如下:
- 第三轮比拟,
oldStartVnode == null
,oldStartIdx
向两头挪动,状态更新如下:
- 第四轮比拟,
oldStartVnode
和newStartVnode
雷同,执行patchVnode()
更新,oldStartIdx
和newStartIdx
向两头挪动,解决完后状态如下:
- 此时
oldStartIdx
大于oldEndIdx
,循环完结。此时newCh
中还有没解决完的新节点,须要调用addVnodes()
插入,最终状态如下:
总结
到这里虚构 DOM 的核心内容曾经梳理结束,Snabbdom 的设计和实现原理我感觉挺好的,大家有空能够去康康源码的细节再细品下,其中的思维很值得学习。
欢送关注我的 JS 博客:小声比比 JavaScript