渲染器

渲染器

最近在深入学习 Virtual DOM 的相关知识,参考了许多资料,也拜读了许多大神的文章,所以在这里大致的整理成了比较适合自己理解的方式,方便时不时回来翻翻,复习一下,篇幅较长,主要会分为三个部分来分别进行介绍,具体章节如下,目录名就差不多代表了章节的相关内容

在上篇的 什么是 Virtual DOM? 章节当中我们介绍过 Virtual DOM 的相关概念,以及如何将 Virtual DOM 渲染为真实的 DOM 节点和一个辅助创建 VNodeh 函数,本章是第二部分,我们就接着之前的内容来介绍渲染器相关内容,参考的是 HcySunYang/vue-design,本章相关内容如下

  • 什么是渲染器?
  • mount 阶段
    • 挂载普通标签元素
    • 挂载文本节点
    • 挂载 Fragment
    • 挂载 Portal
    • 挂载有状态组件
    • 挂载函数式组件
  • patch 阶段
    • 类型不同则替换 VNode
    • 更新标签元素
    • 更新文本节点
    • 更新 Fragment
    • 更新 Portal
    • 更新有状态组件
    • 更新函数式组

什么是渲染器?

所谓渲染器,简单的说就是将 Virtual DOM 渲染成特定平台下真实 DOM 的工具(就是一个函数,通常叫 render),渲染器的工作流程通常分为两个阶段 mountpatch

  • 如果旧的 VNode 存在,则会使用新的 VNode 与旧的 VNode 进行对比,试图以最小的资源开销完成 DOM 的更新,这个过程就叫 patch(打补丁)
  • 如果旧的 VNode 不存在,则直接将新的 VNode 挂载成全新的 DOM,这个过程叫做 mount

渲染器通常接收两个参数,第一个参数是将要被渲染的 VNode 对象,第二个参数是一个用来承载内容的容器(container),通常也叫挂载点,如下代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function render(vnode, container) {
const prevVNode = container.vnode
if (prevVNode == null) {
if (vnode) {
mount(vnode, container) // 没有旧的 VNode,使用 mount 函数挂载全新的 VNode
container.vnode = vnode // 将新的 VNode 添加到 container.vnode 属性下,这样下一次渲染时旧的 VNode 就存在了
}
} else {
if (vnode) {
patch(prevVNode, vnode, container) // 有旧的 VNode,则调用 patch 函数打补丁
container.vnode = vnode // 更新 container.vnode
} else {
container.removeChild(prevVNode.el) // 有旧的 VNode 但是没有新的 VNode,这说明应该移除 DOM,在浏览器中可以使用 removeChild 函数
container.vnode = null
}
}
}

当然渲染器不仅仅是一个把 VNode 渲染成真实 DOM 的工具,它还可以负责控制部分组件生命周期钩子的调用,又与异步渲染有直接关系,但是在这里,我们主要介绍的是其挂载和更新的操作,至于核心的 Diff 算法将会放到下一章单独介绍

mount 阶段

在前面已经简单的介绍过了,渲染器的工作流程通常分为两个阶段 mountpatch,那我们下面就来看看如何使用 mount 函数挂载全新的 VNode

挂载普通标签元素

这部分我们主要探讨渲染器的 mount 函数是如何把 VNode 渲染成真实 DOM 的,mount 函数的作用是把一个 VNode 渲染成真实 DOM,根据不同类型的 VNode 需要采用不同的挂载方式,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 关于 isSVG 下面会进行介绍
function mount(vnode, container, isSVG, refNode) {
const { flags } = vnode
if (flags & VNodeFlags.ELEMENT) {
mountElement(vnode, container, isSVG, refNode) // 挂载普通标签
} else if (flags & VNodeFlags.COMPONENT) {
mountComponent(vnode, container, isSVG) // 挂载组件
} else if (flags & VNodeFlags.TEXT) {
mountText(vnode, container) // 挂载文本节点
} else if (flags & VNodeFlags.FRAGMENT) {
mountFragment(vnode, container, isSVG) // 挂载 Fragment
} else if (flags & VNodeFlags.PORTAL) {
mountPortal(vnode, container, isSVG) // 挂载 Portal
}
}

我们根据 VNodeflags 属性值能够区分一个 VNode 对象的类型,不同类型的 VNode 采用不同的挂载函数

在之前的章节当中,我们曾经简单的实现过一个 mountElement 的方法

1
2
3
4
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag)
container.appendChild(el)
}

这是一个极简的用于挂载普通标签元素的 mountElement 函数,但它具有以下缺陷

  • VNode 被渲染为真实 DOM 之后,没有引用真实 DOM 元素
  • 没有将 VNodeData 应用到真实 DOM 元素上
  • 没有继续挂载子节点,即 children
  • 不能严谨地处理 SVG 标签

这里有一点需要说明,我们之所以设计 vnode.el 这个字段

是因为在 patch 阶段对 DOM 元素进行移动时,应该确保将其放到正确的位置,而不应该始终使用 appendChild 函数
有时需要使用 insertBefore 函数,这时候我们就需要拿到相应的节点引用,这时候 vnode.el 属性是必不可少的

