Virtual DOM理解与Snabbdom源码分析

本文主要讲解什么是 Virtual DOM 以及 Virtual DOM 的作用,通过一个 Virtual DOM 库 Snabbdom 真正了解什么是 Virtual DOM ,以及 Diff 算法的实现和 key 的作用。

Virtual DOM 和 DOM

DOM(Document Object Model——文档对象模型)是用来呈现以及与任意 HTML 或 XML文档交互的API。DOM 是载入到浏览器中的文档模型,以节点树的形式来表现文档,每个节点代表文档的构成部分(例如:页面元素、字符串或注释等等)。

什么是 Virtual DOM

Virtual DOM(虚拟 DOM),是由普通的的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM 。

  • 真实 DOM 成员
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">Hello Virtual DOM</div>
<script>
let element = document.querySelector('#app')
let s = ''
for (var key in element) {
s += key + ','
}
console.log(s)
</script>
</body>
</html>

打印结果:
DOM
通过打印结果得知,一个DOM对象的成员有很多,从而创建一个DOM成本是非常高的。

  • 可以使用 Virtual DOM 来描述真实 DOM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// 选择器
sel: "div",
// 模块,节点数据(属性,样式,事件等)
data: {},
// 子节点,和text互斥
children: undefined,
// 节点中的文本内容
text: "Hello Virtual DOM",
// 记录vnode对应的真实DOM
elm: undefined,
// 节点标识(唯一)
key: undefined
}

对比真实DOM创建VNode成本比真实DOM小很多。

为什么使用 Virtual DOM

  • 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
  • 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题(数据发生变化时无法获取上次状态),于是 Virtual DOM 出现了
  • Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
  • 参考github上 virtual-dom 的描述
    • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
    • 通过比较前后两次状态的差异更新真实 DOM

虚拟 DOM 的作用

  • 使用VNode可以记录上次状态的变化,只更新发生变化的部分
  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能,简单视图首次渲染并不能提高性能
  • 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
    DOM

Snabbdom

Vue 2.x 内部使用的Virtual DOM就是改造的Snabbdom,Snabbdom内部使用TypeScript开发,是最快的Virtual DOM 之一

Snabbdom基本使用

需求:实现一个如下html结构的web ui,其中div的text部分hello是会在[hello、visual、world]三者当中随机选择一个显示的(演示dom更新):

1
2
3
4
5
6
7
 <div>
hello
<ul>
<li id="1" class="li-1">first li</li>
<li id="2" class="li-2">second li</li>
</ul>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src='./index.js'></script>
<div id="app"></div>
</body>
</html>

dom实现(原生)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// index.js
let app = document.getElementById("app");
function createDivNode() {
const divNode = document.createElement("div");
const textNode = document.createTextNode(["hello", "visual", "dom"][Date.now()%3]);
divNode.appendChild(textNode);
const notesNode = document.createComment("this is a notes node");
divNode.appendChild(notesNode);

function createLiNode(props, text) {
const li = document.createElement("li");
for (key in props) {
const attr = document.createAttribute(key);
attr.value = props[key];
li.setAttributeNode(attr);
}
const textNode = document.createTextNode(text);
li.appendChild(textNode);
return li;
}
divNode.appendChild(createLiNode({ id: 1, class: "li-1" }, "first li"));
divNode.appendChild(
createLiNode({ id: 2, class: "li-2" }, "second li")
);
return divNode;
}

function render(action) {
function replaceDom() {
const newDiv = createDivNode();
app.parentNode.appendChild(newDiv);
app.parentNode.removeChild(app);
app = newDiv;
}
switch (action) {
case "init":
replaceDom();
break;
case "update":
replaceDom();
break;
}
}
render("init");
setInterval(() => {
render("update");
}, 1000);

