关于Virtual DOM理解和Snabbdom源码浅析
什么是Virtual DOM
Virtual DOM(虚拟DOM),在形态上表现为一个能够描述DOM结构及其属性信息的普通的JS对象,因为不是真实的DOM对象,所以叫虚拟DOM。
<div></div>
{
sel: 'div',
data: {},
chidren:undefined,
elm:undefined,
key:undefined,
}
Virtual DOM 本质上JS和DOM之间的一个映射缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。
为什么需要Virtual DOM
手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jquery等库简化DOM操作,但是随着项目的复杂DOM操作复杂提升。 为了简化DOM的复杂操作于是出现了MVVM框架,MVVM框架解决了视图和状态的同步问题。 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了。 Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清除如何有效(diff)的更新DOM。 虚拟DOM可以维护程序的状态,跟踪上一次的状态,通过比较前后两次状态的差异更新真实DOM。
Virtual DOM的作用
1、减少对真实DOM的操作
真实DOM 因为浏览器厂商需要实现众多的规范(各种 HTML5 属性、DOM事件),即使创建一个空的 div 也要付出昂贵的代价。如以下代码,打印空的div属性一共298个。而这仅仅是第一层。真正的 DOM 元素非常庞大。直接操作DOM可能会导致频繁的回流和重绘。
const div = document.createElement('div');
const arr = [];
for(key in div){arr.push(key)};
console.log(arr.length); // 298
对复杂的文档DOM结构(复杂视图情况下提升渲染性能),提供一种方便的工具,进行最小化地DOM操作。既然我们可以用JS对象表示DOM结构,那么当数据状态发生变化而需要改变DOM结构时,我们先通过JS对象表示的虚拟DOM计算出实际DOM需要做的最小变动(Virtual DOM会使用diff算法计算出如果有效的更新dom,只更新状态改变的DOM),然后再操作实际DOM,从而避免了粗放式的DOM操作带来的性能问题,减少对真实DOM的操作。
2、无需手动操作 DOM,维护视图和状态的关系
我们不再需要手动去操作 DOM,只需要写好 View-Model 的代码逻辑,MVVM框架会根据虚拟 DOM 和 数据双向绑定,帮我们以可预期的方式更新视图,极大提高我们的开发效率。
3、跨平台
虚拟DOM是对真实的渲染内容的一层抽象,是真实DOM的描述,因此,它可以实现“一次编码,多端运行”,可以实现SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等。
Virtual DOM有什么不足
上面我们也说到了在复杂视图情况下提升渲染性能。虽然虚拟 DOM + 合理的优化,足以应对绝大部分应用的性能需求,但在一些性能要求极高的应用中虚拟DOM 无法进行针对性的极致优化。首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。
下方是尤大自己的见解。https://www.zhihu.com/question/31809713/answer/53544875
Virtual DOM库
virtual-dom
一个JavaScript DOM模型,支持元素创建,差异计算和补丁操作,以实现高效的重新渲染。
源代码库地址:https://github.com/Matt-Esch/virtual-dom.git 已经有五六年没有维护了
snabbdom
一个虚拟DOM库,重点放在简单性,模块化,强大的功能和性能上。
源代码库地址:https://github.com/snabbdom/snabbdom.git 最近一直在维护
为什么要介绍Virtual DOM库Snabbdom
Vue2.x内部使用的Virtual DOM就是改造的Snabbdom; 核心代码大约200行; 通过模块可扩展; 源码使用TypeScript开发; 最快的Virtual DOM之一; 最近在维护
Snabbdom核心
使用 h()函数创建 JavaScript 对象(Vnode)描述真实 DOM init()设置模块,创建 patch() patch()比较新旧两个 Vnode 把变化的内容更新到真实 DOM 树上
Snabbdom搭建项目
第一步,初始化项目
npm init -y
or
yarn init -y
第二步,安装依赖
安装snabbdom
npm install snabbdom
or
yarn add snabbdom
安装parcel-bundler
npm install parcel-bundler
or
yarn add parcel-bundler
第三步,创建文件夹/文件,编辑文件
在根目录下创建一个名为src的文件目录,然后在里面创建一个main.js文件。最后,在根目录下创建一个index.html文件。
package.json文件可以编辑如下,更利于操作。
"scripts": {
"serve": "parcel index.html --open",
"build": "parcel build index.html"
},
第四步,编辑文件内容
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>snabbdomApp</title>
</head>
<body>
<div id="app"></div>
<script src="src/main.js"></script>
</body>
</html>
main.js
主要介绍snabbdom中两个核心init()、h()。
init() 是一个高阶函数,返回patch()(对比两个VNode的差异更新到真实DOM); h() 返回虚拟节点VNode;
示例1
import { h, init } from 'snabbdom';
// init函数参数:数组(模块)
// 返回值:patch函数:作用是对比两个Vnode的差异更新到真实dom
const patch = init([]);
// h函数
// 第一个参数:标签+选择器;
// 第二个参数:如果是字符串则是标签的内容
let vnode = h('div#container', 'Hello World');
const app = document.querySelector('#app');
// patch函数
// 第一个参数:可以是DOM元素(内部会把DOM元素转化为Vnode),也可以是Vnode;
// 第二个参数:Vnode
// 返回值:Vnode
let oldVnode = patch(app, vnode);
// 新旧Vnode对比
vnode = h('div', 'Hello Snabbdom');
patch(oldVnode, vnode);
示例2
import { h, init } from 'snabbdom';
const patch = init([]);
// 可放置子元素
let vnode = h('div#container', [h('h1', '1'), h('h2', '2')]);
const app = document.querySelector('#app');
const oldVnode = patch(app, vnode);
vnode = h('div', 'Hello Snabbdom');
patch(oldVnode, vnode);
setInterval(() => {
// 清除页面元素
patch(oldVnode, h('!'));
}, 3000);
示例3
常用模块
The attributes module
设置DOM元素的特性,使用setAttribute添加和更新特性。
The props module
允许设置DOM元素的属性。
The class module
提供了一种动态切换元素上的类的简单方法。
The style module
允许在元素上设置CSS属性。请注意,如果样式属性作为样式对象的属性被移除,样式模块并不会移除它们。为了移除一个样式,应该将其设置为空字符串。
The dataset module
允许在DOM元素上设置自定义数据属性(data-*)。
The eventlisteners module
提供了附加事件监听器的强大功能。
import {
h,
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
} from 'snabbdom';
const patch = init([
styleModule,
classModule,
propsModule,
eventListenersModule,
]);
let vnode = h(
'div#container',
{
style: {
color: '#000',
},
on: {
click: eventHandler,
},
},
[
h('p', 'p1', h('a', { class: { active: true, selected: true } }, 'Toggle')),
h('p', 'p2'),
h('a', { props: { href: '/' } }, 'Go to'),
]
);
function eventHandler() {
console.log('1');
}
const app = document.querySelector('#app');
patch(app, vnode);
snabbdom源码浅析
源码地址:https://github.com/snabbdom/snabbdom.git以下分析snabbdom版本3.0.1。
源码核心文件目录及其文件
核心文件夹是**src目录。**里面包含了如下文件夹及其目录:
helpers:里面只有一个文件attachto.ts,这个文件主要作用是定义了几个类型在vnode.ts文件中使用。 modules:里面存放着snabbdom模块,分别是attributes.ts、class.ts、dataset.ts、eventlisteners.ts、props.ts、style.ts这6个模块。另外一个module.ts这个文件为它们提供了钩子函数。 h.ts:创建Vnode。 hook.ts:提供钩子函数。 htmldomapi:提供了DOM API。 index.ts:snabbdom 入口文件。 init.ts:导出了patch函数。 is.ts:导出了两个方法。一个方法是判断是否是数组,另一个判断是否是字符串或数字。 jsx.ts:与jsx相关。 thunk.ts:与优化key相关。 tovnode.ts:真实DOM 转化为 虚拟DOM。 vnode.ts:定义了Vnode的结构。
核心文件浅析
h.ts
h 函数最早见于 hyperscript,使用 JavaScript 创建超文本,Snabbdom 中的 h 函数不是用来创建超文本,而是创建 Vnode。 在使用 Vue2.x 的时候见过 h 函数,它的参数就是h函数,但是Vue加强了h函数,使其支持组件机制。
new Vue({
router,
store,
render:h => h(App)
}).$mount('#app)
以上是h.ts文件中的内容,可以看到它导出了多个h方法,这种方式叫做函数重载。在JS中暂时没有,目前TS支持这种机制(但也只是通过调整代码参数层面上,因为最终TS还是要转换为JS)。方法名相同,参数个数或类型不同的方法叫做函数重载。所以通过参数个数或类型不同来区分它们。
// 这里通过参数不同来区分不同的函数
function add(a, b) {
console.log(a + b);
}
function add(a, b, c) {
console.log(a + b + c);
}
add(1, 2);
add(1, 2, 3);
从上面代码层面上我们知道了通过函数重载这种方法可以在通过参数个数或类型不同轻松地实现了相应情况调用相应参数的方法。
那么,我们来具体看下源码是怎么实现函数重载的。
通过源码我们看到,通过传入不同的类型的参数调用对应的代码,最后将将参数传入到vnode方法中,创建一个Vnode,并返回这个方法。
那么接下来,我们看下vnode方法的实现。
vnode.ts
我们打开vnode.ts这个文件,这个文件主要是导出了一个vnode方法,并且定义了几个接口。我们看到以下代码中vnode中的参数含义就知道在h.ts文件中函数参数的意思,是相对应的。
init.ts
在介绍init.ts文件之前的,我们需要知道这样的一个概念:
init()是一个高阶函数,返回patch() patch(oldVnode,newVnode) 把新节点中的变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
这个概念我们在上面已经阐述了。**init()**就是通过这个文件导出的。
在看init.ts源码之前,我们还需要了解Vnode是渲染到真实DOM的整体流程。这样,看源码才不会有误解。
整体流程:
对比新旧Vnode是否相同节点(节点数据中的key、sel、is相同) 如果不是相同节点,删除之前的内容,重新渲染 如果是相同节点,再判断新的Vnode是否有text,如果有并且和oldVnode 的text 不同,直接更新文本内容 如果新的Vnode 有children,判断子节点是否有变化,判断子节点的过程就是diff 算法 diff 算法过程只进行同层级节点比较
Diff算法的作用是用来计算出 Virtual DOM 中被改变的部分,然后针对该部分进行原生DOM操作,而不用重新渲染整个页面。
同级对比
对比的时候,只针对同级的对比,减少算法复杂度。
就近复用
为了尽可能不发生 DOM 的移动,会就近复用相同的 DOM 节点,复用的依据是判断是否是同类型的 dom 元素。 看到这里你可能就会想到Vue中列表渲染为什么推荐加上key,我们需要使用key来给每个节点做一个唯一标识,Diff算法就可以正确的识别此节点,找到正确的位置区插入新的节点。key的作用主要是为了高效的更新虚拟DOM。
我们先看下init.ts中的大体源码。
我们先简单地来看下sameVnode方法。判断是否是相同的虚拟节点。
// 是否是相同节点
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;
}
是否是Vnode。
// 是否是vnode
function isVnode(vnode: any): vnode is VNode {
return vnode.sel !== undefined;
}
注册一系列的钩子,在不同的阶段触发。
// 定义一些钩子函数
const hooks: Array<keyof Module> = [
"create",
"update",
"remove",
"destroy",
"pre",
"post",
];
下面呢,主要看下导出的init方法。也是init.ts中最主要的部分,从68行到472行。
// 导出init函数
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number;
let j: number;
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: [],
};
// 初始化转化成虚拟节点的api
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 把传入的所有模块的钩子函数,统一存储到cbs对象中
// 最终构建的cbs对象的形式cbs = {create:[fn1,fn2],update:[],....}
for (i = 0; i < hooks.length; ++i) {
// cbs.create= [], cbs.update = []...
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
// modules 传入的模块数组
// 获取模块中的hook函数
// hook = modules[0][create]...
const hook = modules[j][hooks[i]];
if (hook !== undefined) {
// 把获取到的hook函数放入到cbs 对应的钩子函数数组中
(cbs[hooks[i]] as any[]).push(hook);
}
}
}
function emptyNodeAt(elm: Element) {
const id = elm.id ? "#" + elm.id : "";
const c = elm.className ? "." + elm.className.split(" ").join(".") : "";
return vnode(
api.tagName(elm).toLowerCase() + id + c,
{},
[],
undefined,
elm
);
}
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node;
api.removeChild(parent, childElm);
}
};
}
/*
1.触发钩子函数init
2.把vnode转换为DOM对象,存储到vnode.elm中
- sel是!--》创建注释节点
- sel不为空 --》创建对应的DOM对象;触发模块的钩子函数create;创建所有子节点对应的DOM对象;触发钩子函数create;如果是vnode有inset钩子函数,追加到队列
- sel为空 --》创建文本节点
3.返回vnode.elm
*/
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any;
let data = vnode.data;
if (data !== undefined) {
// 执行init钩子函数
const init = data.hook?.init;
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
// 把vnode转换成真实dom对象(没有渲染到页面)
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, " "));
// 执行模块的create钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 如果vnode中有子节点,创建子Vnode对应的DOM元素并追加到DOM树上
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)) {
// 如果vode的text值是string/number,创建文本节点并追加到DOM树
api.appendChild(elm, api.createTextNode(vnode.text));
}
const hook = vnode.data!.hook;
if (isDef(hook)) {
// 执行传入的钩子 create
hook.create?.(emptyNode, vnode);
if (hook.insert) {
insertedVnodeQueue.push(vnode);
}
}
} else {
// 如果选择器为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!);
}
// 返回新创建的DOM
return vnode.elm;
}
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);
}
}
}
function invokeDestroyHook(vnode: VNode) {
const data = vnode.data;
if (data !== undefined) {
// 执行的destroy 钩子函数
data?.hook?.destroy?.(vnode);
// 调用模块的destroy钩子函数
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
// 执行子节点的destroy钩子函数
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);
}
}
}
}
}
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) {
// 如果sel 有值
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);
// 执行remove钩子函数
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
removeHook(ch, rm);
} else {
// 如果没有钩子函数,直接调用删除元素的方法
rm();
}
} else {
// Text node
// 如果是文本节点,直接是调用删除元素的方法
api.removeChild(parentElm, ch.elm!);
}
}
}
}
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 (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);
}
}
}
/*
对比两个新旧节点,然后找到差异并更新DOM
第一部分
1.触发prepatch钩子函数
2.触发update钩子函数
第二部分
1.新节点有text属性,且不等于旧节点的text属性 -》如果旧节点有children,移除旧节点children对应的DOM元素;设置新节点对应的DOM元素的textContent
2.新旧节点都有children,且不相等-》调用updateChildren();对比子节点,并且更新子节点的差异
3.只有新节点有children属性-》如果旧节点有text属性,清空对应DOM元素的textContent;添加所有的子节点
4.只有旧节点有children属性-》移除所有旧节点
5.只有旧节点有text属性=》清空对应的DOM元素的textContent
第三部分
1.触发postpatch钩子函数
*/
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
// 首先执行prepatch钩子函数
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
// 如果新旧vnode相同返回
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
// 执行模块的update钩子函数
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
// 执行update钩子函数
vnode.data.hook?.update?.(oldVnode, vnode);
}
// 如果是vnode.text 未定义
if (isUndef(vnode.text)) {
// 如果是新旧节点都有 children
if (isDef(oldCh) && isDef(ch)) {
// 使用diff算法对比子节点,更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 如果新节点有children,旧节点没有children
// 如果旧节点有text,清空dom 元素的内容
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
// 批量添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果旧节点有children,新节点没有children
// 批量移除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果旧节点有 text,清空 DOM 元素
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// 如果没有设置 vnode.text
if (isDef(oldCh)) {
// 如果旧节点有children,移除
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置 DOM 元素的textContent为 vnode.text
api.setTextContent(elm, vnode.text!);
}
// 最后执行postpatch钩子函数
hook?.postpatch?.(oldVnode, vnode);
}
// init 内部返回 patch 函数,把vnode渲染成真实dom,并返回vnode
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 保存新插入的节点的队列,为了触发钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 执行模块的pre 钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 如果oldVnode不是Vnode,创建Vnode并设置elm
if (!isVnode(oldVnode)) {
// 把Dom元素转化成空的Vnode
oldVnode = emptyNodeAt(oldVnode);
}
// 如果新旧节点是相同节点
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新DOM,这里的原理就是diff算法
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果新旧节点不同,vnode创建对应的DOM
// 获取当前的DOM元素
elm = oldVnode.elm!;
// 获取父元素
parent = api.parentNode(elm) as Node;
// 创建Vnode对应的DOM元素,并触发init/create 钩子函数
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果父元素不为空,把vnode对应的DOM插入到父元素中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
// 移除旧节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 执行insert 钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
}
// 执行模块的post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
// 返回vnode 作为下次更新的旧节点
return vnode;
};
}
接下来,我们分开介绍init方法中的内容。
将各个模块的钩子方法,挂到统一的钩子上
初始化的时候,将每个 modules 下的相应的钩子都追加都一个数组里面 在进行 patch 的各个阶段,触发对应的钩子去处理对应的事情 这种方式比较方便扩展。新增钩子的时候,不需要更改到主要的流程 这些模块的钩子,主要用在更新节点的时候,会在不同的生命周期里面去触发对应的钩子,从而更新这些模块。
let i: number;
let j: number;
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: [],
};
// 把传入的所有模块的钩子函数,统一存储到cbs对象中
// 最终构建的cbs对象的形式cbs = {create:[fn1,fn2],update:[],....}
for (i = 0; i < hooks.length; ++i) {
// cbs.create= [], cbs.update = []...
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
// modules 传入的模块数组
// 获取模块中的hook函数
// hook = modules[0][create]...
const hook = modules[j][hooks[i]];
if (hook !== undefined) {
// 把获取到的hook函数放入到cbs 对应的钩子函数数组中
(cbs[hooks[i]] as any[]).push(hook);
}
}
}
patch方法
init 方法最后返回一个 patch 方法 。
主要的逻辑如下 :
触发 pre 钩子 如果旧节点非 vnode, 则新创建空的 vnode 新旧节点为 sameVnode 的话,则调用 patchVnode 更新 vnode , 否则创建新节点 触发收集到的新元素 insert 钩子 触发 post 钩子
// init 内部返回 patch 函数,把vnode渲染成真实dom,并返回vnode
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 保存新插入的节点的队列,为了触发钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 执行模块的pre 钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 如果oldVnode不是Vnode,创建Vnode并设置elm
if (!isVnode(oldVnode)) {
// 把Dom元素转化成空的Vnode
oldVnode = emptyNodeAt(oldVnode);
}
// 如果新旧节点是相同节点
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新DOM,这里的原理就是diff算法
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果新旧节点不同,vnode创建对应的DOM
// 获取当前的DOM元素
elm = oldVnode.elm!;
// 获取父元素
parent = api.parentNode(elm) as Node;
// 创建Vnode对应的DOM元素,并触发init/create 钩子函数
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果父元素不为空,把vnode对应的DOM插入到父元素中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
// 移除旧节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 执行insert 钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
}
// 执行模块的post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
// 返回vnode 作为下次更新的旧节点
return vnode;
};
patchVnode方法
主要的逻辑如下 :
触发 prepatch 钩子 触发 update 钩子, 这里主要为了更新对应的 module 内容 非文本节点的情况 , 调用 updateChildren 更新所有子节点 文本节点的情况 , 直接 api.setTextContent(elm, vnode.text as string)
这里在对比的时候,就会直接更新元素内容了。并不会等到对比完才更新 DOM 元素。
/*
对比两个新旧节点,然后找到差异并更新DOM
第一部分
1.触发prepatch钩子函数
2.触发update钩子函数
第二部分
1.新节点有text属性,且不等于旧节点的text属性 -》如果旧节点有children,移除旧节点children对应的DOM元素;设置新节点对应的DOM元素的textContent
2.新旧节点都有children,且不相等-》调用updateChildren();对比子节点,并且更新子节点的差异
3.只有新节点有children属性-》如果旧节点有text属性,清空对应DOM元素的textContent;添加所有的子节点
4.只有旧节点有children属性-》移除所有旧节点
5.只有旧节点有text属性=》清空对应的DOM元素的textContent
第三部分
1.触发postpatch钩子函数
*/
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
// 首先执行prepatch钩子函数
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
// 如果新旧vnode相同返回
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
// 执行模块的update钩子函数
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
// 执行update钩子函数
vnode.data.hook?.update?.(oldVnode, vnode);
}
// 如果是vnode.text 未定义
if (isUndef(vnode.text)) {
// 如果是新旧节点都有 children
if (isDef(oldCh) && isDef(ch)) {
// 使用diff算法对比子节点,更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 如果新节点有children,旧节点没有children
// 如果旧节点有text,清空dom 元素的内容
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
// 批量添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果旧节点有children,新节点没有children
// 批量移除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果旧节点有 text,清空 DOM 元素
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// 如果没有设置 vnode.text
if (isDef(oldCh)) {
// 如果旧节点有children,移除
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置 DOM 元素的textContent为 vnode.text
api.setTextContent(elm, vnode.text!);
}
// 最后执行postpatch钩子函数
hook?.postpatch?.(oldVnode, vnode);
}
updateChildren 方法
patchVnode 里面最重要的方法,也是整个 diff 里面的最核心方法。
主要的逻辑如下:
优先处理特殊场景,先对比两端。也就是
旧 vnode 头 vs 新 vnode 头 旧 vnode 尾 vs 新 vnode 尾 旧 vnode 头 vs 新 vnode 尾 旧 vnode 尾 vs 新 vnode 头
首尾不一样的情况,寻找 key 相同的节点,找不到则新建元素 如果找到 key,但是,元素选择器变化了,也新建元素 如果找到 key,并且元素选择没变, 则移动元素 两个列表对比完之后,清理多余的元素,新增添加的元素
不提供 key 的情况下,如果只是顺序改变的情况,例如第一个移动到末尾。这个时候,会导致其实更新了后面的所有元素。
// 更新子节点
function updateChildren(
parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<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: any;
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 as Node,
api.nextSibling(oldEndVnode.elm as Node)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
// 最右侧对比最左侧
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
// 移动元素到左侧指针的后面
api.insertBefore(
parentElm,
oldEndVnode.elm as Node,
oldStartVnode.elm as Node
);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 首尾都不一样的情况,寻找相同 key 的节点,所以使用的时候加上key可以调高效率
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(
oldCh,
oldStartIdx,
oldEndIdx
);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) {
// New element
// 如果找不到 key 对应的元素,就新建元素
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm as Node
);
newStartVnode = newCh[++newStartIdx];
} else {
// 如果找到 key 对应的元素,就移动元素
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm as Node
);
} else {
patchVnode(
elmToMove,
newStartVnode,
insertedVnodeQueue
);
oldCh[idxInOld] = undefined as any;
api.insertBefore(
parentElm,
elmToMove.elm as Node,
oldStartVnode.elm as Node
);
}
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);
}
}
}
addVnodes方法
主要功能就是添加 Vnodes 到 真实 DOM 中。
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);
}
}
}
removeVnodes方法
主要逻辑如下:
循环触发 destroy 钩子,递归触发子节点的钩子 触发 remove 钩子,利用 createRmCb , 在所有监听器执行后,才调用 api.removeChild,删除真正的 DOM 节点
function invokeDestroyHook(vnode: VNode) {
const data = vnode.data;
if (data !== undefined) {
// 执行的destroy 钩子函数
data?.hook?.destroy?.(vnode);
// 调用模块的destroy钩子函数
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
// 执行子节点的destroy钩子函数
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);
}
}
}
}
}
//创建一个删除的回调,多次调用这个回调,直到监听器都没了,就删除元素
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node;
api.removeChild(parent, childElm);
}
};
}
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) {
// 如果sel 有值
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);
// 执行remove钩子函数
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
removeHook(ch, rm);
} else {
// 如果没有钩子函数,直接调用删除元素的方法
rm();
}
} else {
// Text node
// 如果是文本节点,直接是调用删除元素的方法
api.removeChild(parentElm, ch.elm!);
}
}
}
}
createElm方法
将 vnode 转换成真正的 DOM 元素。
主要逻辑如下:
触发 init 钩子 处理注释节点 创建元素并设置 id , class 触发模块 create 钩子 。 处理子节点 处理文本节点 触发 vnodeData 的 create 钩子
/*
1.触发钩子函数init
2.把vnode转换为DOM对象,存储到vnode.elm中
- sel是!--》创建注释节点
- sel不为空 --》创建对应的DOM对象;触发模块的钩子函数create;创建所有子节点对应的DOM对象;触发钩子函数create;如果是vnode有inset钩子函数,追加到队列
- sel为空 --》创建文本节点
3.返回vnode.elm
*/
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any;
let data = vnode.data;
if (data !== undefined) {
// 执行init钩子函数
const init = data.hook?.init;
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
// 把vnode转换成真实dom对象(没有渲染到页面)
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, " "));
// 执行模块的create钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 如果vnode中有子节点,创建子Vnode对应的DOM元素并追加到DOM树上
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)) {
// 如果vode的text值是string/number,创建文本节点并追加到DOM树
api.appendChild(elm, api.createTextNode(vnode.text));
}
const hook = vnode.data!.hook;
if (isDef(hook)) {
// 执行传入的钩子 create
hook.create?.(emptyNode, vnode);
if (hook.insert) {
insertedVnodeQueue.push(vnode);
}
}
} else {
// 如果选择器为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!);
}
// 返回新创建的DOM
return vnode.elm;
}
参考资料
https://foio.github.io/virtual-dom/;https://tech.tuya.com/xu-ni-dom/;https://github.com/snabbdom/snabbdom/;https://segmentfault.com/a/1190000017519084/;https://qastack.cn/programming/21965738/what-is-virtual-dom
结语
谢谢阅读,如果你觉得对你有帮助,欢迎一键三连,另外,我自己创办了一个公众号,你可以关注它。 VX搜索:前端历劫之路 。关注后,我可以拉你进学习交流群。掌握最新前端动态,一起学习进步。