下面我们就来针对上面的四个问题,我们逐个去解决,代码已经整合,详细的可以参见注释部分

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
function mountElement(vnode, container, isSVG) {
const domPropsRE = /\W|^(?:value|checked|selected|muted)$/
/**
* 针对第四个问题,不能严谨地处理 SVG 标签,这里需要单独说明一下,之前我们通过 vnode.flags 来判断一个标签是否是 SVG,如下
*
* if (typeof tag === 'string') {
* flags = tag === 'svg' ? VNodeFlags.ELEMENT_SVG : VNodeFlags.ELEMENT_HTML
* }
*
* 我们可以注意到,只有当标签名字全等于字符串 'svg' 时,该 VNode 的 flags 才会被标记为 VNodeFlags.ELEMENT_SVG
* 这意味着 <circle/> 标签不会被标记为 VNodeFlags.ELEMENT_SVG,所以在创建 <circle/> 元素时并不会使用 document.createElementNS 函数
* 但 <circle/> 标签确实是 svg 标签
*
* 但是又因为 svg 的书写总是以 <svg> 标签开始的,所有我们可以认为其他 svg 相关的标签都是 <svg> 标签的子代元素
* 所以我们添加了一个 isSVG 变量,在 mountElement 函数中一旦 isSVG 为真,那么后续创建的所有子代元素都会被认为是 svg 标签
*
* 这样即使 <circle/> 标签对应的 vnode.flags 不是 VNodeFlags.ELEMENT_SVG,但在 mountElement 函数看来它依然是 svg 标签
*/
isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG
const el = isSVG
? document.createElementNS('http://www.w3.org/2000/svg', vnode.tag)
: document.createElement(vnode.tag)

// 针对第一个问题,VNode 被渲染为真实 DOM 之后,没有引用真实 DOM 元素
// 我们只需要将 vnode.el 指向被创建的元素即可
vnode.el = el

// 针对第二个问题,没有将 VNodeData 应用到真实 DOM 元素上
// 我们使用循环,将其应用到元素之上
const data = vnode.data
if (data) {
for (let key in data) {

// key 可能是 class、style、on 等等,所以我们使用 switch 来进行处理
switch (key) {

// 如果 key 的值是 style,说明是内联样式,逐个将样式规则应用到 el
case 'style':
for (let k in data.style) {
el.style[k] = data.style[k]
}
break

/**
* 在使用过程中,class 并不一定传递的是字符串,也有可能是数组,亦或者对象,但是我们需要对传递进来的 class 进行序列化操作
* 所以这就是前文当中的 h 函数里面的 normalizeClass() 方法的作用
*/
case 'class':
if (isSVG) {
el.setAttribute('class', data[key])
} else {
el.className = data[key]
}
break
default:

// 针对事件,我们可以在 VNodeData 中使用 onclick 来代替 click
// 因为这样设计之后,我们就可以很容易地区分 VNodeData 中的某个属性是 DOM 属性还是 DOM 事件(只需要检测属性名的前两个字符是不是 on 即可)
if (key[0] === 'o' && key[1] === 'n') {
el.addEventListener(key.slice(2), data[key])

/**
* 关于属性的设置,这里需要单独说明一下
*
* 需要针对 Attributes 和 DOM Properties 来分别进行处理(简单来说就是标准属性(如 id 之类)和自定义属性(如 userId))
* 因为是非标准属性,所以当你尝试通过 document.body.userId 访问其值时会得到 undefined(这也是为什么 setAttribute 方法存在的原因)
* 但是也不能总是使用 setAttribute 设置全部的 DOM 属性,比如一些特殊值,如 checked/disabled 等,只要出现就会被初始化为 true
* 只有调用 removeAttribute 删除这个 attribute,对应的 property 才会变成 false
* 所以有些属性不能通过 setAttribute 设置,而是应该直接通过 DOM 元素设置,比如 el.checked = true
*
* 针对上述情况,我们创建了一个正则表达式 domPropsRE,用来检测那些应该以 Property 的方式添加到 DOM 元素上的属性,其他的属性则使用 setAttribute 方法设置
* 它还能匹配那些拥有大写字母的属性,这是为了匹配诸如 innerHTML、textContent 等属性设计的
* 这是因为拥有大写字母的属性我们都会采用 el[key] = xxx 的方式将其添加到 DOM 元素上
*/
} else if (domPropsRE.test(key)) {
// 当做 DOM Prop 处理
el[key] = data[key]
} else {
// 当做 Attr 处理
el.setAttribute(key, data[key])
}
break
}
}
}

// 针对第三个问题,没有继续挂载子节点
const childFlags = vnode.childFlags
const children = vnode.children

// 因为 VNode 对象的 children 属性不总是数组,所以我们分类来进行处理
// 如果没有子节点则无需递归挂载
if (childFlags !== ChildrenFlags.NO_CHILDREN) {
if (childFlags & ChildrenFlags.SINGLE_VNODE) {
// 如果是单个子节点则调用 mount 函数挂载
// 所以在这里需要把 isSVG 传递下去(原因见上方)
mount(children, el, isSVG)
} else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) {
// 如果是单多个子节点则遍历并调用 mount 函数挂载
for (let i = 0; i < children.length; i++) {
// 所以在这里需要把 isSVG 传递下去(原因见上方)
mount(children[i], el, isSVG)
}
}
}

// 当 refNode 存在时,我们应该使用 insertBefore 方法代替 appendChild 方法,为了后面 diff 算法当中进行优化操作
refNode ? container.insertBefore(el, refNode) : container.appendChild(el)
}

自此,我们用于挂载普通标签元素的 mountElement 函数算是暂时告一段落,下面来看看如何挂载文本节点

挂载文本节点

如果一个 VNode 的类型是 VNodeFlags.TEXT,那么 mount 函数会调用 mountText 函数挂载该纯文本元素,mountText 函数实现起来很简单,由于纯文本类型的 VNodechildren 属性存储着与之相符的文本字符串,所以只需要调用 document.createTextNode 函数创建一个文本节点即可,然后将其添加到 container 中即可

1
2
3
4
5
function mountText(vnode, container) {
const el = document.createTextNode(vnode.children)
vnode.el = el
container.appendChild(el)
}

挂载 Fragment