virtual dom实现(snabbdom)

  • 项目中引入snabbdom可以通过script标签和npm形式引入,此案例使用npm方式

    • script方式引入:

      1
      2
      <script src="https://cdn.bootcss.com/snabbdom/0.7.4/snabbdom.js"></script>
      <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>
    • npm方式引入:

      1
      2
      $ yarn add parcel-bundler  # 本地安装 parcel 打包工具
      $ yarn add snabbdom # snabbdom
  • 配置 package.json 的 scripts

    1
    2
    3
    4
    5
    6
    {
    "scripts": {
    "dev": "parcel index.html --open",
    "build": "parcel build index.html"
    }
    }
  • index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    // exports是在nodejs12中中开始支持,webpack4和parcel是不支持的,webpack5中开始支持exports,所以此处不能直接使用官方文档提供的写法,需要导入完整的路径
    import { init } from 'snabbdom/build/package/init'
    import { h } from 'snabbdom/build/package/h'
    let app = document.getElementById("app");
    /** -- init() 使用 init() 函数创建 patch()
    * 参 数: 数组,将来可以传入模块,处理属性/样式/事件等
    * 返回值:patch函数
    */
    const patch = init([])

    function createDivVNode() {
    function createLiVNode(sel, text) {
    return h(sel, text);
    }
    /** -- h() 函数创建 vnode
    * 第一个参数:标签 + 选择器
    * 第二个参数:若是字符串,则表示标签中的内容
    * 若是数组, 则表示创建标签中的子元素
    */
    const vnode = h("div", [
    ["hello", "visual", "dom"][Date.now()%3],
    // h('!') 是创建注释
    h("!", "this is a notes node"),
    createLiVNode("li", "first li"),
    createLiVNode("li", "second li"),
    ]);

    return vnode;
    }

    function render(action) {
    switch (action) {
    case "init":
    /** -- patch() 对比两个vnode的差异,更新到真实DOM
    * 第一个参数:旧VNode 可以是 DOM元素,则内部会把DOM元素转换成VNode;也可以是 VNode
    * 第二个参数:新VNode
    * 返回值:新的 VNode
    */
    app = patch(app, createDivVNode());
    break;
    case "update":
    app = patch(app, createDivVNode());
    break;
    }
    }
    render("init");
    setInterval(() => {
    render("update");
    }, 1000);

上述示例中可以看到:DOM 方式实现是使用 DOM 提供的 API 编写渲染 DOM 结构即可。那么对于 virtual dom 库 snbbdom 是如何实现的 dom 渲染?通过代码可以看到,使用 snabbdom 的 init() 函数创建 patch(),通过h函数创建虚拟节点vnode,patch 函数用于把 vnode 渲染到 app 的位置,更新时再把新的虚拟节点渲染在老的虚拟节点位置上,后面我们将通过snabbdom源码来研究下内部实现原理的。

需要注意,查看官方文档是可以看到案例是通过import { init } from 'snabbdom/init'方式引入,此时运行的话会告诉我们找不到 init / h 模块,查看源码得知模块路径并不是 snabbdom/int,这个路径是在 package.json 中的 exports 字段设置的,而我们使用的parcel打包工具不支持 exports 这个字段,webpack 4 也不支持,webpack 5 支持该字段,该字段在导入 snabbdom/init 的时候会补全路径成 snabbdom/build/package/init.js,如果使用不支持 package.json 的 exports 字段的打包工具,我们应该把模块的路径写全既import { init } from 'snabbdom/build/package/init'

内置模块

Snabbdom 的核心库并不能处理DOM元素的属性/样式/事件等,如果需要处理的话,可以使用snabbdom默认提供的模块来实现,snabbdom中模块的实现是通过注册全局的钩子函数来实现。

官方提供了 6 个模块

  • attributes
    • 设置 DOM 元素的属性,使用 setAttribute()
    • 处理布尔类型的属性
  • props
    • 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
    • 不处理布尔类型的属性
  • class
    • 切换类样式
    • 注意:给元素设置类样式是通过 sel 选择器
  • dataset
    • 设置 data-* 的自定义属性
  • eventlisteners
    • 注册和移除事件
  • style
    • 设置行内样式,支持动画
    • delayed/remove/destroy

模块使用

  • 模块使用步骤:
    • 导入需要的模块
    • init() 中注册模块
    • 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { init} from 'snabbdom/build/package/init'
import {h} from 'snabbdom/build/package/h'

// 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'

// 使用 init() 函数创建 patch()
// init() 的参数是数组,将来可以传入模块,处理属性/样式/事件等
let patch = init([
// 注册模块
styleModule,
eventListenersModule
])

// 使用 h() 函数创建 vnode
let vnode = h('div.cls', {
// 设置 DOM 元素的行内样式
style: { color: '#DEDEDE', backgroundColor: '#181A1B' },
// 注册事件
on: { click: clickHandler }
}, [
h('h1', 'Hello Snabbdom'),
h('p', '这是段落')
])

function clickHandler () {
// 此处的 this 指向对应的 vnode
console.log(this.elm.innerHTML)
}

snabbdom源码分析

Snabbdom 的核心

  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  • init() 设置模块,创建 patch()
  • patch() 比较新旧两个 VNode
  • 把变化的内容更新到真实 DOM 树上

snabbdom目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|—— .vscode --------------------------- 开发工具的配置文件
|—— examples -------------------------- 官方提示示例
├── hero ---------------------------- 演示自定义模块
├── reorder-animation --------------- 演示过度动画的列表
|—— perf ------------------------------ 性能测试
|—— src ------------------------------ 源码
├── package
│   ├── helpers
│   │   └── attachto.ts ------------- 定义了 vnode.ts 中 AttachData 的数据结构
│   ├── modules --------------------- 所有模块的定义
│   │   ├── attributes.ts
│   │   ├── class.ts
│   │   ├── dataset.ts
│   │   ├── eventlisteners.ts
│   │   ├── hero.ts ----------------- example 中使用到的自定义钩子
│   │   ├── module.ts --------------- 定义了模块中用到的钩子函数
│   │   ├── props.ts
│   │   └── style.ts
│   ├── h.ts ----------------------- h() 函数,用来创建 VNode
│   ├── hooks.ts ------------------- 声明周期用到的所有钩子函数的定义,类似于vue生命周期钩子函数
│   ├── htmldomapi.ts -------------- 对 DOM API 的包装(元素的增删改查)
│   ├── init.ts -------------------- 加载 modules、DOMAPI,返回 patch 函数
│   ├── is.ts ---------------------- 判断数组和原始值的函数
│   ├── jsx-global.ts -------------- jsx 的类型声明文件
│   ├── jsx.ts --------------------- 处理 jsx
│   ├── thunk.ts ------------------- 优化处理,对复杂视图不可变值得优化
│   ├── tovnode.ts ----------------- 提供一个函数把DOM 转换成 VNode(patch函数内部使用这个函数转换)
│   ├── ts-transform-js-extension.cjs
│   ├── tsconfig.json -------------- ts 的编译配置文件
│   └── vnode.ts ------------------- 虚拟节点定义
|── test --------------------------- 单元测试

snabbdom如何通过h函数创建Virtual Node?

h() 函数
  • 在使用 Vue 的时候见过 h() 函数,Vue中增强了 h 函数,实现了组件的机制

    1
    2
    3
    4
    5
    new Vue({
    router,
    store,
    render: h => h(App)
    }).$mount('#app')
  • h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本,也就是 html 字符串

  • Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode

重载

  • 参数个数或类型不同的函数
  • JavaScript 中没有重载的概念
  • TypeScript 中有重载,不过重载的实现还是通过代码调整参数

在源码中,snabbdom通过函数重载方式提供给用户四种调用h函数的方式,而后定义了一个h函数处理参数,并且调用vnode函数创建一个VNode对象返回