其实挂载 Fragment 和单纯地挂载一个 VNodechildren 是没什么区别的,只是在没有 Fragment 的时候我们如果要想挂载一个片段,这个片段必须使用包裹元素包裹(比如 div),有了 Fragment 则不需要包裹元素,实际上对于 Fragment 类型的 VNode 的挂载,就等价于只挂载一个 VNodechildren

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
// 这里需要注意的地方就是对于 Fragment 类型的 VNode 来说,当它被渲染为真实 DOM 之后,其 el 属性的引用是谁的问题,可以见下方注释部分
function mountFragment(vnode, container, isSVG) {
const { children, childFlags } = vnode
switch (childFlags) {

// 如果是单个子节点,则直接调用 mount
case ChildrenFlags.SINGLE_VNODE:
mount(children, container, isSVG)

// 如果是单个子节点,引用就指向该节点
vnode.el = children.el
break

// 如果没有子节点,等价于挂载空片段,会创建一个空的文本节点占位
case ChildrenFlags.NO_CHILDREN:
const placeholder = createTextVNode('')
mountText(placeholder, container)

// 如果没有子节点,则引用指向占位的空文本节点
vnode.el = placeholder.el
break

// 多个子节点,遍历挂载之
default:
for (let i = 0; i < children.length; i++) {
mount(children[i], container, isSVG)
}

// 如果有多个子节点,则默认指向第一个子节点
vnode.el = children[0].el
}
}

挂载 Portal

实际上 Portal 可以『不严谨地认为是可以被到处挂载』的 Fragment,实现 Portal 的关键是要将其 VNodechildren 中所包含的子 VNode 挂载到 tag 属性所指向的挂载点,『而非 container』(这个很重要)

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
function mountPortal(vnode, container) {
const { tag, children, childFlags } = vnode

// 获取挂载点
const target = typeof tag === 'string' ? document.querySelector(tag) : tag

if (childFlags & ChildrenFlags.SINGLE_VNODE) {
// 将 children 挂在到 target 上,而非 container
mount(children, target)
} else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) {
for (let i = 0; i < children.length; i++) {
// 将 children 挂在到 target 上,而非 container
mount(children[i], target)
}
}

/**
* 关于 Portal 类型的 VNode 其 el 属性的指向问题,需要说明一下
* 不应该指向挂载点元素,因为实际上虽然 Portal 所描述的内容可以被挂载到任何位置,但仍然需要一个占位元素
* 并且 Portal 类型的 VNode 其 el 属性应该指向该占位元素
*
* 之所以这样设置,是因为 Portal 的另外一个特性,虽然 Portal 的内容可以被渲染到任意位置,但它的行为仍然像普通的DOM元素一样
* 如事件的捕获和冒泡机制仍然按照代码所编写的 DOM 结构实施
* 要实现这个功能就必须需要一个占位的 DOM 元素来承接事件,就目前来说,我们用一个空的文本节点占位即可
*/

// 占位的空文本节点
const placeholder = createTextVNode('')
// 将该节点挂载到 container 中(注意不是挂载到 target 下)
mountText(placeholder, container, null)
// el 属性引用该节点
vnode.el = placeholder.el
}

挂载有状态组件

组件还分为有状态组件和函数式组件,所以在 mountComponent 函数内部,我们需要再次对组件的类型进行区分,并使用不同的挂载方式

1
2
3
4
5
6
7
8
// 需要再次对组件的类型进行区分,并使用不同的挂载方式
function mountComponent(vnode, container, isSVG) {
if (vnode.flags & VNodeFlags.COMPONENT_STATEFUL) {
mountStatefulComponent(vnode, container, isSVG)
} else {
mountFunctionalComponent(vnode, container, isSVG)
}
}

我们先来看看有状态组件的挂载方式

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
/**
* 本质上来说就分四个步骤
* 1. 创建组件实例
* 2. 获取组件产出的 VNode(调用实例的 render() 方法)
* 3. mount 挂载
* 4. 让组件实例的 $el 属性和 vnode.el 属性的值引用组件的根 DOM 元素
*
* 组件的 render 函数会返回该组件产出的 VNode,当该 VNode 被挂载为真实DOM之后,就可以通过 instance.$vnode.el 元素拿到组件的根DOM元素
* 接着我们就可以让组件实例的 $el 属性和 vnode.el 属性的值都引用该DOM元素
* 如果组件的 render 返回的是一个片段(Fragment),那么 instance.$el 和 vnode.el 引用的就是该片段的第一个 DOM 元素
*/
function mountStatefulComponent(vnode, container, isSVG) {
// 创建组件实例
const instance = (vnode.children = new vnode.tag())

// 初始化 props
instance.$props = vnode.data

// 我们把除了创建组件实例这一步之外的代码封装成一个函数
// 因为 _update 函数所做的工作就是渲染组件,这样当组件自身状态发生变化后,我们就可以再次调用 _update 函数来完成组件的更新
instance._update = function () {
// 设计一个 instance._mounted 变量用来标记组件是否已经被挂载,这样 _update 函数就能够区分当前这次渲染到底是初次挂载还是后续更新了
// 如果 instance._mounted 为真,说明组件已挂载,应该执行更新操作
if (instance._mounted) {
// 1. 拿到旧的 VNode(由于初次挂载组件时所产出的 VNode 存储在组件实例的 $vnode 属性中,所以我们可以通过 $vnode 属性拿到旧的 VNode)
const prevVNode = instance.$vnode
// 2. 重新调用 render 函数产出新的 VNode(重新渲染新的 VNode )
const nextVNode = (instance.$vnode = instance.render())
// 3. 调用 patch 函数对比新旧 VNode,完成更新操作
patch(prevVNode, nextVNode, prevVNode.el.parentNode)
// 4. 我们还应该使用新的真实 DOM 元素去更新 vnode.el 属性和组件实例的 $el 属性的值(更新 vnode.el 和 $el)
instance.$el = vnode.el = instance.$vnode.el
} else {
instance.$vnode = instance.render() // 1. 渲染VNode
mount(instance.$vnode, container, isSVG) // 2. 挂载
instance._mounted = true // 3. 组件已挂载的标识
instance.$el = vnode.el = instance.$vnode.el // 4. el 属性值 和 组件实例的 $el 属性都引用组件的根 DOM 元素

// 另外如果在组件中我们使用了 mounted 生命周期钩子
// 我们只需要在组件被渲染为真实 DOM 之后调用该组件实例的 mounted 函数即可
// 更多的关于生命周期钩子函数的内容我们暂且不做深入讨论
instance.mounted && instance.mounted() // 5. 调用 mounted 钩子
}
}

instance._update()
}