源码位置:src/package/h.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// h函数重载 (前四个)
export function h (sel: string): VNode // h1
export function h (sel: string, data: VNodeData | null): VNode // h2
export function h (sel: string, children: VNodeChildren): VNode // h3
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode // h4
// h函数的真正实现,ts支持重载,js不支持重载
// ts代码编译为js代码后只有一个h函数,参数的差异是通过在函数内部通过判断类型来实现的
// 此函数作用:用来判断参数的个数了类型
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
// 输入解析(重载)1.调用者传入三个参数,b视为data(属性节点),c视为children/text(子节点),对应传参方式h4
// 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
data = b
}
// 如果c是数组
if (is.array(c)) {
children = c
// 如果c是字符串或者数字
} else if (is.primitive(c)) {
text = c
// 如果c是VNode
} else if (c && c.sel) {
children = [c]
}
} else
// 输入解析(重载)2.调用者传入两个参数。判断b类型后把b视为children/text或者data,对应传参方式h2或h3
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 }
}
// 输入解析(重载)3:调用者只传了一个参数时,视为只传入一个带选择器的空标签,对应于传参方式h1
// 预处理逻辑1,把文本转换为VNode
if (children !== undefined) {
// 处理children中的原始值(string/number)
for (i = 0; i < children.length; ++i) {
// 正常情况下children都是vnode对象
// 如果child是string/number,说明用户没有调用h函数,此处调用vnode函数将string/number转换为VNode对象,此处的VNode是用来描述文本节点
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
// 预处理逻辑2,为SVG添加命名空间
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是svg,添加命名空间
addNS(data, children, sel)
}
// 调用vnode函数创建一个VNode对象并返回
return vnode(sel, data, children, text, undefined)
};

对于h函数的具体函数逻辑则在第五个h函数当中,它主要做了三件事情:

  • 首先,解析参数输入实现函数重载
  • 其次,进行输入预处理,先是子节点的输入分为两种类型(为的是加快效率),即单文本children节点和children节点数组,而后为svg添加命名空间。
  • 最后,转给vnode函数取出data中的key后构造一个vNode节点对象
VNode

h函数是通过调用 VNode 函数创建了一个 VNode 对象,下面具体看下 VNode 对象包含什么

源码位置:src/package/vnode.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// key 用来表示唯一标识当前的vnode对象
export type Key = string | number
//用来约束最终创建的VNode对象需要具有哪些属性
export interface VNode {
// 选择器
sel: string | undefined
// 描述模块中所需要的的数据
// 模块,节点数据:属性/样式/事件等
data: VNodeData | undefined;
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined;
// 记录 vnode 对应的真实 DOM
elm: Node | undefined;
// 对应节点中的文本内容,和 children 只能互斥
text: string | undefined;
// 优化用
key: Key | undefined;
}
// 约束data的类型,VNode中data属性
export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
[key: string]: any // for any other 3rd party module
}
// vnode的实现
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
// key是通过data来赋值
const key = data === undefined ? undefined : data.key
// 返回了普通的js对象,具有vnode接口定义的6个属性,
// vnode的作用就是用来描述真实DOM,创建vnode时候可以根据需要传入对应的参数
return { sel, data, children, text, elm, key }
}

snabbdom如何通过patch函数渲染Virtual Node?

patch 整体执行过程
  • patch(oldVnode, newVnode) —— snabbdom的核心
  • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
  • 对比新旧 VNode 是否相同节点(节点的 key(节点唯一标识) 和 sel(节点选择器) 相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
  • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
  • diff 过程只进行同层级比较

diff

VNode中为什么要使用Diff算法?
渲染真实DOM的开销很大,DOM操作会引起浏览器的重拍和重绘,即浏览器重新渲染,性能消耗比较大
diff 算法核心:当数据变化后,不直接操作DOM,用js对象描述真实DOM,当数据变化后会先比较js对象是否发生变化,找到所有变化的位置,最小化的更新变化的位置,从而提高性能

接下来我们看下patch函数是如果创建的?

从之前的示例中我们可以得知,patch函数是通过调用init函数得到的,我们先来探讨一下init函数。

init函数

init函数负责接收用户传入的模块数组参数和渲染平台domApi参数,通过闭包的方式返回一个构造好外部环境的用于渲染Virtual Node的patch函数。

init(modules, domApi),返回 patch() 函数(高阶函数)

为什么要使用高阶函数?
1.因为 patch() 函数在外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
2.通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建

源码位置:src/package/init.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 存储了钩子函数的名字
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
// domAPI 执行DOM操作
// 把vnode对象转换为其他平台对应的元素,不传默认设置为操作浏览器平台DOM的API
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);
// 初始化转换虚拟节点的 api
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 把传入的所有模块的钩子函数,统一存储到 cbs 对象中
// 最终构建的 cbs 对象的形式 cbs = { create: [], 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 Array<any>).push(hook);
}
}
}
function emptyNodeAt (elm: Element) {
······
}
function createRmCb (childElm: Node, listeners: number) {
······
}
// 创建 vnode 对应的 DOM 元素
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
······
}
// 新增节点
function addVnodes (parentElm: Node,before: Node | null,vnodes: VNode[],startIdx: number,endIdx: number,insertedVnodeQueue: VNodeQueue){
······
}
// 执行 destroy 钩子函数(会执行所有子节点的 destroy 钩子函数)
function invokeDestroyHook (vnode: VNode) {
······
}
// 移除节点
function removeVnodes (parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number): void {
······
}
// 对比新旧节点的 children,更新 DOM
function updateChildren (parentElm: Node, oldCh: VNode[],newCh: VNode[],insertedVnodeQueue: VNodeQueue) {
······
}
// 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
······
}
// init 内部返回 patch 函数,把vnode渲染成真实 dom,并返回vnode
// 高阶函数,在一个函数内部返回一个函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
.....
};
}

init函数的输入参数有:模块数组modules和平台的domApi作为参数。

  • 模块数组参数modules通过钩子机制使得snabbdom具备了可扩展性(snabbdom核心部分只具备解析id、class以及key属性的功能,其它属性以及事件机制都在钩子模块中实现)
  • domApi参数使得snabbdom具备了跨平台渲染的能力,默认值为htmlDomApi

除了modules和domApi之外,init函数的函数体中还为patch函数定义了一些工具变量和函数以便patch函数调用,下面我们看下patch函数内部的实现

patch函数

patch函数的职责是接收一个老的虚拟node对象和一个新的虚拟node对象,然后用新的虚拟node对象的信息去更新老的虚拟node对象,更新完成后返回新的虚拟node对象以作为下一次更新的老的虚拟node对象。

执行过程:

  • 首先执行模块中的钩子函数 pre
  • 如果 oldVnode 和 vnode 相同(key 和 sel 相同)
    • 调用 patchVnode(),找节点的差异并更新 DOM
  • 如果 oldVnode 是 DOM 元素
    • 把 DOM 元素转换成 oldVnode
    • 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
    • 把刚创建的 DOM 元素插入到 parent 中
    • 移除老节点
    • 触发用户设置的 create 钩子函数

源码位置:src/package/init.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// init 内部返回 patch 函数,把vnode渲染成真实 dom,并返回vnode(高阶函数,在一个函数内部返回一个函数)
// oldVnode参数的类型为 VNode或Element,这样设计的目的是为了兼容第一次更新,这时,老的节点对象是dom元素(如示例中的id为app的div元素)而不是虚拟node节点。
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]()
// 1.如果 oldVnode 不是 VNode,创建 VNode 并设置 elm,如果是就转换为虚拟dom节点
if (!isVnode(oldVnode)) {
// 把 DOM 元素转换成空的 VNode
oldVnode = emptyNodeAt(oldVnode)
}
// 2.如果新旧节点是相同节点(key 和 sel 相同)
if (sameVnode(oldVnode, vnode)) {
// 如果相同,找新旧节点的差异并使用新节点更新老节点的DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 如果新旧节点不同,vnode 创建对应的 DOM(使用新节点替换老节点)
// 获取当前的 DOM 元素
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// 创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数
// 把VNode节点转换为对应的DOM元素,把DOM元素存储到VNode对象的Element属性中并没有把创建的DOM元素挂载在DOM树上
createElm(vnode, insertedVnodeQueue)

if (parent !== null) {
// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
// ! typescript 语法,告诉编译器vnode.elm是百分百有值的
// 调用insertBefore时候把VNode中存储的elm属性对应的DOM元素插入到parent(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
}

snabbdom如何通过createElm函数把Virtual Node转为Dom Node?

createElm函数

createElm函数以虚拟node对象作为输入,经过一定的逻辑转换后返回输出一个dom节点。需要注意的是,在前面说到,需要创建的dom节点的类型可以分为四种,即文本节点、注释节点、属性节点和元素节点。
用户想要创建哪一种节点类型在调用h函数创建虚拟node节点时已经告诉了snabbdom,但是虚拟node对象却不区分这些类型,所以在转换过程中,我们要把用户希望创建的节点类型给解析回来,然后创建对应的dom类型节点。

执行过程:
createElm

  • 首先触发用户设置的 init 钩子函数
  • 把 vnode 转换成 DOM 对象,存储到 vnode.elm 中
  • 如果选择器是!,创建注释节点
  • 如果选择器为空,创建文本节点
  • 如果选择器不为空
    • 创建对应的 DOM 对象:解析选择器,设置标签的 id 和 class 属性
    • 执行模块的 create 钩子函数
    • 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树
    • 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
    • 执行用户设置的 create 钩子函数
    • 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
  • 返回 vnode.elm

源码位置:src/package/init.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 作用:把 VNode 转换成对应的 DOM 元素,但是并不会把 DOM 渲染到页面中
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any,
//使用h函数时候传入的第二个参数,data中可以传入真实DOM需要的属性以及vnode的钩子函数
data = vnode.data;
if (data !== undefined) {
// 执行用户设置的 init 的钩子函数
const init = data.hook?.init; // ?. 用来判断hook是否有值,有值返回hook.init,没有就返回undefined
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
// 把 vnode 转换成真实 DOM 对象(没有渲染到页面)
let children = vnode.children, 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;
// data.ns 是否有命名空间
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).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, ' '));
// 执行模块的 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)) {
// 如果 vnode 的 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) {
// 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
insertedVnodeQueue.push(vnode);
}
}
} else {
// 如果选择器为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!);
}
// 返回新创建的 DOM
return vnode.elm;
}