挂载函数式组件

在挂载函数式组件的时候,比挂载有状态组件少了一个实例化的过程,如果一个 VNode 描述的是函数式组件,那么其 tag 属性值就是该函数的引用

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
/**
* 函数式组件相较于有状态组件更为简单,因为它就是一个返回 VNode 的函数
* 而且比挂载有状态组件少了一个实例化的过程
*/
function mountFunctionalComponent(vnode, container, isSVG) {
vnode.handle = {
/**
* 这里简单介绍一下四个字段
* 1. handle.prev,存储旧的函数式组件 VNode,在初次挂载时,没有旧的 VNode 可言,所以初始值为 null
* 2. handle.next,存储新的函数式组件 VNode,在初次挂载时,被赋值为当前正在挂载的函数式组件 VNode
* 3. handle.container,存储的是挂载容器
* 4. handle.update ,针对函数式组件,与有状态组件不同,函数式组件没有组件实例,所以我们没办法封装类似 instance._update 这样的函数
* 所以这里我们采用把 update 函数定义在函数式组件的 VNode 上的方式
*/
prev: null,
next: vnode,
container,
update: () => {
/**
* 通过判断 vnode.handle.prev 是否存在来判断该函数式组件是初次挂载还是后续更新
* 由于在 patchComponent 函数内我们已经将 vnode.handle.prev 属性赋值为旧的组件 VNode
* 所以如果 vnode.handle.prev 存在则说明该函数式组件并非初次挂载,而是更新
*/
// 更新操作
if (vnode.handle.prev) {
/**
* 由于我们在 patchComponent 函数内已经更新过了 handle 对象
* 所以此时我们可以通过 vnode.handle.prev 和 vnode.handle.next 分别拿到旧的组件 VNode 和新的组件 VNode
* 但是这里需要区分开来,prevVNode 和 nextVNode 是用来描述函数式组件的 VNode,并非函数式组件所产出的 VNode
*/
const prevVNode = vnode.handle.prev
const nextVNode = vnode.handle.next

/**
* 因为函数式组件所产出的 VNode 存放在用来描述函数式组件的 VNode 的 children 属性中
* 所以我们通过 prevVNode.children 拿到了组件所产出的旧的 VNode 即 prevTree
* 接着使用新的 props 重新调用组件函数 nextVNode.tag(props) 得到新产出的 VNode 即 nextTree
*/
const prevTree = prevVNode.children
const props = nextVNode.data
const nextTree = (nextVNode.children = nextVNode.tag(props))

// 有了 prevTree 和 nextTree 之后我们就可以调用 patch 函数执行更新操作了
patch(prevTree, nextTree, vnode.handle.container)
} else {
/**
* 1. 获取 props(我们在调用组件函数获取 VNode 之前,要先获取 props,这里我们同样直接将整个 VNodeData 作为 props 数据)
* 2. 获取 VNode(在调用组件函数 vnode.tag(props) 时将 props 作为参数传递过去,这样子组件就可以通过参数访问由父组件传递过来的数据了)
* 3. 挂载
*/
const props = vnode.data
// 同样的,我们也用其 children 属性存储组件产出的 VNode
const $vnode = (vnode.children = vnode.tag(props))
mount($vnode, container, isSVG)
// el 元素引用该组件的根元素
vnode.el = $vnode.el
}
}
}

// 立即调用 vnode.handle.update 完成初次挂载
vnode.handle.update()
}

以上就是 mount 阶段的全部内容了,主要就是针对各种不同的挂载类型来进行对应的处理,下面我们再来看看 patch 阶段

patch 阶段

在上面我们已经介绍过了渲染器的挂载逻辑,其实 mount 阶段的本质就是将各种类型的 VNode 渲染成真实 DOM 的过程,渲染器除了将全新的 VNode 挂载成真实 DOM 之外,它的另外一个职责是负责对新旧 VNode 进行比对,并以合适的方式更新 DOM,也就是我们常说的 patch

当使用 render 渲染器渲染一个全新的 VNode 时,会调用 mount 函数挂载该 VNode,同时让容器元素存储对该 VNode 对象的引用,这样当再次调用渲染器渲染新的 VNode 对象到相同的容器元素时,由于旧的 VNode 已经存在,所以会调用 patch 函数以合适的方式进行更新

1
2
3
4
5
6
7
8
9
10
11
// 旧的 VNode
const prevVNode = h('div')

// 新的 VNode
const nextVNode = h('span')

// 第一次渲染 VNode 到 #app,此时会调用 mount 函数
render(prevVNode, document.getElementById('app'))

// 第二次渲染新的 VNode 到相同的 #app 元素,此时会调用 patch 函数
render(nextVNode, document.getElementById('app'))