snabbdom如何通过patchVnode函数和updateChildren函数实现Virtual Node更新(Dom diff算法)?

实现Virtual Node更新也就是以生成新虚拟node对应的dom结构为目标,通过最大限度的从现有的dom树中寻找并复用已有的dom节点的方式减少dom节点的创建和渲染,加快UI渲染速度。
从算法角度来说,就是实现树节点的差异更新,也就是diff算法。

patchVnode

patchNode函数负责在新旧节点相同后,把新虚拟节点渲染在旧虚拟节点对应的dom节点位置上

执行过程:
patchVNode

  • 首先执行用户设置的 prepatch 钩子函数
  • 执行 create 钩子函数
    • 首先执行模块的 create 钩子函数
    • 然后执行用户设置的 create 钩子函数
  • 如果 vnode.text 未定义
    • 如果 oldVnode.children 和 vnode.children 都有值
      • 调用 updateChildren()
      • 使用 diff 算法对比子节点,更新子节点
    • 如果 vnode.children 有值,oldVnode.children 无值
      • 清空 DOM 元素
      • 调用 addVnodes(),批量添加子节点
    • 如果 oldVnode.children 有值,vnode.children 无值
      • 调用 removeVnodes(),批量移除子节点
    • 如果 oldVnode.text 有值
      • 清空 DOM 元素的内容
  • 如果设置了 vnode.text 并且和和 oldVnode.text 不等
    • 如果老节点有子节点,全部移除
    • 设置 DOM 元素的 textContent 为 vnode.text
  • 最后执行用户设置的 postpatch 钩子函数