patch 函数会对新旧 VNode 进行比对,但是在这里,我们需要设定一定的规则,因为只有相同类型的 VNode 才有比对的意义,例如我们有两个 VNode,其中一个 VNode 的类型是标签元素,而另一个 VNode 的类型是组件,当这两个 VNode 进行比对时,最优的做法是使用新的 VNode 完全替换旧的 VNode,按照这个思路,我们先来实现一个基本版本的 patch 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// patch 函数的作用是用来比对新旧 VNode,那么 patch 函数必然需要接收新旧 VNode 作为参数
// 我们使用 prevVNode 形参代表旧的 VNode,使用 nextVNode 形参代表新的 VNode
function patch(prevVNode, nextVNode, container) {
// 分别拿到新旧 VNode 的类型,即 flags
const nextFlags = nextVNode.flags
const prevFlags = prevVNode.flags

// 检查新旧 VNode 的类型是否相同,如果类型不同,则直接调用 replaceVNode 函数替换 VNode
// 如果新旧 VNode 的类型相同,则根据不同的类型调用不同的比对函数
if (prevFlags !== nextFlags) {
replaceVNode(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.ELEMENT) {
patchElement(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.COMPONENT) {
patchComponent(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.TEXT) {
patchText(prevVNode, nextVNode)
} else if (nextFlags & VNodeFlags.FRAGMENT) {
patchFragment(prevVNode, nextVNode, container)
} else if (nextFlags & VNodeFlags.PORTAL) {
patchPortal(prevVNode, nextVNode)
}
}

其核心原则是

如果类型不同,则直接调用 replaceVNode 函数使用新的 VNode 替换旧的 VNode,否则根据不同的类型调用与之相符的比对函数

如下图所示

类型不同则替换 VNode

替换操作并不复杂,本质就是把旧的 VNode 所渲染的 DOM 移除,再挂载新的 VNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function replaceVNode(prevVNode, nextVNode, container) {

// 将旧的 VNode 所渲染的 DOM 从容器中移除
container.removeChild(prevVNode.el)

// 在后面组件的删除当中,我们还有另外一件事需要做,即调用 unmounted 钩子,所以针对组件需要单独处理一下
// 如果将要被移除的 VNode 类型是组件,则需要调用该组件实例的 unmounted 钩子函数
if (prevVNode.flags & VNodeFlags.COMPONENT_STATEFUL_NORMAL) {
// 类型为有状态组件的 VNode,其 children 属性被用来存储组件实例对象
const instance = prevVNode.children
instance.unmounted && instance.unmounted()
}

// 再把新的 VNode 挂载到容器中
mount(nextVNode, container)
}

更新标签元素

首先即使两个 VNode 的类型同为标签元素,但它们也可能是不同的标签,也就是说它们的 tag 属性值不尽相同,所以我们可以认为『不同的标签渲染的内容不同』,例如 ul 标签下只能渲染 li 标签,所以拿 ul 标签和一个 div 标签进行比对是没有任何意义的,这种情况下我们会使用新的标签元素替换旧的标签元素

1
2
3
4
5
6
7
function patchElement(prevVNode, nextVNode, container) {
// 如果新旧 VNode 描述的是不同的标签,则调用 replaceVNode 函数,使用新的 VNode 替换旧的 VNode
if (prevVNode.tag !== nextVNode.tag) {
replaceVNode(prevVNode, nextVNode, container)
return
}
}

如果新旧 VNode 描述的是相同的标签,那两个 VNode 之间的差异就只会出现在 VNodeDatachildren 上,所以这里主要分为两部分

  • VNodeData 的比对
  • children 的比对

我们首先先来看一下如何更新 VNodeData,在这里我们遵循的原则是

将新的 VNodeData 全部应用到元素上,再把那些已经不存在于新的 VNodeData 上的数据从元素上移除

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
function patchElement(prevVNode, nextVNode, container) {
// 如果新旧 VNode 描述的是不同的标签,则调用 replaceVNode 函数,使用新的 VNode 替换旧的 VNode
if (prevVNode.tag !== nextVNode.tag) {
replaceVNode(prevVNode, nextVNode, container)
return
}

// 拿到 el 元素,注意这时要让 nextVNode.el 也引用该元素
const el = (nextVNode.el = prevVNode.el)
// 拿到 新旧 VNodeData
const prevData = prevVNode.data
const nextData = nextVNode.data
// 新的 VNodeData 存在时才有必要更新
if (nextData) {
// 遍历新的 VNodeData
for (let key in nextData) {
// 根据 key 拿到新旧 VNodeData 值
const prevValue = prevData[key]
const nextValue = nextData[key]
switch (key) {
case 'style':
// 遍历新 VNodeData 中的 style 数据,将新的样式应用到元素
for (let k in nextValue) {
el.style[k] = nextValue[k]
}
// 遍历旧 VNodeData 中的 style 数据,将已经不存在于新的 VNodeData 的数据移除
for (let k in prevValue) {
if (!nextValue.hasOwnProperty(k)) {
el.style[k] = ''
}
}
break
default:
break
}
}
}
}

是不是有一点似成相识的感觉,实际上无论是 mountElement 函数中用来处理 VNodeData 的代码还是 patchElement 函数中用来处理 VNodeData 的代码,它们的本质都是将 VNodeData 中的数据应用到 DOM 元素上,唯一的区别就是在 mountElement 函数中没有旧数据可言,所以我们可以来稍微的封装一下

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
// 遍历新的 VNodeData,将旧值和新值都传递给 patchData 函数,并由 patchData 函数负责更新数据
// 同时也需要遍历旧的 VNodeData,将已经不存在于新的 VNodeData 中的数据从元素上移除
function patchElement(prevVNode, nextVNode, container) {
// 如果新旧 VNode 描述的是不同的标签,则调用 replaceVNode 函数,使用新的 VNode 替换旧的 VNode
if (prevVNode.tag !== nextVNode.tag) {
replaceVNode(prevVNode, nextVNode, container)
return
}

// 拿到 el 元素,注意这时要让 nextVNode.el 也引用该元素
const el = (nextVNode.el = prevVNode.el)
const prevData = prevVNode.data
const nextData = nextVNode.data

if (nextData) {
// 遍历新的 VNodeData,将旧值和新值都传递给 patchData 函数
for (let key in nextData) {
const prevValue = prevData[key]
const nextValue = nextData[key]
patchData(el, key, prevValue, nextValue)
}
}
if (prevData) {
// 遍历旧的 VNodeData,将已经不存在于新的 VNodeData 中的数据移除
for (let key in prevData) {
const prevValue = prevData[key]
if (prevValue && !nextData.hasOwnProperty(key)) {
// 第四个参数为 null,意味着将该数据从元素上移除
patchData(el, key, prevValue, null)
}
}
}
}

// 将之前 h 方法中的 patchElement 函数中的 switch 语句块移动到了 patchData 函数中
// 改动的地方,只需要添加将旧的事件回调函数移除功能即可,其他照搬过来就行
function patchData(el, key, prevValue, nextValue) {
const domPropsRE = /\W|^(?:value|checked|selected|muted)$/
switch (key) {
case 'style':
for (let k in nextValue) {
el.style[k] = nextValue[k]
}
for (let k in prevValue) {
if (!nextValue.hasOwnProperty(k)) {
el.style[k] = ''
}
}
break
case 'class':
el.className = nextValue
break
default:
if (key[0] === 'o' && key[1] === 'n') {
// 事件这里相较于原本在 patchElement 当中有所调整
// 如果旧的事件回调函数存在,我们先将其从 DOM 元素上移除,接着如果新的事件回调函数存在我们再将其添加到 DOM 元素中
if (prevValue) {
el.removeEventListener(key.slice(2), prevValue)
}
if (nextValue) {
el.addEventListener(key.slice(2), nextValue)
}
} else if (domPropsRE.test(key)) {
// 当做 DOM Prop 处理
el[key] = nextValue
} else {
// 当做 Attr 处理
el.setAttribute(key, nextValue)
}
break
}
}

在了解完如何更新 VNodeData 以后,我们再来看看最后一小部分,那就是如何更新子节点,针对子节点,我们只需在 patchElement 函数中最后递归地更新子节点即可

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
function patchElement(prevVNode, nextVNode, container) {

if (prevVNode.tag !== nextVNode.tag) {
// ...
}

if (nextData) {
// ...
}
if (prevData) {
// ...
}

// ... 省略(可以见上方)

// 调用 patchChildren 函数递归地更新子节点
// patchChildren 函数的作用就是对新旧 VNode 的子节点进行同层级的比较
// 它接收五个参数,前四个参数分别是新旧 VNode 子节点以及子节点的类型
// 第五个参数 el 是这些子节点的父节点,也就是当前被更新的标签元素
patchChildren(
prevVNode.childFlags, // 旧的 VNode 子节点的类型
nextVNode.childFlags, // 新的 VNode 子节点的类型
prevVNode.children, // 旧的 VNode 子节点
nextVNode.children, // 新的 VNode 子节点
el // 当前标签元素,即这些子节点的父节点
)
}

接下来我们就可以思考如何来实现 patchChildren 函数,简单来说,无论是新标签还是旧标签,该标签的子节点都可以分为三种情况

  • 只有一个子节点
  • 没有子节点
  • 有多个子节点

至于一个标签的子节点属于哪种类型是可以通过该标签所对应的 VNode 对象的 childFlags 属性得知的,所以根据以上规则,我们可以得出 patchChildren 函数最基本的样子

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
function patchChildren(
prevChildFlags,
nextChildFlags,
prevChildren,
nextChildren,
container
) {
switch (prevChildFlags) {
// 旧的 children 是单个子节点,会执行该 case 语句块
case ChildrenFlags.SINGLE_VNODE:
switch (nextChildFlags) {
case ChildrenFlags.SINGLE_VNODE:
// 新的 children 也是单个子节点时,会执行该 case 语句块
break
case ChildrenFlags.NO_CHILDREN:
// 新的 children 中没有子节点时,会执行该 case 语句块
break
default:
// 新的 children 中有多个子节点时,会执行该 case 语句块
break
}
break
// 旧的 children 中没有子节点时,会执行该 case 语句块
case ChildrenFlags.NO_CHILDREN:
switch (nextChildFlags) {
case ChildrenFlags.SINGLE_VNODE:
// 新的 children 是单个子节点时,会执行该 case 语句块
break
case ChildrenFlags.NO_CHILDREN:
// 新的 children 中没有子节点时,会执行该 case 语句块
break
default:
// 新的 children 中有多个子节点时,会执行该 case 语句块
break
}
break
// 旧的 children 中有多个子节点时,会执行该 case 语句块
default:
switch (nextChildFlags) {
case ChildrenFlags.SINGLE_VNODE:
// 新的 children 是单个子节点时,会执行该 case 语句块
break
case ChildrenFlags.NO_CHILDREN:
// 新的 children 中没有子节点时,会执行该 case 语句块
break
default:
// 新的 children 中有多个子节点时,会执行该 case 语句块
break
}
break
}
}

由于新旧 children 各有三种情况,所以合起来共有九种情况,我们会根据这九种情况分别来进行完善,具体处理逻辑可以参考下面几个图

所以完善后的 patchChildren 函数如下所示,一些需要注意的地方都已经标注在注释当中了

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
function patchChildren(
prevChildFlags,
nextChildFlags,
prevChildren,
nextChildren,
container
) {
switch (prevChildFlags) {

// 旧的 children 是单个子节点
case ChildrenFlags.SINGLE_VNODE:
switch (nextChildFlags) {

// 新旧 children 都是单个子节点
// 在这种情况下新旧 children 的比较等价于两个 children(单个子节点)之间的比较,所以只需要递归地调用 patch 函数即可
case ChildrenFlags.SINGLE_VNODE:
patch(prevChildren, nextChildren, container)
break

// 新的 children 中没有子节点时,在这种情况下我们只需要把旧的子节点移除即可
case ChildrenFlags.NO_CHILDREN:
container.removeChild(prevChildren.el)
break

// 由于旧的子节点只有一个,而新的子节点有多个
// 所以我们可以采用将旧的单个子节点移除,再将新的多个子节点挂载上去的方案
default:
container.removeChild(prevChildren.el)
for (let i = 0; i < nextChildren.length; i++) {
mount(nextChildren[i], container)
}
break
}
break

// 旧的 children 中没有子节点时
case ChildrenFlags.NO_CHILDREN:
switch (nextChildFlags) {

// 新的 children 是单个子节点时,此时只需要把新的单个子节点添加到容器元素即可
// 使用 mount 函数将新的子节点挂载到容器元素
case ChildrenFlags.SINGLE_VNODE:
mount(nextChildren, container)
break

// 新的 children 中没有子节点时
// 什么都不做
case ChildrenFlags.NO_CHILDREN:
break

// 但新的 children 中有多个子节点时
// 遍历多个新的子节点,逐个使用 mount 函数挂载到容器元素
default:
for (let i = 0; i < nextChildren.length; i++) {
mount(nextChildren[i], container)
}
break
}
break

// 旧的 children 中有多个子节点时
default:
switch (nextChildFlags) {

// 有多个旧的子节点,但新的子节点是单个子节点
// 这时只需要把所有旧的子节点移除,再将新的单个子节点添加到容器元素即可
case ChildrenFlags.SINGLE_VNODE:
for (let i = 0; i < prevChildren.length; i++) {
container.removeChild(prevChildren[i].el)
}
mount(nextChildren, container)
break

// 有多个旧的子节点,但没有新的子节点,这时只需要把所有旧的子节点移除即可
case ChildrenFlags.NO_CHILDREN:
for (let i = 0; i < prevChildren.length; i++) {
container.removeChild(prevChildren[i].el)
}
break

/**
* 最复杂的当属最后一种情况,新旧子节点都是多个子节点的情况,这时将进入到至关重要的一步,即核心 diff 算法的用武之地
* 其实本质上,原理就是将旧的子节点全部移除,再将所有新的子节点添加的一种思路来完成更新
* 目前我们先暂时这样处理,关于 diff 的内容,会在后面详细的进行介绍
*/
default:
for (let i = 0; i < prevChildren.length; i++) {
container.removeChild(prevChildren[i].el)
}
for (let i = 0; i < nextChildren.length; i++) {
mount(nextChildren[i], container)
}
break
}
break
}
}

更新文本节点

相较于标签元素,文本节点的更新非常简单,如果一个 DOM 元素是文本节点或注释节点,那么可以通过调用该 DOM 对象的 nodeValue 属性读取或设置文本节点(或注释节点)的内容

1
2
3
4
5
6
7
8
9
function patchText(prevVNode, nextVNode) {
// 拿到文本元素 el,同时让 nextVNode.el 指向该文本元素
const el = (nextVNode.el = prevVNode.el)
// 由于对纯文本类型的 VNode 而言,它的 children 属性存储的就是其文本内容,所以通过对比新旧文本内容是否一致来决定是否需要更新.
// 只有新旧文本内容不一致时我们才会设置文本节点的 el.nodeValue 属性的值
if (nextVNode.children !== prevVNode.children) {
el.nodeValue = nextVNode.children
}
}

更新 Fragment

实际上片段的更新是『简化版的标签元素的更新』,我们知道对于标签元素来说更新的过程分为两个步骤

  • 首先需要更新标签本身的 VNodeData
  • 其次更新其子节点

然而由于 Fragment 没有包裹元素,只有子节点,所以我们对 Fragment 的更新本质上就是更新两个片段的子节点

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
function patchFragment(prevVNode, nextVNode, container) {
// 直接调用 patchChildren 函数更新 新旧片段的子节点即可
patchChildren(
prevVNode.childFlags, // 旧片段的子节点类型
nextVNode.childFlags, // 新片段的子节点类型
prevVNode.children, // 旧片段的子节点
nextVNode.children, // 新片段的子节点
container
)

switch (nextVNode.childFlags) {
// 如果新的片段的 children 类型是单个子节点,则意味着其 vnode.children 属性的值就是 VNode 对象
// 所以直接将 nextVNode.children.el 赋值给 nextVNode.el 即可
case ChildrenFlags.SINGLE_VNODE:
nextVNode.el = nextVNode.children.el
break

// 如果新的片段没有子节点,我们知道对于没有子节点的片段我们会使用一个空的文本节点占位,而 prevVNode.el 属性引用的就是该空文本节点
// 所以我们直接通过旧片段的 prevVNode.el 拿到该空文本元素并赋值给新片段的 nextVNode.el 即可
case ChildrenFlags.NO_CHILDREN:
nextVNode.el = prevVNode.el
break

// 如果新的片段的类型是多个子节点,则 nextVNode.children 是一个 VNode 数组,我们会让新片段的 nextVNode.el 属性引用数组中的第一个元素
default:
nextVNode.el = nextVNode.children[0].el
}
}

更新 Portal

实际上 Portal 的更新与 Fragment 类似,我们需要更新其子节点,但由于 Portal 可以被到处挂载,所以新旧 Portal 的挂载目标可能不同,所以对于 Portal 的更新除了要更新其子节点之外,还要对比新旧挂载目标是否相同,如果新的 Portal 的挂载目标变了我们就需要将 Portal 的『内容从旧的容器中搬运到新的容器中』

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
function patchPortal(prevVNode, nextVNode) {
patchChildren(
prevVNode.childFlags,
nextVNode.childFlags,
prevVNode.children,
nextVNode.children,
prevVNode.tag // 注意 container 是旧的 container
)

// 让 nextVNode.el 指向 prevVNode.el
nextVNode.el = prevVNode.el

/**
* 由于我们在更新子节点的过程中,传递给 patchChildren 函数的容器元素始终都是旧的容器元素
* 所以最终结果是,更新后的子节点也存在于旧的容器中
* 所以我们还需要做最后一步工作,就是把旧容器内的元素都搬运到新容器中
* 搬运的原理就是利用 appendChild 的特性,即被添加的元素如果已存在于页面上,那么就会移动该元素到目标容器元素下
*/

// 如果新旧容器不同,才需要搬运
if (nextVNode.tag !== prevVNode.tag) {
// 获取新的容器元素,即挂载目标
const container =
typeof nextVNode.tag === 'string'
? document.querySelector(nextVNode.tag)
: nextVNode.tag

switch (nextVNode.childFlags) {
case ChildrenFlags.SINGLE_VNODE:
// 如果新的 Portal 是单个子节点,就把该节点搬运到新容器中
container.appendChild(nextVNode.children.el)
break
case ChildrenFlags.NO_CHILDREN:
// 新的 Portal 没有子节点,不需要搬运
break
default:
// 如果新的 Portal 是多个子节点,遍历逐个将它们搬运到新容器中
for (let i = 0; i < nextVNode.children.length; i++) {
container.appendChild(nextVNode.children[i].el)
}
break
}
}
}

更新有状态组件

对于有状态组件来说它的更新方式有两种,主动更新和被动更新

  • 主动更新,指的是组件自身的状态发生变化所导致的更新,例如组件的 data 数据发生了变化就必然需要重渲染
  • 被动更新,一个组件所渲染的内容是很可能包含其它组件的,也就是子组件,对于子组件来讲,它除了自身状态之外,很可能还包含从父组件传递进来的外部状态(props),所以父组件自身状态的变化很可能引起子组件外部状态的变化,此时就需要更新子组件,像这种因为外部状态变化而导致的组件更新就叫做被动更新

我们先来看看主动更新,关于主动更新的关键点在于数据变化之后需要重新执行渲染函数,得到新的 VNode,这里就用到了我们在 mountStatefulComponent 方法中定义的 instance._update() 方法

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
function patchComponent(prevVNode, nextVNode, container) {
/**
* 但是有时父组件自身状态的变化会导致父组件渲染不同的子组件
* 所以在这里,我们认为不同的组件渲染不同的内容,所以对于不同的组件我们采用的方案是使用新组件的内容替换旧组件渲染的内容
* tag 属性的值是组件类,我们通过对比前后组件类是否相同来确定新旧组件是否是相同的组件,如果不相同则直接调用 replaceVNode 函数使用新组件替换旧的组件
*/
if (nextVNode.tag !== prevVNode.tag) {
replaceVNode(prevVNode, nextVNode, container)
} else if (nextVNode.flags & VNodeFlags.COMPONENT_STATEFUL_NORMAL) {
/**
* 通过检查组件的 flags 判断组件是否是有状态组件,如果是有状态组件则更新之,分为三步
* 1、获取组件实例(通过 prevVNode.children 拿到组件实例)
* 2、更新 props(使用新的 VNodeData 重新设置组件实例的 $props 属性)
* 3、更新组件(由于组件的 $props 已更新,所以调用组件的 _update 方法,让组件重渲染)
*/
const instance = (nextVNode.children = prevVNode.children)

// 这里需要特殊说明一下
// 在组件实例创建完成之后,我们为组件实例添加了 $props 属性,并且将 vnode.data 赋值给 $props
// 这样,子组件中就可以通过 this.$props.text 访问从父组件传递进来的 props 数据
// 但是 VNodeData 中的数据并不全是 props,其中还包含事件以及其他信息(这些将在后续来进行介绍)
instance.$props = nextVNode.data
instance._update()
} else {
// 函数式组件的更新
}
}

更新函数式组件

其实无论是有状态组件还是函数式组件,它们的更新原理都是一样的,用组件新产出的 VNode 与之前产出的旧 VNode 进行比对,从而完成更新,这里就用到了我们在 mountFunctionalComponent 方法中定义的 update() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function patchComponent(prevVNode, nextVNode, container) {
if (nextVNode.tag !== prevVNode.tag) {
// ...
} else if (nextVNode.flags & VNodeFlags.COMPONENT_STATEFUL_NORMAL) {

// ... 省略(见上方)

} else {
/**
* 1. 将旧的函数式组件 VNode(prevVNode) 赋值给 handle.prev
* 2. 将新的函数式组件 VNode(nextVNode) 赋值给 handle.next
* 3. 更新 container(即使 container 未必会变,但仍要更新之)
* 4. 最后我们调用了 handle.update 函数完成更新操作
*/
// 更新函数式组件
const handle = (nextVNode.handle = prevVNode.handle)
handle.prev = prevVNode
handle.next = nextVNode
handle.container = container

handle.update()
}
}

参考

如果想了解更多的相关内容,可以参考以下链接

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×