源码位置:src/package/init.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook;
// 首先执行用户设置的 prepatch 钩子函数
hook?.prepatch?.(oldVnode, vnode);
const elm = vnode.elm = oldVnode.elm!;
let oldCh = oldVnode.children as VNode[];
let ch = vnode.children as VNode[];
// 如果新老 vnode 相同,直接返回
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
// 执行模块的 update 钩子函数
for (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

updateChildren函数负责在新旧节点相同后,把新虚拟节点的子节点与老虚拟节点的子结点数组进行比较后差异更新(diff 算法的核心,对比新旧节点的 children,更新 DOM)

执行过程

  • 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)(传统的diff)
    传统的diff算法是:将两棵树中所有节点一一对比需要O(n^2),在加上更新(移动,创建,删除)时需要遍历一次,所以是O(n^3)
  • 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)
    snbbdom根据DOM的特点对传统的diff算法做了优化,DOM操作时候会很少跨级别操作节点,值对比同级别的节点,n个节点需要对比n次,时间复杂度为O(n)

    diff

  • 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引

  • 在对开始和结束节点比较的时候,总共有四种情况

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
    • oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
    • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

    diff

  • 开始节点和结束节点比较,这两种情况类似

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
  • 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)

    • 调用 patchVnode() 对比和更新节点
    • 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++

    diff

  • oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同

    • 调用 patchVnode() 对比和更新节点
    • 把 oldStartVnode 对应的 DOM 元素,移动到右边
      • 更新索引

    diff

  • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同

    • 调用 patchVnode() 对比和更新节点
    • 把 oldEndVnode 对应的 DOM 元素,移动到左边
      • 更新索引

    diff

  • 如果不是以上四种情况

    • 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
    • 如果没有找到,说明 newStartNode 是新节点
      • 创建新节点对应的 DOM 元素,插入到 DOM 树中
    • 如果找到了
      • 判断新节点和找到的老节点的 sel 选择器是否相同
      • 如果不相同,说明节点被修改了
        • 重新创建对应的 DOM 元素,插入到 DOM 树中
      • 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边

    diff

  • 循环结束

    • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
    • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
  • 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边

    diff

  • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除

    diff

这里有一个视频通过动画的形式讲解了Diff算法:图解 Vue.js Virtual DOM 的 Diff 算法核心

源码位置:src/package/init.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// VNode 的核心
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];
// 存储对象,对象中键是老节点对应的key值是老节点的索引,方便根据新节点key找到对应老节点在数组中的索引
let oldKeyToIdx: any;
// 存储老节点的索引
let idxInOld: number;
let elmToMove: VNode;
// 当新节点在老节点中找到相同key的老节点,存储对应的老节点Before:存储插入的参考元素
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)) {
// 1. 比较老的开始节点和新的开始节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 2. 比较老的结束节点和新的结束节点
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 3. 比较老的开始节点和新的结束节点
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
// 4. 比较老的结束节点和新的开始节点
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 开始节点和结束节点都不相同
// 使用 newStartNode 的 key 在老的节点数组中找相同节点
// 先设置记录 key 和 index 的对象
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 遍历 newStartVnode,从老的节点中找相同 key 的 oldVnode 的索引
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 如果是新的 vnode
if (isUndef(idxInOld)) { // New element
// 如果没找到,newStartVnode 是新节点
// 创建元素插入 DOM 树
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
// 重新给 newStartVnode 赋值,指向下一个新节点
newStartVnode = newCh[++newStartIdx];
} else {
// 如果找到相同 key 相同的老节点,记录到 elmToMove 遍历
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
// 如果新旧节点的选择器不同
// 创建新开始节点对应的 DOM 元素,插入到 DOM 树中
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
} else {
// 如果相同,patchVnode()
// 把 elmToMove 对应的 DOM 元素,移动到左边
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
// 重新给 newStartVnode 赋值,指向下一个新节点
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);
}
}
}

通过源码发现,在通常情况下,snabbdom的做法和我们上面说的一样,用新节点数组中的节点一一去匹配老节点数组中的节点,有则移动或更新(注意老节点只复用一次),无则创建并插入。

节点设置key与不设置key有什么不同?

  • 当选择器的sel都相同时,未设置key,新旧节点会被认为是相同的节点,只会更改老节点的text或者children属性从而最大限度的重用现有DOM
  • 当选择器的sel都相同时,设置key时,他会对比在diff中用来比较是否是相同的节点,如果不同会移动老节点的DOM元素来匹配新数据项的顺序,从而重用和重排现有元素,减少DOM操作

Hooks 函数 和 模块 Modules

  • snabbdom核心: patch() -> patchVnode() -> updateChildren()
  • Snabbdom 为了保证核心库的精简,把处理元素的属性/事件/样式等工作,放置到模块中
  • 模块可以按照需要引入
  • 模块的使用可以查看官方文档
  • 模块实现的核心是基于 Hooks
Hooks 函数

预定义的钩子函数的名称
源码位置:src/package/hooks.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export interface Hooks {
// patch 函数开始执行的时候触发
pre?: PreHook;
// createElm 函数开始之前的时候触发
// 在把 VNode 转换成真实 DOM 之前触发
init?: InitHook;
// createElm 函数末尾调用
// 创建完真实 DOM 后触发
create?: CreateHook;
// patchVnode 函数末尾执行
// 真实 DOM 添加到 DOM 树中触发
insert?: InsertHook;
// patchVnode 函数开头调用
// 开始对比两个 VNode 的差异之前触发
prepatch?: PrePatchHook;
// patchVnode 函数开头调用
// 两个 VNode 对比过程中触发,比 prepatch 稍晚
update?: UpdateHook;
// patchVnode 的最末尾调用
// 两个 VNode 对比结束执行
postpatch?: PostPatchHook;
// removeVnodes -> inVokeDestroyHook 中调用
// 在删除元素之前触发,子节点的 destroy 也被触发
destroy?: DestroyHook;
// removeVnodes 中调用
//
remove?: RemoveHook;
post?: PostHook;
}
模块 Modules

Snabbdom 提供的所有模块在:src/package/modules 文件夹下,主要模块有:

modules目录结构:

1
2
3
4
5
6
7
8
|—— attributes.ts --------------------------- 使用 setAttribute/removeAttribute 操作属性,能够处理 boolean 类型的属性
|—— class.ts -------------------------------- 切换类样式
|—— dataset.ts ------------------------------ 操作元素的 data-* 属性
|—— eventlisteners.ts ---------------------- 注册和移除事件
|—— module.ts ------------------------------ 定义模块遵守的钩子函数
|—— props.ts ------------------------------- 和 attributes.ts 类似,但是是使用 elm[attrName] = value 的方式操作属性
|—— style.ts ------------------------------- 操作行内样式,可以使动画更平滑
|—— hero.ts -------------------------------- 自定义的模块,examples/hero 示例中使用

attributes.ts

  • 模块到出成员
1
2
3
4
5
export const attributesModule = { 
create: updateAttrs,
update: updateAttrs
} as Module;
export default attributesModule;
  • updateAttrs 函数功能
    1.更新节点属性
    2.如果节点属性值是 true 设置空置
    3.如果节点属性值是 false 移除属性

  • updateAttrs 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
var key: string, elm: Element = vnode.elm as Element,
oldAttrs = (oldVnode.data as VNodeData).attrs,
attrs = (vnode.data as VNodeData).attrs;
// 新老节点没有 attrs 属性,返回
if (!oldAttrs && !attrs) return;
// 新老节点的 attrs 属性相同,返回
if (oldAttrs === attrs) return;
oldAttrs = oldAttrs || {};
attrs = attrs || {};

// update modified attributes, add new attributes
// 遍历新节点的属性
for (key in attrs) {
// 新老节点的属性值
const cur = attrs[key];
const old = oldAttrs[key];
// 如果新老节点的属性值不同
if (old !== cur) {
// 布尔类型值的处理
if (cur === true) {
elm.setAttribute(key, "");
} else if (cur === false) {
elm.removeAttribute(key);
} else {
// xChar -> x
// <svg xmlns="http://www.w3.org/2000/scg">
if (key.charCodeAt(0) !== xChar) {
elm.setAttribute(key, cur);
} else if (key.charCodeAt(3) === colonChar) {
// colonChar -> :
// Assume xml namespace
elm.setAttributeNS(xmlNS, key, cur);
} else if (key.charCodeAt(5) === colonChar) {
// Assume xlink namespace
// <svg xmlns:xlink="http://www.w3.org/1999/xlink">
elm.setAttributeNS(xlinkNS, key, cur);
} else {
elm.setAttribute(key, cur);
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
// 如果老节点的属性在新节点中不存在,移除
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key);
}
}
}

参考

MDN DOM概述
算法的时间复杂度
snabbdom源码分析