Vue.js设计与实现

ES6 再学习 2021-01-08
2023-06-29 18:20:06 刚读了一遍,并复写在了 03_js/076_结束.html
.
Vue 再学习 2021-01-11
2023-06-29 18:19:22 刚把它简要读了一遍,感谢我之前做的好笔记啊。
.
微信读书
操作笔记 09_vue/书籍:Vue.js设计与实现
2023-06-29 16:46:49(开始)
2023-07-28 10:49:31(结束) 一个月了,拖拖拉拉,真的很想是放弃了,慢慢还是坚持住了。

感悟

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
2023-07-03 09:48:45
# 开篇 第一章 命令式与声明式
开篇就给出 命令式与声明式:js、jQuery、vue。
# jQuery
$('#app') // 获取 div
.text('hello world') // 设置文本内容
.on('click', () => { alert('ok') }) // 绑定点击事件

# js
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件

# vue
<div @click="() => alert('ok')">hello world</div>

# 第三章 渲染函数 编译器
当我看完第三章,真的感悟很大啊。看了 render() 包括 mountElement、mountComponent。
不由得联想到 JDK Spring SpringBoot SpringCloud MyBatis MQ Redis 等配合。
模版(.vue文件) => 编译器 => 渲染函数(虚拟DOM) => 渲染器 => 真实DOM

# 第四章 响应式数据
(2023-07-05 09:59:49
第一次看的时候犯困,云里雾里。
昨儿看下去了,但ok false触发两次没看懂。
今天文本记录变量状态值,代码走一遍就懂了,又用这方法继续往下看到4.7调度执行。)

# 第五章 非原始值 / 第六章 原始值
2023-07-13 08:37:55 每天坚持下来真的很有效,感谢当初自己重头细心的看第四章响应式数据,不然第五章/第六章根本看不懂,还自己实操了一波。
这里看完,转眼,一本书已经过了 1/3。后面还有很多,说到这里我又要把之前的笔记再过一遍了。
今天也是用了ChatGPT解释了一些 Vue.js 里面我看不懂的代码。(好了,暂时就写那么多吧)

# 7~11章
2023-07-19 09:28:56 已经做完了笔记!但没有实操!
---
2023-07-21 09:09:41
基于笔记又把 7~11章,重跑了一遍,已提交上面的 [操作笔记 09]
在实操的时候,发现里面也有很多不对的地方,没跑通的地方,又加以改正。

# 12~14章
2023-07-24 17:44:39 只整理了笔记,但没有实操!

# 15编译器 16解析器 17编译优化
# 18 SSR CSR
2023-07-28 10:57:00
编译器:状态机,正则就是状态机。 initial tagOpen tagName text tagEnd tagEndName
解析器:递归下降算法。 parseChildren => parseElement => parseChildren
编译优化:Block dynamicChildren(动态节点:根节点+指令节点) 与 PatchFlags(处理方式)

# 书评
2023-07-28 07:39:48
设计与实现系列,之前读过redis感觉很经典,这次把这个读了也是一绝,
主要学到了响应式,代理,渲染,编译,解析,编译优化,…

前言

1
2
3
4
5
# Vue.js 3.0
Vue.js 前端框架,2020-09-18,正式迎来了 3.0 版本
1. 框架设计与实现上做了很多创新
2. 更少代码实现更多功能
3. “还清” 了 Vue.js 2 的技术债

第1章 权衡的艺术

“框架设计里到处都体现了权衡的艺术。”

1.1 命令式和声明式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
命令式框架 => 关注过程
例如:
- 获取 id 为 app 的 div 标签
- 它的文本内容为 hello world
- 为其绑定点击事件
- 当点击时弹出提示:ok

# jQuery
$('#app') // 获取 div
.text('hello world') // 设置文本内容
.on('click', () => { alert('ok') }) // 绑定点击事件

# js
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件

# vue
声明式框架 => 关注结果
<div @click="() => alert('ok')">hello world</div>

Vue.js 封装过程。
Vue.js 内部实现-命令式,暴露给用户-声明式。

1.2 性能与可维护性的权衡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[命令式, 声明式] 各有好坏,框架需权衡 [性能, 可维护性]。
先抛出结论:声明式代码的性能 <= 命令式代码的性能

假设现在我们要将 div 标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?
直接调用相关命令操作
div.textContent = 'hello vue3' // 直接修改

声明式代码不能直接修改,它要找到差异
<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>
对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:
div.textContent = 'hello vue3' // 直接修改

如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B,那么有:
● 命令式代码的更新性能消耗 = A
● 声明式代码的更新性能消耗 = B + A

可以看到,声明式代码会比命令式代码多出找出差异的性能消耗。
因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。
这符合前文中给出的性能结论:声明式代码的性能不优于命令式代码的性能。

在保持可维护性的同时让性能损失最小化。

1.3 虚拟 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
所谓的虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。

innerHTML 和 document.createElement 等 DOM 操作方法有何差异?

把字符串赋值给 DOM 元素的 innerHTML 属性
const html = ` <div><span>...</span></div> `
div.innerHTML = html

innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM 计算量

虚拟 DOM 创建页面的过程分为两步:
第一步是创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述;
第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。
我们同样可以用一个公式来表达:创建 JavaScript 对象的计算量 + 创建真实 DOM 的计算量。


使用 innerHTML 更新页面的过程是重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML 属性,这其实是在说,哪怕我们只更改了一个文字,也要重新设置innerHTML 属性。
而重新设置 innerHTML 属性就等价于销毁所有旧的 DOM 元素,再全量创建新的 DOM 元素。
再来看虚拟 DOM 是如何更新页面的。它需要重新创建 JavaScript 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化的元素并更新它。

当更新页面时,影响虚拟 DOM 的性能因素与影响 innerHTML 的性能因素不同。
对于虚拟 DOM 来说,无论页面多大,都只会更新变化的内容,而对于 innerHTML 来说,页面越大,就意味着更新时的性能消耗越大。

innerHTML(模版) 心智负担中等,性能差
虚拟DOM 心智负担小,可维护性强,性能不错
原生JavaScript 心智负担大,可维护性差,性能高

1.4 运行时和编译时

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
# 框架的三种选择
当设计一个框架的时候,我们有三种选择:
- 纯运行时
- 运行时 + 编译时
- 纯编译时

假设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,
然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。

# ========= 纯运行时
# 定义函数
function Render(obj, root) {
const el = document.createElement(obj.tag)
if (typeof obj.children === 'string') {
const text = document.createTextNode(obj.children)
el.appendChild(text)
} else if (obj.children) {
// 数组,递归调用 Render,使用 el 作为 root 参数
obj.children.forEach((child) => Render(child, el))
}

// 将元素添加到 root
root.appendChild(el)
}

# 使用
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
// 渲染到 body 下
Render(obj, document.body)

# ========= 运行时 + 编译时
const html = `
<div>
<span>hello world</span>
</div>
`
// 调用 Compiler 编译得到树型结构的数据对象
// 构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)


# ========= 纯编译时
将HTML字符串编译为命令式代码

# HTML字符串
`<div>
<span>hello world</span>
</div>`

# 命令式代码
const div = document.createElement('div')
const span = document.createElement('span')
span.innerText = 'hello world'
div.appendChild(span)
document.body.appendChild(div)


Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。
等到后面讲解 Vue.js 3 的编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。

1.5 总结

1
2
3
4
5
6
7
8
9
10
11
# 命令式、声明式
命令式(更加关注过程)在理论上可以做到极致优化,但是用户要承受巨大的心智负担;
声明式(更加关注结果)能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化。

# 虚拟 DOM
声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。
虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。
心智负担、性能、可维护性等因素综合考虑。一番权衡之后,我们发现虚拟 DOM 是个还不错的选择。

# 运行时、编译时
Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。

第2章 框架设计的核心要素

2.1 提升用户的开发体验

1
2
3
4
5
6
7
8
# 开发体验
衡量框架,也看它的开发体验
例如 Vue.js 3:createApp(App).mount('#not-exist')
当我们创建一个 Vue.js 应用并挂载到一个不存在的 DOM 节点时,就会警告。
若 Vue.js 不做处理,会得到 JavaScript 错误,难知问题所在。
=> 例如: Uncaught TypeError: Cannot read property 'xxx' of null,

框架设计和开发过程中,友好的警告提示(至关重要)。

2.2 控制框架代码的体积

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
# 代码体积
衡量框架,也看它的代码大小
实现同样功能,代码越少越好,这样体积就越小,浏览器加载资源的时间也就越少。

# 悖论 [警告, 代码体积]
完善的警告信息=编写更多代码,这与控制代码体积相悖。
因此,我们要想办法解决这个问题。


# __DEV__ 常量检查
Vue.js 3 的源码,每一个 warn 函数都有__DEV__ 常量的检查:
if (__DEV__ && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}

# 不同版本 [开发, 生产]
Vue.js 在输出资源的时候,会输出两个版本(通过文件名我们也能够区分),
其中一个用于开发环境,如vue.global.js,
另一个用于生产环境,如 vue.global.prod.js。

# 开发环境
当 Vue.js 构建用于开发环境的资源时,会把 __DEV__ 常量设置为 true
这段代码在开发环境中是肯定存在的。
if (true && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}

# 生产环境
这时我们发现这段分支代码永远都不会执行,因为判断条件始终为假,这段永远不会执行的代码称为 dead code,
它不会出现在最终产物中,在构建资源的时候就会被移除,因此在 vue.global.prod.js 中是不会存在这段代码的。
if (false && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}

# 总结
[√] 在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。

2.3 框架要做到良好的 Tree-Shaking

1
2
3
4
5
6
7
8
9
10
11
12
13
# Tree-Shaking
在前端,这个概念因 rollup.js 而普及。
简单说,Tree-Shaking 指消除永远不会被执行的代码,也就是 dead code,
[rollup.js, webpack] 都支持 Tree-Shaking


# /*#__PURE__*/ 注释
在 Vue.js 3 的源码中,基本都是在一些顶级调用的函数上使用 /*#__PURE__*/ 注释。
foo() // 顶级调用

function bar() {
foo() // 函数内调用
}

2.4 框架应该输出怎样的构建产物

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
# iife
在 rollup.js 中,我们可以通过配置 format: 'iife' 来输出这种形式的资源:
// rollup.config.js
const config = {
input: 'input.js',
output: {
file: 'output.js',
format: 'iife' // 指定模块形式
}
}

export default config

# ESM
ESM 格式,不能把 __DEV__ 设置为 true/false,而要使用 (process.env.NODE_ENV !== 'production') 替换 __DEV__ 常量。
if (__DEV__) {
warn(`useCssModule() is not supported in the global build.`)
}

在带有 -bundler 字样的资源中会变成
if ((process.env.NODE_ENV !== 'production')) {
warn(`useCssModule() is not supported in the global build.`)
}

# cjs
当进行服务端渲染时,Vue.js 的代码是在 Node.js 环境中运行的,而非浏览器环境。
在 Node.js 环境中,资源的模块格式应该是 CommonJS,简称 cjs。
为了能够输出 cjs 模块的资源,我们可以通过修改 rollup.config.js 的配置 format: 'cjs' 来实现:
// rollup.config.js
const config = {
input: 'input.js',
output: {
file: 'output.js',
format: 'cjs' // 指定模块形式
}
}

export default config

2.6 错误处理

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
# 统一定义 utils.js
// utils.js
let handleError = null
export default {
foo(fn) {
callWithErrorHandling(fn)
},
// 用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}

# 使用
import utils from 'utils.js'
// 注册错误处理程序
utils.registerErrorHandler((e) => {
console.log(e)
})
utils.foo(() => {/*...*/})
utils.bar(() => {/*...*/})


# 实际上,这就是 Vue.js 错误处理的原理,你可以在源码中搜索到 callWithErrorHandling 函数。
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 错误处理程序
}

2.7 良好的 TypeScript 类型支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# TS 优点
- 代码即文档
- 编辑器自动提示
- 避免低级 bug
- 代码的可维护性
- ...

# 类型
它接收参数 val 并且该参数可以是任意类型(any),该函数直接将参数作为返回值,这说明返回值的类型是由参数决定的,
如果参数是 number 类型,那么返回值也是 number 类型。
function foo(val: any) {
return val
}

在调用 foo 函数时,我们传递了一个字符串类型的参数 'str'
按照之前的分析,得到的结果 res 的类型应该也是字符串类型,然而当我们把鼠标指针悬浮到 res 常量上时,可以看到其类型是 any,这并不是我们想要的结果。
为了达到理想状态,我们只需要对 foo 函数做简单的修改即可
function foo<T extends any>(val: T): T {
return val
}

2.8 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
开发体验是衡量一个框架的重要指标之一。
在框架层面抛出有意义的警告信息是非常必要的。

警告越详细,框架体积越大。
利用 Tree-Shaking 机制,配合构建工具预定义常量的能力,
例如预定义 __DEV__ 常量,仅在开发环境中打印警告,而生产环境中则不包含这些代码。

Tree-Shaking 是一种排除 dead code 的机制,使最终打包的代码体积最小化。
Tree-Shaking 基于 ESM,并且JavaScript 是动态语言,通过纯静态分析进行 Tree-Shaking 较难,因此大部分工具识别 /*#__PURE__*/ 注释来进行 Tree-Shaking。

为了让用户能够通过 <script> 标签直接引用并使用,我们需要输出 IIFE 格式的资源,即立即调用的函数表达式。
为了让用户能够通过 <script type="module"> 引用并使用,我们需要输出 ESM 格式的资源。
这里需要注意的是,ESM 格式的资源有两种:
用于浏览器的 esm-browser.js(将 __DEV__ 常量替换为字面量 truefalse
用于打包工具的 esm-bundler.js(将 __DEV__ 常量替换为 process.env.NODE_ENV !== 'production' 语句)

第3章 Vue.js 3 的设计思路

3.1 声明式地描述 UI

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
# Vue.js 3 => 声明式 UI 框架
● 使用与 HTML 标签一致的方式来描述属性,例如 <div id="app"></div>;
● 使用 : 或 v-bind 来描述动态绑定的属性,例如 <div :id="dynamicId"></div>;
● 使用 @ 或 v-on 来描述事件,例如点击事件 <div @click="handler"></div>;
...

# 可声明式,也可js对象描述
除了上面这种使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述:
# JavaScript 对象
const title = {
tag: 'h1',// 标签名称
props: {// 标签属性
onClick: handler
},
children: [// 子节点
{ tag: 'span' }
]
}

# Vue.js 模板
<h1 @click="handler"><span></span></h1>

# js描述UI更灵活
使用模板和 JavaScript 对象描述 UI 有何不同呢?答案是:使用 JavaScript 对象描述 UI 更加灵活。
# JavaScript
let level = 3// h 标签的级别
const title = {
tag: `h${level}`, // h3 标签
}

# vue
<h1 v-if="level === 1"></h1>
<h2 v-else-if="level === 2"></h2>
<h3 v-else-if="level === 3"></h3>
<h4 v-else-if="level === 4"></h4>
<h5 v-else-if="level === 5"></h5>
<h6 v-else-if="level === 6"></h6>

# 手写渲染函数描述,使用虚拟DOM描述UI
正是因为虚拟 DOM 的这种灵活性,Vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。
其实我们在 Vue.js 组件中手写的渲染函数就是使用虚拟 DOM 来描述 UI 的
import { h } from 'vue'

export default {
render() {
return h('h1', { onClick: handler }) // 虚拟 DOM
}
}

h 函数的返回值就是一个对象,其作用是让我们编写虚拟 DOM 变得更加轻松。
如果把上面h 函数调用的代码改成 JavaScript 对象,就需要写更多内容:
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}


# render函数 => 虚拟DOM => 组件
Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。

3.2 初识渲染器

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.虚拟DOM => 2.渲染器 => 3.真实DOM

# 1.虚拟DOM
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}

# 2.渲染器
// ● vnode:虚拟 DOM 对象。
// ● container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。
function renderer(vnode, container) {
// 1. [tag] 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)

// 2. [props] 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}

// 3. [children] 处理 children
// 如果 children 是字符串,说明它是元素的文本子节点
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
if (typeof vnode.children === 'string') {
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => renderer(child, el))
}

// 最后,将元素添加到挂载点下
container.appendChild(el)
}

# 3.真实DOM
renderer(vnode, document.body) // body 作为挂载点

# 4.运行代码
在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出alert('hello')
运行结果如下图

运行结果

3.3 组件的本质

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
组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容。

# 组件作为 JavaScript 函数
# MyComponent
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}

# vnode
const vnode = {
tag: MyComponent
}

# renderer
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}

# mountElement
// mountElement 函数与上文中 renderer 函数的内容一致
// ● vnode:虚拟 DOM 对象。
// ● container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。
function mountElement(vnode, container) {
// 1. [tag] 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)

// 2. [props] 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}

// 3. [children] 处理 children
// 如果 children 是字符串,说明它是元素的文本子节点
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
if (typeof vnode.children === 'string') {
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => renderer(child, el))
}

// 最后,将元素添加到挂载点下
container.appendChild(el)
}

# mountComponent
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}



# 组件也可以是 JavaScript 对象
# MyComponent function => object
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}


# renderer 修改支持 'function' => 'object'
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}


# mountComponent 获取 render() 返回值
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}

3.4 模板的工作原理

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
模板 => 编译器 => 渲染函数

对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个渲染函数
# 模版
<div @click="handler">
click me
</div>

# 渲染函数
render() {
return h('div', { onClick: handler }, 'click me')
}


.vue文件就是一个组件

# .vue文件
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
}
}
</script>

# 编译后
// <template> 就是模板内容,编译器会把<template>编译成render()函数并添加到<script>的组件对象上,所以最终在浏览器里运行的代码就是
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}

# 小总结
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,
然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。

模版(.vue文件) => 编译器 => 渲染函数(虚拟DOM) => 渲染器 => 真实DOM

3.5 Vue.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
# 模板
<div id="foo" :class="cls"></div>

# 模版 => 编译器 => 渲染函数
render() {
// 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
// 下面的代码等价于:
// return h('div', { id: 'foo', class: cls })
return {
tag: 'div',
props: {
id: 'foo',
class: cls
}
}
}

cls 是一个变量,它可能会发生变化。
渲染器的作用之一就是寻找并且只更新变化的内容,所以当变量 cls 的值发生变化时,渲染器会自行寻找变更点。

渲染器寻找变化点费力气。
从编译器的视角来看,编译器分析动态内容,并在编译阶段把这些信息提取出来,然后直接交给渲染器,渲染器很容易知道变化点。

id="foo" 是永远不会变化的,而 :class="cls" 是一个 v-bind 绑定,它是可能发生变化的。
编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码的时候可以附带这些信息:
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
xxx: 1 // 假设数字 1 代表 class 是动态的
}
}
渲染器看到生成的虚拟 DOM 对象中多出了一个 xxx 属性,这样就知道:“哦,原来只有 class 属性会发生改变。”
渲染器就不用寻找变化点,性能自然就提升了。

3.6 总结

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
Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。
Vue.js 描述UI的两种方式:
- 模板(更直观)
<h1 @click="handler"><span></span></h1>
- 虚拟DOM(更灵活)
const title = {
tag: 'h1',// 标签名称
props: {// 标签属性
onClick: handler
},
children: [// 子节点
{ tag: 'span' }
]
}

# 渲染器
作用:虚拟DOM => 渲染器 => 真实DOM
工作原理:递归遍历虚拟DOM,调用原生DOM API创建真实DOM。
渲染器的精髓:后续的更新,通过DIFF算法找出变化点,并只更新需要更新的内容。(后续讲)

# 组件的本质
组件就是一组虚拟 DOM 元素的封装
- 可以是一个返回虚拟 DOM 的函数
- 也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM

渲染器在渲染组件时,先获取组件的渲染内容(即执行渲染函数得到返回值),我们称为 subtree,再递归调用subtree渲染。

# Vue.js 的模板(.vue文件)会被一个叫作编译器的程序编译为渲染函数。

编译器、渲染器都是 Vue.js 的核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。
=> 编译器提取动态属性告知渲染器,渲染器后续只更新需要更新的内容(提升性能,无需自寻变化点)。

【日期标记】2023-07-03 09:17:38 以上同步完成
同步的时候,更清晰的认识了一遍,可以说是同步笔记是读取文章的第二遍。

第4章 响应系统的作用与实现

4.1 响应式数据与副作用函数

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
# 副作用
# case1
# => effect()修改body内容,除它之外的任何函数都可以读body内容。
# => 也就是说 effect函数产生了副作用(直接或间接影响其他函数的执行)
function effect() {
document.body.innerText = 'hello vue3'
}


# case2
# => 修改了成员变量,也是一个副作用。
let val = 1 // 全局变量

function effect() {
val = 2 // 修改全局变量,产生副作用
}



# 响应式数据
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}

// 修改 obj.text 的值,同时希望副作用函数会重新执行。
// 如果能实现这个目标,那么对象 obj 就是响应式数据。
obj.text = 'hello vue3'

但很明显,以上面的代码来看,我们还做不到这一点,因为 obj 是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。

4.2 响应式数据的基本实现

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
在 ES2015之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。
在ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。
# 定义
// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = { text: 'hello world' }

// 对原始数据的代理
const obj = new Proxy(data, {

get(target, key) {// 拦截读取操作
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},

set(target, key, newVal) {// 拦截设置操作
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})

# 测试
// 副作用函数
function effect() {
document.body.innerText = obj.text
}

// 执行副作用函数,触发读取
effect()

// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3' // 设置会被拦截,从桶中依次取出 fn执行
}, 1000)

4.3 设计一个完善的响应系统

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
上面,我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。


# 匿名函数
# 重新定义 effect()
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}

# 修改 Proxy
const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到“桶”中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})

# 测试
// 一个匿名的副作用函数
effect(() => {
document.body.innerText = obj.text
})


# 测试2 => 不存在属性,调用多次问题
// 匿名副作用函数
effect(() => {
alert('effect run') // 会调用 2 次(匿名函数调用一次,1秒后拦截调用一次)
document.body.innerText = obj.text
})

setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)


测试2 => 不存在属性,调用多次问题
原因:没有在副作用函数与被操作的目标字段之间建立明确的联系。
(那么我们下面就来建立联系)

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
# 三个角色
effect(function effectFn() {
document.body.innerText = obj.text
})
在这段代码中存在三个角色:
● 被操作(读取)的代理对象 obj;
● 被操作(读取)的字段名 text;
● 使用 effect 函数注册的副作用函数 effectFn。


# 树形关系表示
如果
- 用 target 来表示一个代理对象所代理的原始对象,
- 用 key 来表示被操作的字段名,
- 用 effectFn 来表示被注册的副作用函数,
那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn


# 举例1 如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})

target
└── text
└── effectFn1
└── effectFn2

# 举例2 如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})

target
└── text1
└── effectFn
└── text2
└── effectFn

# 举例3 如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})

target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2

关系解决了,下面来进行代码的改造:

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
// 存储副作用函数的桶(WeakMap 代替 Set )
const bucket = new WeakMap()



const obj = new Proxy(data, {

get(target, key) {// 拦截读取操作
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]

// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}

// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)

// 返回属性值
return target[key]
},

set(target, key, newVal) {// 拦截设置操作
// 设置属性值
target[key] = newVal
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return

// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})


WeakMap<k, v> 弱引用,若 v没有任何引用,则会被回收。
Map<k, v> 强引用,若 v没有任何引用,也不会被回收,即使OOM。

# 测试一下
// 匿名副作用函数
effect(() => {
alert('effect run') // 会调用 1 次(匿名函数调用一次,1秒后拦截不会调用,因为set拦截的时候,根据key ‘notExist’ 获取不到effects)
document.body.innerText = obj.text
})

setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)

vue---WeakMap、Map 和 Set 之间的关系


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
# 在 get 拦截函数内调用 track 函数追踪变化
# 在 set 拦截函数内调用 trigger 函数触发变化

const obj = new Proxy(data, {

get(target, key) {// 拦截读取操作
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},

set(target, key, newVal) {// 拦截设置操作
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return

let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}

let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}

4.4 分支切换与 cleanup

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
# 触发3次
const data = {ok: true, text: 'hello world'}

// 匿名副作用函数
effect(() => {
alert("effect run")// 弹出3次(执行1次,拦截ok 1次,拦截text 1次)
document.body.innerText = obj.ok ? obj.text : 'not';
})

obj.ok = false;// 触发更新
obj.text = 'hello vue3';// 触发更新 ===> 即使 ok=false,也会重新执行


# 修改代码,使之触发2次
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return

let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}

let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把当前激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps) // push 数组尾插
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
}

/*
2023-07-05 07:59:32 昨天没看明白,今儿从新梳理一下,理解了。
ok
effectFn
deps [okSet, textSet]

okSet [effectFn] => [] => [effectFn] // obj.ok = false; 重新触发匿名函数
okSetRun [effectFn]

text
effectFn
deps [okSet, textSet]
textSet [effectFn] => []
textSetRun [effectFn]
*/

4.5 嵌套的 effect 与 effect 栈

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
# effect 可嵌套
effectFn1 嵌套 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ })
/* ... */
})

# vue:可嵌套 effect
那么,什么场景下会出现嵌套的 effect 呢?
拿 Vue.js 来说,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的。
# vue组件的 effect
// Foo 组件
const Foo = {
render() {
return /* ... */
}
}

在一个 effect 中执行 Foo 组件的渲染函数:
effect(() => {
Foo.render()
})

# vue组件的 可嵌套effect
当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}

effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})

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
# 009_4.5_响应式 v09 嵌套 错误演示.html
# 测试
// 全局变量
let temp1, temp2

// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')

effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
console.log("开始修改 -----------------");
obj.foo = false;// 触发 effectFn2(希望触发 effectFn1 再触发 effectFn2)

# 分析
2023-07-05 08:20:12

foo
effectFn1
deps [barSet, fooSet]
fooSet [effectFn2]

bar
effectFn2
deps [barSet, fooSet]
barSet [effectFn2]

foo get拦截,放入的 activeEffect是 effectFn2
为了解决这个问题,我们需要一个副作用函数栈 effectStack。
在副作用函数执行时,将当前副作用函数压入栈中(push数组尾插),待副作用函数执行完毕后将其从栈中弹出(pop数组尾删),并始终让 activeEffect 指向栈顶(数组尾部)的副作用函数。

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
# 010_4.5_响应式 v10 嵌套 栈.html
# 修改 => 添加栈
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// effect 栈
const effectStack = []

// effect 函数用于注册副作用函数
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作 => xxxSet中移除此 effectFn
cleanup(effectFn)

// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
effectStack.push(effectFn)// 压栈(push 尾插)
fn()
effectStack.pop()// 出栈(pop 尾删)
activeEffect = effectStack[effectStack.length - 1]// activeEffect 指向尾部
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

/*---------------------------------*/
# 测试
// 全局变量
let temp1, temp2

// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')

effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
console.log("开始修改 -----------------");
obj.foo = false;// 触发 effectFn1,并触发 effectFn2


# 分析
2023-07-05 08:36:18

foo
Fn1
deps [fooSet]
fooSet [Fn1]

bar
Fn2
deps [barSet]
barSet [Fn2]

stack [] => [Fn1] => [Fn1, Fn2] => [Fn1] => []

4.6 避免无限递归循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 011_4.6_响应式 v11 无限递归 错误演示.html
# 测试
const data = { foo: 1 }

effect(() => obj.foo++)// 无限触发 Fn get set => Fn get set => ...

# 结果
Uncaught RangeError: Maximum call stack size exceeded
=> 未捕获的范围错误:超过了最大调用堆栈大小

# 分析
set 会触发 Fn,Fn触发 get => +1 set,...
Fn => get => +1 set
=> Fn => get => +1 set
=> Fn => get => +1 set
=> ...

无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是 activeEffect。
基于此,我们可以在 trigger 动作发生时增加守卫条件:
=> 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 012_4.6_响应式 v12 无限递归 守卫条件.html
# trigger => 增加守卫条件
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}

# 测试
const data = { foo: 1 }

effect(() => obj.foo++)// 只触发1次 Fn get set

【日期标记】2023-07-05 09:03:51 以上同步完成

4.7 调度执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 013_4.7_响应式 v13 调度 顺序问题 正常.html
# 测试
const data = {foo: 1}

effect(() => {
console.log(obj.foo)// get=>1
})

obj.foo++// get set get=>2

console.log('结束了')


依次输出 1 2 结束了

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
# 014_4.7_响应式 v14 调度 顺序问题 改变.html
# 修改 effect => 支持 options参数
// effect 函数用于注册副作用函数
function effect(fn, options = {}) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作 => xxxSet中移除此 effectFn
cleanup(effectFn)

// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
effectStack.push(effectFn)// 压栈(push 尾插)
fn()
effectStack.pop()// 出栈(pop 尾删)
activeEffect = effectStack[effectStack.length - 1]// activeEffect 指向尾部
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

# 修改 trigger => options 调度处理
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
effectFn.options.scheduler(effectFn)
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}

# 测试
const data = {foo: 1}

effect(
// fn
() => {
console.log(obj.foo)// get=>1
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(effectFn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(effectFn)
}
}
)

obj.foo++// get set get=>2

console.log('结束了')

调度 => 依次输出 1 结束了 2

1
2
3
4
5
6
7
8
9
10
11
12
# 015_4.7_响应式 v15 调度 123 有中间状态.html
# 测试
const data = {foo: 1}

effect(() => {
console.log(obj.foo)// get=>1
})

obj.foo++// get set get=>2
obj.foo++// get set get=>3

依次输出 1 2 3

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
# 016_4.7_响应式 v16 调度 13 无中间状态.html
# 增加 flushJob
// 定义一个任务队列 => set去重,只会执行一次
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false

function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true,代表正在刷新
isFlushing = true

// 在微任务队列中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}

# 测试
const data = {foo: 1}

effect(() => {
console.log(obj.foo)// get=>1
}, {
scheduler(effectFn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(effectFn)// jobQueue为 Set 类型,会去重 effectFn
// 调用 flushJob 刷新队列
flushJob()
}
})


obj.foo++// get set get=>2
obj.foo++// get set get=>3

依次输出 1 3(中间状态不输出,因为 JobQueue 是 Set去重了)
【日期标记】2023-07-05 10:00:03 以上同步完成

4.8 计算属性 computed 与 lazy

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
# 017_4.8_响应式 v17 lazy 无返回值.html
# 修改 effect => 添加 lazy 选项
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)// 压栈(push 尾插)
fn()
effectStack.pop()// 出栈(pop 尾删)
activeEffect = effectStack[effectStack.length - 1]// activeEffect 指向尾部
}
effectFn.options = options
effectFn.deps = []

// 只有非 lazy 的时候,才执行
if (!options.lazy) {
// 执行副作用函数
effectFn()
}
return effectFn;
}

const data = {foo: 1}

# 立即执行
// 这个函数会立即执行
effect(() => {
console.log(obj.foo)// get=>1
})

# 手动执行
// 指定了 lazy 选项,这个函数不会立即执行
const effectFn = effect(() => {
console.log(obj.foo)// get=>1
}, {lazy: true});

// 手动执行副作用函数
effectFn()

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
# 018_4.8_响应式 v18 lazy 有返回值.html
# 修改 effect => 获取 fn() 返回值
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const res = fn()// 接收返回值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]

// 将 res 作为 effectFn 的返回值
return res
}
effectFn.options = options
effectFn.deps = []

// 只有非 lazy 的时候,才执行
if (!options.lazy) {
// 执行副作用函数
effectFn()
}
return effectFn;
}


# 测试
const data = {foo: 1, bar: 2}

const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,// 触发两次 get拦截
{lazy: true}
);

// value 是 getter 的返回值
const value = effectFn()
console.log(value)// 3

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
# 019_4.8_响应式 v19 computed 重新计算问题.html
# computed 初体验
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})

const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}

return obj
}

/*---------------------------------*/

# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

console.log(sum.value)// get get 3
console.log(sum.value)// get get 3
console.log(sum.value)// get get 3

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
# 020_4.8_响应式 v20 computed dirty解决重新计算.html
# computed => 新增标识 dirty
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true

const effectFn = effect(getter, {
lazy: true
})

const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}

return obj
}

/*---------------------------------*/

# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

console.log(sum.value)// get get 3
console.log(sum.value)// 3
console.log(sum.value)// 3

1
2
3
4
5
6
7
8
9
10
11
# 021_4.8_响应式 v21 computed 更新不变问题.html
# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

console.log(sum.value)// get get 3
console.log(sum.value)// 3
obj.foo++// get set => get get
console.log('---')
console.log(sum.value)// 3 ===> 仍为 3,应该为 4

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
# 022_4.8_响应式 v22 computed scheduler解决更新不变.html
# computed => 新增 scheduler dirty=true
function computed(getter) {
let value
let dirty = true

const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true
}
})

const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}

return obj
}

/*---------------------------------*/

# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

console.log(sum.value)// get get 3
console.log(sum.value)// 3
obj.foo++// get set(scheduler 中只会设置 dirty = true,而未设置执行 effectFn)
console.log('---')
console.log(sum.value)// get get 4

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
# 023_4.8_响应式 v23 computed 内层变化不影响外层.html
# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

effect(() => {
// 在该副作用函数中读取 sum.value
console.log(sum.value)// get get 3
})

console.log('---')

obj.foo++// get set(scheduler 中只会设置 dirty = true,而未设置执行 effectFn)
console.log('---')
console.log(sum.value)// get get 4

# 分析
2023-07-05 15:12:17

上面其实是 effect嵌套。

内层的 obj.foo++ 变化,不会使外层 effect重新执行。
(scheduler 中只会设置 dirty = true,而未设置执行 effectFn)

=> 内层属性 obj.foo++ 变化,没有使 sum 所在的外层 effect 重新执行。

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
# 024_4.8_响应式 v24 computed 内层变化影响外层.html
# computed => 新增 trigger track
function computed(getter) {
let value
let dirty = true

const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}

}
})

/*
测试 变量引用问题 => 关于 trigger(obj, 'value')
// console.log('------' + aaa)
const aaaFn = () => {
console.log('------' + aaa)
}
// let aaa;
const aaa = '';
aaaFn()
*/

const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}

return obj
}


# 测试
const data = {foo: 1, bar: 2}

const sum = computed(() => obj.foo + obj.bar)

effect(() => {
// 在该副作用函数中读取 sum.value
console.log(sum.value)// get get 3
})

console.log('---')

obj.foo++// get set => get get 4
console.log('---')
console.log(sum.value)// 4

# 分析
2023-07-05 15:12:58

上面其实是 effect嵌套。

内层的 obj.foo++ 变化,会触发 scheduler,从而触发到 外层 effect 重新执行。

=> 内层属性 obj.foo++ 变化,会使 sum 所在的外层 effect 重新执行。

4.9 watch 的实现原理

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。

实际上,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项。

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
# 025_4.9_响应式 v25 watch 固定属性.html
# watch函数
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(obj, () => {
console.log('数据变化了')
})

obj.foo++// get get set 数据变化了

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
# 026_4.9_响应式 v26 watch 任意属性.html
# watch => 任意属性
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}

function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen)
}

return value
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(obj, () => {
console.log('数据变化了')
})

obj.foo++// get get set 数据变化了

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
# 027_4.9_响应式 v27 watch getter函数.html
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}

effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb()
}
}
)
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)

obj.foo++// get get set 'obj.foo 的值变了'

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
# 028_4.9_响应式 v28 watch 新旧值.html
# watch => newValue, oldValue
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// lazy 需手动调用副作用函数,这里拿到的是 getter() 返回值,也就是旧值
oldValue = effectFn()
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue)// 2, 1
}
)

obj.foo++

4.10 立即执行的 watch 与回调执行时机

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
# 029_4.10_响应式 v29 watch immediate立即执行.html
# watch => options.immediate
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb, options = {}) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue

// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}

// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)

if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
// lazy 需手动调用副作用函数,这里拿到的是 getter() 返回值,也就是旧值
oldValue = effectFn()
}
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})

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
# 030_4.10_响应式 v30 watch flush执行时机.html
除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机,
例如在 Vue.js 3 中使用 flush 选项来指定:

# watch => options.flush
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 callback
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}

let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}

// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)

if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
// lazy 需手动调用副作用函数,这里拿到的是 getter() 返回值,也就是旧值
oldValue = effectFn()
}
}

/*---------------------------------*/

# 测试
const data = {foo: 1}

watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
flush: 'post' // 还可以指定为 'pre' | 'sync'
})


flush 本质上是在指定调度函数的执行时机。

如以上代码所示,我们修改了调度器函数 scheduler 的实现方式,在调度器函数内检测 options.flush 的值是否为 post,
如果是,则将 job 函数放到微任务队列中,从而实现异步延迟执行;否则直接执行 job 函数,这本质上相当于 'sync' 的实现机制,即同步执行。
对于 options.flush 的值为'pre' 的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机,其中 'pre''post' 原本的语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控制回调函数的更新时机。

4.12 总结

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
# 响应式数据的拦截机制
- 响应式数据的实现依赖于对读取和设置操作的拦截。
- 当读取操作发生时,将当前执行的副作用函数存储到"桶"中。
- 当设置操作发生时,从"桶"中取出副作用函数并执行。

# WeakMap 和 Map 的区别
- WeakMap 配合 Map 构建了用于存储副作用函数的"桶"结构。
- WeakMap 是弱引用的,不影响垃圾回收器的工作。
- WeakMap 不会阻止垃圾回收器回收没有引用关系的对象。

# 解决分支切换导致的冗余副作用问题
- 在副作用函数重新执行之前,清除上一次建立的响应联系。
- 当副作用函数重新执行后,再次建立新的响应联系,解决冗余副作用问题。

# 解决遍历 Set 数据结构导致的无限循环问题
- 建立一个新的 Set 数据结构用于遍历,解决重复访问问题。

# 解决嵌套副作用函数的问题
- 使用副作用函数栈存储不同的副作用函数。
- 读取响应式数据时,只与栈顶的副作用函数建立响应联系。

# 解决副作用函数无限递归调用导致的栈溢出问题
- 当 trigger 触发执行的副作用函数与当前执行的副作用函数相同时,不触发执行。

# 实现可调度的响应系统
- 通过为 effect 函数增加第二个选项参数,允许使用调度器进行任务调度。
- 使用调度器实现任务去重,通过微任务队列对任务进行缓存。

# 计算属性的实现原理
- 计算属性是懒执行的副作用函数,通过 lazy 选项进行懒执行。
- 读取计算属性的值时,手动执行副作用函数。
- 当计算属性依赖的响应式数据发生变化时,将 dirty 标记设置为 true,重新计算值。

# watch 的实现原理
- watch 利用副作用函数的可调度性实现。
- 创建一个 effect,当其依赖的响应式数据发生变化时,执行调度器函数。
- 调度器函数执行用户注册的回调函数。
- 通过 immediate 选项实现立即执行回调。
- 通过 flush 选项控制回调函数的执行时机。

第5章 非原始值的响应式方案

1
2
3
4
5
6
7
8
9
什么。实际上,实现响应式数据要比想象中难很多,并不是像上一章讲述的那样,单纯地拦截get/set 操作即可。
举例来说,
如何拦截 for...in 循环?
track 函数如何追踪拦截到的 for...in 循环?
类似的问题还有很多。

除此之外,我们还应该考虑如何对数组进行代理。
Vue.js 3 还支持集合类型,如Map、Set、WeakMap 以及 WeakSet 等,那么应该如何对集合类型进行代理呢?
实际上,想要实现完善的响应式数据,我们需要深入语言规范。

5.1 理解 Proxy 和 Reflect

Proxy

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
# 基本语义
obj.foo // 读取属性 foo 的值
obj.foo++ // 读取和设置属性 foo 的值

# Proxy 拦截 => get / set [属性]
类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作。
既然是基本操作,那么它就可以使用 Proxy 拦截:
const p = new Proxy(obj, {
// 拦截读取属性操作
get() { /*...*/ },

// 拦截设置属性操作
set() { /*...*/ }
})

# Proxy 拦截 => apply [函数]
在 JavaScript 的世界里,万物皆对象。
例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:
const fn = (name) => {
console.log('我是:', name)
}

// 使用 apply 拦截函数调用
const proxyFn = new Proxy(fn, {
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})

proxyFn('hcy') // 输出:'我是:hcy'

# 复合操作
实际上,调用一个对象下的方法,是由两个基本语义组成的。
第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。
第二个基本语义是函数调用,即通过 get 得到 obj.fn 的值后再调用它,也就是我们上面说到的apply。
理解 Proxy 只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或 Map、Set 等数据类型的代理时,都利用了 Proxy 的这个特点。
obj.fn()

Reflect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Reflect
理解了 Proxy,我们再来讨论 Reflect。
Reflect 是一个全局对象,其下有许多方法,例如:
Reflect.get()
Reflect.set()
Reflect.apply()
// ...

你可能已经注意到了,Reflect 下的方法与 Proxy 的拦截器方法名字相同,其实这不是偶然。
任何在 Proxy 的拦截器中能够找到的方法,都能够在Reflect 中找到同名函数。

# Reflect.get
const obj = { foo: 1 }

// 直接读取
console.log(obj.foo) // 1

// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1

# Reflect.get => 第三个参数 receiver(函数调用过程中的 this)
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 2(可不是 1 喔)

访问器属性的 this 问题

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
# 031_5.1_响应式 v31 Proxy 属性访问器this.html
# 代码
const obj = {
foo: 1,
get bar() {
// bar 属性是一个访问器属性,它返回了 this.foo 属性的值。
return this.foo
}
}

const p = new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})

effect(() => {
console.log(p.bar) // 1
})

p.foo++// 没有重新触发 Fn

# 分析
p.foo 触发 get拦截,get拦截的 target[key],其中target是原始对象obj,key是属性名称'foo'
obj 的 bar属性访问器中的 this 就是 get拦截中的 target,即 obj。
所以,obj bar属性访问器的 this.foo 等同于 obj.foo。
所以等同于
effect(() => {
// obj 是原始数据,不是代理对象,这样的访问不能够建立响应联系
console.log(obj.foo) // 1
})

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
# 032_5.1_响应式 v32 Proxy 属性访问器this receiver.html
# 修改 Proxy => Reflect.get receiver
const p = new Proxy(obj, {
// 拦截读取操作,接收第三个参数 receiver
get(target, key, receiver) {
track(target, key)
// 使用 Reflect.get 返回读取到的属性值
return Reflect.get(target, key, receiver)
},
// ...
})

# 解释 receiver
代理对象的 get 拦截函数接收第三个参数 receiver,它代表谁在读取属性,例如:
p.bar // 代理对象 p 在读取 bar 属性
当我们使用代理对象 p 访问 bar 属性时,那么 receiver 就是 p,你可以把它简单地理解为函数调用中的 this。

# 分析
此时
const obj = {
foo: 1,
get bar() {
// 现在这里的 this 为 receiver,也就是代理对象 p
return this.foo
}
}

5.2 JavaScript 对象及 Proxy 的工作原理

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
如何区分一个对象是普通对象还是函数呢?
通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会。

# Proxy 对象部署的所有内部方法
[[GetPrototypeOf]] getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf
[[IsExtensible]] isExtensible
[[PreventExtensions]] preventExtensions
[[GetOwnProperty]] getOwnPropertyDescriptor
[[DefineOwnProperty]] defineProperty
[[HasProperty]] has
[[Get]] get
[[Set]] set
[[Delete]] deleteProperty
[[OwnPropertyKeys]] ownKeys
[[Call]] apply
[[Construct]] construct

# 033_5.2_响应式 v33 Proxy 删除属性.html
const obj = {foo: 1}

const p = new Proxy(obj, {
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key)
}
})

console.log(p.foo) // 1
delete p.foo
console.log(p.foo) // undefined


【日期标记】2023-07-06 09:18:36 以上同步完成

5.3 如何代理 Object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
一个普通对象的所有可能的读取操作。
● 访问属性:obj.foo。
● 判断对象或原型上是否存在给定的 key:key in obj。
● 使用 for...in 循环遍历对象:for (const key in obj){}。


# 034_5.2_响应式 v34 has in关键字.html
const obj = {foo: 1}
const p = new Proxy(obj, {
has(target, key) {
console.log(`has key=${key}`)// has key=foo
track(target, key)
return Reflect.has(target, key)
}
})

effect(() => {
'foo' in p // 将会建立依赖关系
})

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
# 035_5.3_响应式 v35 ownKeys for...in 新旧属性都触发.html
# ownKeys 拦截所有 key
# 拦截 ownKeys 操作即可间接拦截 for...in 循环。
# ownKeys 是获取对象的所有 key,这里不是拦截某一个 key,所以用 ITERATE_KEY。

# Proxy => ownKeys ITERATE_KEY
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {// 拦截设置操作
console.log("set")
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
// 把副作用函数从桶里取出并执行
trigger(target, key)
return res
},

ownKeys(target) {
console.log('ownKeys')
// 将副作用函数与 ITERATE_KEY 关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})

# trigger => ITERATE_KEY
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const iterateEffects = depsMap.get(ITERATE_KEY)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
iterateEffects && iterateEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
effectFn.options.scheduler(effectFn)
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}

/*---------------------------------*/

# 测试
const obj = {foo: 1}

effect(() => {
// for...in 循环
for (const key in p) {
console.log(key) // foo
}
})

console.log('---')

p.bar = 2

# 分析
2023-07-06 11:27:15
p.bar = 2 导致第二次循环,没问题

p.foo = 2 非新增属性,也导致了第三次循环

# 关于 Symbol
1. 什么Symbol?
Symbol是ES6中新增的一种数据类型, 被划分到了基本数据类型中
基本数据类型: 字符串、数值、布尔、undefined、null、Symbol
引用数据类型: Object

2. Symbol的作用
用来表示一个独一无二的值

3. 格式
let xxx=Symbol(‘标识字符串’);

4. 为什么需要Symbol?
为了避免第三方框架的同名属性被覆盖
————————————————
原文链接:https://blog.csdn.net/darabiuz/article/details/121962153

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
# 036_5.3_响应式 v36 ownKeys for...in 仅新增属性触发.html

# Proxy set => 判断 SET ADD
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}

const p = new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {// 拦截设置操作
console.log("set")

// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD

// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)

// 将 type 作为第三个参数传递给 trigger 函数
trigger(target, key, type)

return res
},

ownKeys(target) {
console.log('ownKeys')
// 将副作用函数与 ITERATE_KEY 关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})


# trigger => type ADD 判断
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})

console.log(type, key)// 'ADD bar'
// 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
if (type === TriggerType.ADD) {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}

/*---------------------------------*/

# 测试
const obj = {foo: 1}

effect(() => {
// for...in 循环
for (const key in p) {
console.log(key) // ownKeys foo
}
})

console.log('---')
p.bar = 2// set => 'ADD bar' ownKeys foo bar

console.log('---')
p.foo = 2// set 'SET foo'(非 ADD,不触发 ownKeys Fn)

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
# 037_5.3_响应式 v37 ownKeys for...in 删除属性触发.html
# Proxy => deleteProperty
const p = new Proxy(obj, {
// ...
deleteProperty(target, key) {
console.log('deleteProperty')
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
// 使用 Reflect.deleteProperty 完成属性的删除
const res = Reflect.deleteProperty(target, key)

if (res && hadKey) {
// 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
trigger(target, key, TriggerType.DELETE)
}

return res
}
})

# trigger => 兼容 DELETE
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})

console.log(type, key)// 'ADD bar' 'DELETE foo'
// 只有当操作类型为 ADD 或 DELETE 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
if (type === TriggerType.ADD || type === TriggerType.DELETE) {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}


/*---------------------------------*/

# 测试
const obj = {foo: 1}

effect(() => {
// for...in 循环
for (const key in p) {
console.log(key) // ownKeys foo
}
})

console.log('---')
p.bar = 2// set => 'ADD bar' ownKeys foo bar

console.log('---')
p.foo = 2// set 'SET foo'(非 ADD,不触发 ownKeys Fn)

console.log('---')
delete p.foo// deleteProperty 'DELETE foo' ownKeys bar

5.4 合理地触发响应

1
2
3
4
5
6
7
8
# 038_5.4_响应式 v38 值未变也触发.html
const obj = {foo: 1}

effect(() => {
console.log(p.foo)// get=>1
})

p.foo = 1// set 'SET foo' get=>1(值未变也触发)

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
# 039_5.4_响应式 v39 值变了才触发.html
# Proxy set => 新旧值全等比较
const p = new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {// 拦截设置操作
console.log("set")

const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target, key, newVal, receiver)

// 比较新值与旧值,只要当不全等的时候才触发响应
if (oldVal !== newVal) {
trigger(target, key, type)
}

return res
}
// ...
})

/*---------------------------------*/

# 测试
const obj = {foo: 1}

effect(() => {
console.log(p.foo)// get=>1
})

p.foo = 1// set(值未变不触发)
p.foo = 2// set 'SET foo' get=>2(值变了才触发)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 040_5.4_响应式 v40 NaN问题.html

# 测试
const obj = {foo: NaN}

// NaN 与谁比较全等都是 false
console.log('NaN === NaN ', NaN === NaN)// false
console.log('NaN !== NaN ', NaN !== NaN)// true

effect(() => {
console.log(p.foo)// get=>NaN
})

p.foo = NaN// set 'SET foo' get=>NaN(仍然会触发响应,因为 NaN !== NaN 为 true
p.foo = 1// set 'SET foo' get=>1

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
# 041_5.4_响应式 v41 解决NaN问题.html
# Proxy set => 新旧值全等比较 && 新旧值自己全等比较(解决NaN问题)
const p = new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {// 拦截设置操作
console.log("set")

const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target, key, newVal, receiver)

// 比较新值与旧值,只有当它们不全等,并且任意一个不为 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}

return res
},
// ...
})

/*---------------------------------*/

# 测试
const obj = {foo: NaN}

console.log('NaN === NaN ', NaN === NaN)// false
console.log('NaN !== NaN ', NaN !== NaN)// true

effect(() => {
console.log(p.foo)// get=>NaN
})

p.foo = NaN// set(NaN问题解决)
p.foo = 1// set 'SET foo' get=>1

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
# 042_5.4_响应式 v42 代理原型问题.html
# Proxy => 封装 reactive 函数
function reactive(obj) {
return new Proxy(obj, {

get(target, key, receiver) {// 拦截读取操作,接收第三个参数 receiver
track(target, key)
return Reflect.get(target, key, receiver)
},

// 第一次拦截 child => target=原始对象obj,receiver是代理对象child
// 第二次拦截 parent => target=原始对象proto,receiver仍是代理对象child
set(target, key, newVal, receiver) {// 拦截设置操作
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target, key, newVal, receiver)

// 比较新值与旧值,只有当它们不全等,并且任意一个不为 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}

return res
},
// ...
})
}

/*---------------------------------*/

# 测试
const obj = {}
const proto = {bar: 1}
const child = reactive(obj)
const parent = reactive(proto)
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 会导致副作用函数重新执行两次

# 分析
2023-07-06 15:16:50

child.bar 访问
首先被第一次 get拦截(此次拦截的是 child),随之访问 obj.bar,
因为 obj 没有 bar 属性,所以访问原型 parent.bar,
进行第二次 get拦截(此次拦截的是 parent),随之访问 proto.bar。

child.bar = 2 设置
同理,访问 set 也是如此。
// 第一次拦截 child => target=原始对象obj,receiver是代理对象child
// 第二次拦截 parent => target=原始对象proto,receiver仍是代理对象child

由于我们最初设置的是 child.bar 的值,所以无论在什么情况下,receiver 都是child,而 target 则是变化的。

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
# 043_5.4_响应式 v43 解决代理原型问题.html
# Proxy.get => 'raw' 处理
# Proxy.set => 设置原型处理 target === receiver.raw
function reactive(obj) {
return new Proxy(obj, {

get(target, key, receiver) {// 拦截读取操作,接收第三个参数 receiver
// 代理对象可以通过 raw 属性访问原始数据
if (key === 'raw') {
return target;
}
track(target, key)
return Reflect.get(target, key, receiver)
},

// 第一次拦截 child => target=原始对象obj,receiver是代理对象child
// 第二次拦截 parent => target=原始对象proto,receiver仍是代理对象child
set(target, key, newVal, receiver) {// 拦截设置操作
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target, key, newVal, receiver)

// target === receiver.raw 说明 receiver 就是 target 的代理对象
// 第一次拦截通过,第二次拦截为 false
if (target === receiver.raw) {
// 比较新值与旧值,只有当它们不全等,并且任意一个不为 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}

return res
},
// ...
})
}

/*---------------------------------*/

# 测试
const obj = {}
const proto = {bar: 1}
const child = reactive(obj)
const parent = reactive(proto)
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)

console.log('child.raw === obj ', child.raw === obj)// child.raw === obj true
console.log('parent.raw === proto ', parent.raw === proto)// parent.raw === proto true

effect(() => {
console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 只会触发1次副作用函数


【日期标记】2023-07-06 15:43:25 以上同步完成

5.5 浅响应与深响应

1
2
3
4
5
6
7
8
# 044_5.5_响应式 v44 浅响应.html
const obj = reactive({foo: {bar: 1}})

effect(() => {
console.log(obj.foo.bar)// 1
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2

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
# 045_5.5_响应式 v45 深响应.html
# 修改 Proxy.get
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {// 拦截读取操作,接收第三个参数 receiver
// 代理对象可以通过 raw 属性访问原始数据
if (key === 'raw') {
return target;
}
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)

// 得到原始值结果
const res = Reflect.get(target, key, receiver)
if (typeof res === 'object' && res !== null) {
// 调用 reactive 将结果包装成响应式数据并返回
return reactive(res)
}
return res
},
// ...
})
}

/*---------------------------------*/

# 测试
const obj = reactive({foo: {bar: 1}})

effect(() => {
console.log(obj.foo.bar)// 1 2
})
// 修改 obj.foo.bar 的值,可以触发响应了
obj.foo.bar = 2

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
# 046_5.5_响应式 v46 浅深响应.html
# 封装函数
function reactive(obj) {
return createReactive(obj)
}

function shallowReactive(obj) {
return createReactive(obj, true)
}

// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target;
}
track(target, key)

// 得到原始值结果
const res = Reflect.get(target, key, receiver)

// 如果是浅响应,则直接返回原始值
if (isShallow) {
return res
}

if (typeof res === 'object' && res !== null) {
// 调用 reactive 将结果包装成响应式数据并返回
return reactive(res)
}

return res
},
// ...
})
}

/*---------------------------------*/

# 测试
const obj = shallowReactive({foo: {bar: 1}})// 1 2
// const obj = reactive({foo: {bar: 1}})// 1 2 3

effect(() => {
console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = {bar: 2}
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3

【日期标记】2023-07-10 07:18:09 以上同步完成

5.6 只读和浅只读

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
# 047_5.5_响应式 v47 readonly只读.html
# readonly
function readonly(obj) {
return createReactive(obj, false, true)
}

function shallowReadonly(obj) {
return createReactive(obj, true, true)
}

// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
// 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target;
}
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
if (!isReadonly) {
track(target, key)
}

const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
// 调用 reactive 将结果包装成响应式数据并返回
// 如果数据为只读,则调用 readonly 对值进行包装
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
set(target, key, newVal, receiver) {// 拦截设置操作
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}

const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
},
// ...
deleteProperty(target, key) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}

const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const res = Reflect.deleteProperty(target, key)
if (res && hadKey) {
trigger(target, key, TriggerType.DELETE)
}
return res
}
})
}

/*---------------------------------*/

# 测试
// CASE1 只读警告:set delete 都警告。
// const obj = readonly({ foo: 1 })
// obj.foo = 2// 尝试修改数据,会得到警告 "属性 foo 是只读的"

// CASE2 建立联系:get 只读不track(不建立联系)
// const obj = readonly({foo: 1})
// effect(() => {
// console.log(obj.foo)// 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
// })

// CASE3 深只读:object 只读返回 readonly(res)
const obj = readonly({foo: {bar: 1}})
obj.foo.bar = 2 // 深只读:不能修改

5.7 代理数组

1
2
3
4
5
6
在 JavaScript 中,数组只是一个特殊的对象而已。

数组对象除了[[DefineOwnProperty]] 这个内部方法之外,其他内部方法的逻辑都与常规对象相同。

数组本身也是对象,只不过它是异质对象罢了,它与常规对象的差异并不大。
因此,大部分用来代理常规对象的代码对于数组也是生效的。

5.7.1 数组的索引与 length

1
2
3
4
5
6
7
8
9
10
11
12
13
# 048_5.7.1_响应式 v48 数组 index设置可触发.html
const arr = reactive(['foo'])

effect(() => {
console.log(arr[0]) // 'foo'
})

arr[0] = 'bar' // 能够触发响应,从而输出 'bar'



数组本身也是对象,只不过它是异质对象罢了,它与常规对象的差异并不大。
因此,大部分用来代理常规对象的代码对于数组也是生效的。

1
2
3
4
5
6
7
8
# 049_5.7.1_响应式 v49 数组 length不会重新触发.html
const arr = reactive(['foo']) // 数组的原长度为 1

effect(() => {
console.log(arr.length) // 1
})

arr[1] = 'bar'// 设置索引 1 的值,会导致数组的长度变为 2(不会重新触发输出)

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
# 050_5.7.1_响应式 v50 数组 解决length不会重新触发.html
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}

const oldVal = target[key]

const type = Array.isArray(target)
// 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,(target为数组时,key为index下标)
// 如果是,则视作 SET 操作,否则是 ADD 操作
? Number(key) < target.length ? TriggerType.SET : TriggerType.ADD
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
: Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD

const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
},
// ...
})
}


function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})

console.log(type, key)// 'ADD bar' 'DELETE foo'

// 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
if (type === TriggerType.ADD && Array.isArray(target)) {
// 取出与 length 相关联的副作用函数
const lengthEffects = depsMap.get('length')
// 将这些副作用函数添加到 effectsToRun 中,待执行
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

if (type === TriggerType.ADD || type === TriggerType.DELETE) {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}

/*---------------------------------*/

const arr = reactive(['foo']) // 数组的原长度为 1

effect(() => {
console.log(arr.length) // 1
})

// 设置索引 1 的值,会导致数组的长度变为 2(可以重新触发,输出 2)
arr[1] = 'bar'

# 分析
Proxy.set 添加数组判断
trigger ADD 取出 length 副作用函数

1
2
3
4
5
6
7
8
9
# 051_5.7.1_响应式 v51 数组 length属性设置问题.html
const arr = reactive(['foo'])

effect(() => {
// 访问数组的第 0 个元素
console.log(arr[0]) // foo
})
// 将数组的长度修改为 0,导致第 0 个元素被删除,不会触发响应(错误)
arr.length = 0

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
# 052_5.7.1_响应式 v52 数组 解决length属性设置问题.html
# Proxy.set => 新增 newVal 参数
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// ...
set(target, key, newVal, receiver) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}

const oldVal = target[key]
const type = Array.isArray(target)
// 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,(target为数组时,key为index下标)
// 如果是,则视作 SET 操作,否则是 ADD 操作
? Number(key) < target.length ? TriggerType.SET : TriggerType.ADD
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
: Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD

const res = Reflect.set(target, key, newVal, receiver)

if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
// 增加第四个参数,即触发响应的新值
trigger(target, key, type, newVal)
}
}

return res
},
// ...
})
}

function trigger(target, key, type, newVal) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)

const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})

console.log(type, key)

// 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
if (type === TriggerType.ADD && Array.isArray(target)) {
const lengthEffects = depsMap.get('length')
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

// 如果操作目标是数组,并且修改了数组的 length 属性
if (Array.isArray(target) && key === 'length') {
// 对于索引大于或等于新的 length 值的元素,
// 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
depsMap.forEach((effects, key) => {
// arr.length=0; 这里就是取出 arr数组中index>=0 index相关的所有副作用函数
if (key >= newVal) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
})
}
// ...
}

/*---------------------------------*/

# 测试
const arr = reactive(['foo'])

effect(() => {
// 访问数组的第 0 个元素
console.log(arr[0]) // foo
})
// 将数组的长度修改为 0,导致第 0 个元素被删除,会触发响应(输出 'undefined'
arr.length = 0

# 分析
2023-07-10 08:25:43

set trigger 增加第四个参数,newVal 也就是 arr.length=0 中的 0

trigger 判断数组 key为'length',取出 index>=newVal 的所有副作用函数

5.7.2 遍历数组

1
2
3
4
5
6
7
8
9
10
11
12
# 053_5.7.2_响应式 v53 数组 for...in.html

const arr = reactive(['foo'])

effect(() => {
for (const key in arr) {
console.log(key) // 0
}
})

// arr.length = 0;// 报错 'Uncaught TypeError: Cannot convert a Symbol value to a number'
arr[100] = 'bar';// 会重新触发,输出两个下标值 0 100

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
# 054_5.7.2_响应式 v54 数组 for...in length属性问题.html
# ownKeys 数组 'length'
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// ...
ownKeys(target) {
console.log('ownKeys')
// 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系
// 否则,与 ITERATE_KEY 关联
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
},
// ...
})
}

/*---------------------------------*/

# 测试
const arr = reactive(['foo'])

effect(() => {
for (const key in arr) {
console.log(key) // => 'ownKeys' 0
}
})

arr.length = 0;// 不输出,也不报错 => set get 'ADD length' ownKeys
arr[100] = 'bar';// 会重新触发,仅输出 100 => set get 'ADD 100' ownKeys 100


# 分析
ownKeys 数组,使用 'length' 当做 key

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
# 055_5.7.2_响应式 v55 数组 for...of.html


# // ------------------------------------------------ CASE 1
讲解了使用 for...in 遍历数组,接下来我们再看看使用 for...of 遍历数组的情况。
与 for...in 不同,for...of 是用来遍历可迭代对象(iterable object)的,因此我们需要先搞清楚什么是可迭代对象。
ES2015 为 JavaScript 定义了迭代协议(iteration protocol),它不是新的语法,而是一种协议。
具体来说,一个对象能否被迭代,取决于该对象或者该对象的原型是否实现了 @@iterator 方法。
这里的 @@[name] 标志在 ECMAScript 规范里用来代指 JavaScript 内建的 symbols 值,例如 @@iterator 指的就是 Symbol.iterator 这个值。
如果一个对象实现了 Symbol.iterator 方法,那么这个对象就是可以迭代的:
const obj = {
val: 0,
[Symbol.iterator]() {
return {
next() {
return {
value: obj.val++,
done: obj.val > 10 ? true : false
}
}
}
}
}
for (const value of obj) {
console.log(value) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}


# // ------------------------------------------------ CASE 2
数组内建了 Symbol.iterator 方法的实现,我们可以做一个实验:
const arr2 = [1, 2, 3, 4, 5]
// 获取并调用数组内建的迭代器方法
const itr = arr2[Symbol.iterator]()

console.log(itr.next()) // {value: 1, done: false}
console.log(itr.next()) // {value: 2, done: false}
console.log(itr.next()) // {value: 3, done: false}
console.log(itr.next()) // {value: 4, done: false}
console.log(itr.next()) // {value: 5, done: false}
console.log(itr.next()) // {value: undefined, done: true}


# // ------------------------------------------------ CASE 3
可以看到,我们能够通过将 Symbol.iterator 作为键,获取数组内建的迭代器方法。
然后手动执行迭代器的 next 函数,这样也可以得到期望的结果。
这也是默认情况下数组可以使用 for...of 遍历的原因:
const arr3 = [1, 2, 3, 4, 5]

for (const val of arr3) {
console.log(val) // 1, 2, 3, 4, 5
}


# // ------------------------------------------------ CASE 4
自定义的实现覆盖了数组内建的迭代器方法,但它仍然能够正常工作。
const arr4 = [1, 2, 3, 4, 5]

arr4[Symbol.iterator] = function () {
const target = this
const len = target.length
let index = 0

return {
next() {
return {
value: index < len ? target[index] : undefined,
done: index++ >= len
}
}
}
}


# // ------------------------------------------------ CASE 5
const arr5 = reactive([1, 2, 3, 4, 5])
effect(() => {
for (const val of arr5) {
console.log(val)// 1 2 3 4 5
}
})
arr5[1] = 'bar' // 能够触发响应 输出 1 'bar' 3 4 5
// arr5.length = 0 // 会报错 'Uncaught TypeError: Cannot convert a Symbol value to a number'


# // ------------------------------------------------ CASE 6
数组的 values 方法的返回值实际上就是数组内建的迭代器
console.log(Array.prototype.values === Array.prototype[Symbol.iterator]) // true


# // ------------------------------------------------ CASE 7
const arr7 = reactive([1, 2, 3, 4, 5])

effect(() => {
for (const val of arr7.values()) {
console.log(val)
}
})

arr7[1] = 'bar' // 能够触发响应
arr7.length = 0 // 能够触发响应

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
# 056_5.7.2_响应式 v56 数组 for...of length属性问题.html

# Proxy.get 判断 key 'symbol'
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target;
}

// 添加判断,如果 key 的类型是 symbol,则不进行追踪
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}

const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
// ...
})
}

/*---------------------------------*/

# 测试
console.log('------------- CASE 5')
const arr5 = reactive([1, 2, 3, 4, 5])
effect(() => {
for (const val of arr5) {
console.log(val)// 1 2 3 4 5
}
})
arr5[1] = 'bar' // 能够触发响应 输出 1 'bar' 3 4 5
arr5.length = 0 // 不会报错 无任何输出


# 分析
2023-07-10 09:21:49

typeof key==='symbol' 不进行 track 追踪

这样就不会报错了 arr5.length = 0


无论是使用 for...of 循环,还是调用 values 等方法,它们都会读取数组的 Symbol.iterator 属性。
该属性是一个 symbol 值,为了避免发生意外的错误,我们不应该建立响应联系,因此需要修改 get 拦截函数。

【日期标记】2023-07-10 09:39:20 以上同步完成

5.7.3 数组的查找方法

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
# 057_5.7.3_响应式 v57 数组 代理对象includes.html
# √ CASE 1 => 正常数值的 includes
console.log("------------------- CASE 1")
{
const arr = reactive([1, 2])

effect(() => {
console.log(arr.includes(1)) // 初始打印 true
})

arr[0] = 3 // 副作用函数重新执行,并打印 false
}

# × CASE 2 => 代理对象 includes
console.log("------------------- CASE 2")
{
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(arr[0])) // false
/*
2023-07-11 07:34:36

对于 arr.includes(arr[0])
其中,arr[0] 得到的是一个代理对象,而在 includes 方法内部也会通过 arr 访问数组元素,从而也得到一个代理对象,问题是这两个代理对象是不同的。
这是因为每次调用 reactive 函数时都会创建一个新的代理对象。
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}

function reactive(obj) {
// 每次调用 reactive 时,都会创建新的代理对象
return createReactive(obj)
}
*/
}

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
# 058_5.7.3_响应式 v58 数组 原始对象includes.html
# reactiveMap
// 定义一个 Map 实例,存储原始对象到代理对象的映射
// k=原始对象, v=代理对象
// k=obj, v=createReactive(obj)
const reactiveMap = new Map()

function reactive(obj) {
// 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象
const existingProxy = reactiveMap.get(obj)
if (existingProxy) return existingProxy

// 否则,创建新的代理对象
const proxy = createReactive(obj)
// 存储到 Map 中,从而避免重复创建
reactiveMap.set(obj, proxy)

return proxy
}

/*---------------------------------*/

# √ CASE 2 => 代理对象 includes
console.log("------------------- CASE 2")
{
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(arr[0])) // true
/*
// 定义一个 Map 实例,存储原始对象到代理对象的映射
// k=原始对象, v=代理对象
// k=obj, v=createReactive(obj)
const reactiveMap = new Map()
*/
}

# × CASE 3 => 原始对象 includes
console.log("------------------- CASE 3")
{
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(obj)) // false
/*
obj是原始对象,arr遍历时取的是代理对象,它们俩比较肯定是 false
*/
}

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
# 059_5.7.3_响应式 v59 数组 重写includes.html
# 重写 includes 方法
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
includes: function (...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
console.log(`代理数组 ${res}`)

if (res === false) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值
res = originMethod.apply(this.raw, args)
console.log(`原始数组 ${res}`)
}
// 返回最终结果
return res
}
}

# Proxy.get => 数组特殊处理
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target;
}

// 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上,
// 那么返回定义在 arrayInstrumentations 上的值
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}

if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}

const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
// ...
})
}

/*---------------------------------*/

# √ CASE 3 => 原始对象 includes
console.log("------------------- CASE 3")
{
const obj = {}
const arr = reactive([obj])

console.log(arr.includes(obj)) // true
/*
重写数组 includes 方法,先用代理数组 includes,不行的话,再用原始数组 includes。
*/
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 060_5.7.3_响应式 v60 数组 重写includes indexOf lastIndexOf.html
# 重写 includes indexOf lastIndexOf
const arrayInstrumentations = {};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function (...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)

if (res === false || res === -1) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
})

【日期标记】2023-07-11 08:04:26 以上同步完成

5.7.4 隐式修改数组长度的原型方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 061_5.7.4_响应式 v61 数组 push oom.html
const arr = reactive([])

// 第一个副作用函数
effect(() => {
arr.push(1)
})

// 第二个副作用函数
effect(() => {
arr.push(1)
})
/*
2023-07-11 08:12:03

报错了 'Uncaught RangeError: Maximum call stack size exceeded'

当调用数组.push 添加元素时,既会读取数组.length,也会设置数组.length。

因此,这两个副作用函数都会与 length 建立联系。
2push时 => 修改了length => 影响到了1,
1push时 => 修改了length => 又从而影响到了2,...
*/

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
# 062_5.7.4_响应式 v62 数组 重写push.html
# shouldTrack 标记
// 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
let shouldTrack = true;
// 重写数组的 push 方法
['push'].forEach(method => {
// 取得原始 push 方法
const originMethod = Array.prototype[method]
// 重写
arrayInstrumentations[method] = function (...args) {
// 在调用原始方法之前,禁止追踪
shouldTrack = false
// push 方法的默认行为
let res = originMethod.apply(this, args)
// 在调用原始方法之后,恢复原来的行为,即允许追踪
shouldTrack = true
return res
}
})

# track
function track(target, key) {
// 没有 activeEffect,直接 return
// 当禁止追踪时,直接返回
if (!activeEffect || !shouldTrack) return

// ...
}

/*---------------------------------*/

# 测试
const arr = reactive([])

// 第一个副作用函数
effect(() => {
arr.push(1)
})

// 第二个副作用函数
effect(() => {
arr.push(1)
})
/*
push之前不可以追踪,push之后可以追踪。
*/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 063_5.7.4_响应式 v63 数组 重写push pop shift unshift splice.html

// 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
let shouldTrack = true;
// 重写数组的 push、pop、shift、unshift 以及 splice 方法
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
// 取得原始 push 方法
const originMethod = Array.prototype[method]
// 重写
arrayInstrumentations[method] = function (...args) {
// 在调用原始方法之前,禁止追踪
shouldTrack = false
// push 方法的默认行为
let res = originMethod.apply(this, args)
// 在调用原始方法之后,恢复原来的行为,即允许追踪
shouldTrack = true
return res
}
})

【日期标记】2023-07-11 09:02:09 以上同步完成

5.8 代理 Set 和 Map

5.8.1 如何代理 Set 和 Map

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
# 064_5.8.1_响应式 v64 set 代理问题.html
# × CASE 1 => 代理 Set 访问 size 属性
console.log("------------------- CASE 1")
{
const s = new Set([1, 2, 3])
const p = new Proxy(s, {})

// console.log(p.size) // 报错 TypeError: Method get Set.prototype.size called on incompatible receiver
/*
size 属性应该是一个访问器属性,所以它作为方法被调用了。

Set.prototype.size 是一个访问器属性。
*/
}

# √ CASE 2 => 代理 Set 访问 size 属性
# × CASE 2 => 代理 Set 执行 delete 方法
console.log("------------------- CASE 2")
{
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === 'size') {
// 如果读取的是 size 属性
// 通过指定第三个参数 receiver 为原始对象 target 从而修复问题
return Reflect.get(target, key, target)
}
// 读取其他属性的默认行为
return Reflect.get(target, key, receiver)
}
})

console.log(s.size) // 3


// 调用 delete 方法删除值为 1 的元素
// p.delete(1)// 报错 TypeError: Method Set.prototype.delete called on incompatible receiver [object Object]
/*
delete 方法执行时的 this 都会指向代理对象p,而不会指向原始 Set 对象。
想要解决这个问题,只需把 delete 方法与原始数据对象绑定即可。
*/
}

# √ CASE 3 => 代理 Set 执行 delete 方法
console.log("------------------- CASE 3")
{
const s = new Set([1, 2, 3])
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}
// 将方法与原始数据对象 target 绑定后返回
return target[key].bind(target)
}
})

// 调用 delete 方法删除值为 1 的元素,正确执行
p.delete(1)
console.log(s.size) // 2
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 065_5.8.1_响应式 v65 set 封装new Proxy.html
# 封装 createReactive
// 在 createReactive 里封装用于代理 Set/Map 类型数据的逻辑
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}

return target[key].bind(target)
}
})
}

/*---------------------------------*/

const p = reactive(new Set([1, 2, 3]))
console.log(p.size) // 3

5.8.2 建立响应联系

1
2
3
4
5
6
7
8
9
# 066_5.8.2_响应式 v66 set add未触发响应.html
const p = reactive(new Set([11, 22, 33]))

effect(() => {
// 在副作用函数内访问 size 属性
console.log(p.size)
})

p.add(11)// 添加值为 11 的元素,应该触发响应(这里没有任何输出)

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
# 067_5.8.2_响应式 v67 set add delete.html
# add delete
// 定义一个对象,将自定义的 add 方法定义到该对象下
const mutableInstrumentations = {
add(key) {
// this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
const target = this.raw
// 先判断值是否已经存在
const hadKey = target.has(key)
// 通过原始数据对象执行 add 方法添加具体的值,
// 注意,这里不再需要 .bind 了,因为是直接通过 target 调用并执行的
const res = target.add(key)
// 调用 trigger 函数触发响应,并指定操作类型为 ADD
if (!hadKey) {
trigger(target, key, TriggerType.ADD)
}
// 返回操作结果
return res
},
delete(key) {
const target = this.raw
const hadKey = target.has(key)
const res = target.delete(key)
// 当要删除的元素确实存在时,才触发响应
if (hadKey) {
trigger(target, key, TriggerType.DELETE)
}
return res
}
}

# Proxy 修改
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
// 如果读取的是 raw 属性,则返回原始数据对象 target
if (key === 'raw') return target

if (key === 'size') {
// 调用 track 函数建立响应联系
track(target, ITERATE_KEY)
return Reflect.get(target, key, target)
}

// 返回定义在 mutableInstrumentations 对象下的方法
return mutableInstrumentations[key]
}
})
}

/*---------------------------------*/

# 测试
const p = reactive(new Set([11, 22, 33]))

effect(() => {
// 在副作用函数内访问 size 属性
console.log(p.size)// 初始化输出 3
})

console.log('add 11--------')
p.add(11)// 不触发响应(不是新元素)

console.log('add 44--------')
p.add(44)// 触发响应(是新元素) => 输出 4

console.log('delete 23--------')
p.delete(23)// 不触发响应(元素不存在)

console.log('delete 22--------')
p.delete(22)// 触发响应(元素存在) => 输出 3

5.8.3 避免污染原始数据

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
# 068_5.8.3_响应式 v68 map set触发响应.html
# get set
const mutableInstrumentations = {
add(key) {
// ...
},
delete(key) {
// ...
},
get(key) {
// 获取原始对象
const target = this.raw
// 判断读取的 key 是否存在
const had = target.has(key)
// 追踪依赖,建立响应联系
track(target, key)
// 如果存在,则返回结果。这里要注意的是,如果得到的结果 res 仍然是可代理的数据,
// 则要返回使用 reactive 包装后的响应式数据
if (had) {
const res = target.get(key)
return typeof res === 'object' ? reactive(res) : res
}
},
set(key, value) {
const target = this.raw
const had = target.has(key)
// 获取旧值
const oldValue = target.get(key)
// 设置新值
target.set(key, value)
// 如果不存在,则说明是 ADD 类型的操作,意味着新增
if (!had) {
trigger(target, key, TriggerType.ADD)
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
// 如果不存在,并且值变了,则是 SET 类型的操作,意味着修改
trigger(target, key, 'SET')
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([['key', 1]]))

effect(() => {
console.log(p.get('key'))// 输出 1
})

p.set('key', 2) // 触发响应 => 输出 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 069_5.8.3_响应式 v69 map 污染问题.html
# 测试
// 原始 Map 对象 m
const m = new Map()
// p1 是 m 的代理对象
const p1 = reactive(m)
// p2 是另外一个代理对象
const p2 = reactive(new Map())
// 为 p1 设置一个键值对,值是代理对象 p2
p1.set('p2', p2)

effect(() => {
// 注意,这里我们通过原始数据 m 访问 p2
console.log(m.get('p2').size)// 输出 0
})
// 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
m.get('p2').set('foo', 1)// 触发响应 => 输出 1

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
# 070_5.8.3_响应式 v70 map 解决污染问题.html

const mutableInstrumentations = {
// ...
set(key, value) {
const target = this.raw
const had = target.has(key)
const oldValue = target.get(key)

// 获取原始数据,由于 value 本身可能已经是原始数据,所以此时 value.raw 不存在,则直接使用 value
const rawValue = value.raw || value
target.set(key, rawValue)

if (!had) {
trigger(target, key, TriggerType.ADD)
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
trigger(target, key, 'SET')
}
}
}

/*---------------------------------*/

# 测试
// 原始 Map 对象 m
const m = new Map()
// p1 是 m 的代理对象
const p1 = reactive(m)
// p2 是另外一个代理对象
const p2 = reactive(new Map())
// 为 p1 设置一个键值对,值是代理对象 p2
p1.set('p2', p2)

effect(() => {
// 注意,这里我们通过原始数据 m 访问 p2
console.log(m.get('p2').size)// 输出 0
})
// 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
m.get('p2').set('foo', 1)// 不触发响应

5.8.4 处理 forEach

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
# 071_5.8.4_响应式 v71 map foreach.html
# forEach
const mutableInstrumentations = {
// ...
forEach(callback) {
// 取得原始数据对象
const target = this.raw
// 与 ITERATE_KEY 建立响应联系
track(target, ITERATE_KEY)
// 通过原始数据对象调用 forEach 方法,并把 callback 传递过去
target.forEach(callback)
}
}

/*---------------------------------*/

# √ CASE 1 => Map.foreach 使用
console.log("------------------- CASE 1")
{
const m = new Map([
[{key: 1}, {value: 1}]
])

m.forEach(function (value, key) {
console.log(value) // { value: 1 }
console.log(key) // { key: 1 }
})
}

# √ CASE 2 => Map.set 触发响应
console.log("------------------- CASE 2")
{
const p = reactive(new Map([
[{key: 1}, {value: 1}]
]))

effect(() => {
// 需重写 forEach 方法
p.forEach(function (value, key) {
console.log(value) // { value: 1 }
console.log(key) // { key: 1 }
})
})

// 能够触发响应
p.set({key: 2}, {value: 2})
}

# × CASE 3 => Map.value delete 触发响应
console.log("------------------- CASE 3")
{
const key = {key: 1}
const value = new Set([1, 2, 3])
const p = reactive(new Map([
[key, value]
]))

effect(() => {
p.forEach(function (value, key) {
console.log(value.size) // 3
})
})

p.get(key).delete(1)// 不触发响应
/*
value.size 访问 size 属性时,这里的 value 是原始数据对象。
即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。
*/
}

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
# 072_5.8.4_响应式 v72 map foreach 改造.html
# forEach 改造
const mutableInstrumentations = {
//...

// forEach(callback) {
// // 取得原始数据对象
// const target = this.raw
// // 与 ITERATE_KEY 建立响应联系
// track(target, ITERATE_KEY)
//
// // wrap 函数用来把可代理的值转换为响应式数据
// const wrap = (val) => typeof val === 'object' ? reactive(val) : val
// // 通过 target 调用原始 forEach 方法进行遍历
// target.forEach((v, k) => {
// // 手动调用 callback,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
// callback(wrap(v), wrap(k), this)
// })
// }
/*
最后,出于严谨性,我们还需要做一些补充。
因为 forEach 函数除了接收 callback 作为参数之外,它还接收第二个参数,该参数可以用来指定 callback 函数执行时的 this 值。
*/
// 接收第二个参数
forEach(callback, thisArg) {
// 取得原始数据对象
const target = this.raw
// 与 ITERATE_KEY 建立响应联系
track(target, ITERATE_KEY)

// wrap 函数用来把可代理的值转换为响应式数据
const wrap = (val) => typeof val === 'object' ? reactive(val) : val
// 通过 target 调用原始 forEach 方法进行遍历
target.forEach((v, k) => {
// 通过 .call 调用 callback,并传递 thisArg
// 用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
callback.call(thisArg, wrap(v), wrap(k), this)
})
}
}

/*---------------------------------*/

# √ CASE 3 => Map.value delete 触发响应
console.log("------------------- CASE 3")
{
const key = {key: 1}
const value = new Set([1, 2, 3])
const p = reactive(new Map([
[key, value]
]))

effect(() => {
p.forEach(function (value, key) {
console.log(value.size) // 3
})
})

p.get(key).delete(1)// 触发响应 => 输出 2
/*
value.size 访问 size 属性时,这里的 value 是原始数据对象。
即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。
*/
}

# × CASE 4 => Map.set 修改 value 触发响应
console.log("------------------- CASE 4")
{
const p = reactive(new Map([
['key', 1]
]))

effect(() => {
p.forEach(function (value, key) {
// forEach 循环不仅关心集合的键,还关心集合的值
console.log(value) // 1
})
})

p.set('key', 2) // 即使操作类型是 SET,也应该触发响应 => 未触发响应
}

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
# 073_5.8.4_响应式 v73 map set修改问题.html

# trigger ===> SET Map 重新触发 ITERATE_KEY
function trigger(target, key, type, newVal) {
// ...

// SET && 目标对象是 Map ===> 触发 ITERATE_KEY 相关联的副作用函数
if (type === TriggerType.ADD || type === TriggerType.DELETE
|| (type === TriggerType.SET && Object.prototype.toString.call(target) === '[object Map]')
) {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

// ...
}

/*---------------------------------*/

# √ CASE 4 => Map.set 修改 value 触发响应
console.log("------------------- CASE 4")
{
const p = reactive(new Map([
['key', 1]
]))

effect(() => {
p.forEach(function (value, key) {
// forEach 循环不仅关心集合的键,还关心集合的值
console.log(value) // 1
})
})

p.set('key', 2) // 即使操作类型是 SET,也应该触发响应 => 触发响应 输出 2
}


【日期标记】2023-07-12 08:56:47 以上同步完成

5.8.5 迭代器方法

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
# 074_5.8.5_响应式 v74 map 正常迭代.html
const m = new Map([
['key1', 'value1'],
['key2', 'value2']
])

# CASE 1 调用方法获取迭代器,并使用 for...of 进行循环
console.log("------------------- CASE 1")
for (const [key, value] of m.entries()) {
console.log(key, value)
// key1 value1
// key2 value2
}

# CASE 2 Map 或 Set 类型本身部署了 Symbol.iterator 方法,因此它们自身可以使用 for...of 进行迭代
console.log("------------------- CASE 2")
for (const [key, value] of m) {
console.log(key, value)
// key1 value1
// key2 value2
}

# CASE 3 调用迭代器函数获取迭代器,手动调用 next 方法获取对应的值
console.log("------------------- CASE 3")
const itr = m[Symbol.iterator]()
console.log(itr.next()) // { value: ['key1', 'value1'], done: false }
console.log(itr.next()) // { value: ['key2', 'value2'], done: false }
console.log(itr.next()) // { value: undefined, done: true }

# CASE 4 m[Symbol.iterator] 与 m.entries 是等价的
console.log("------------------- CASE 4")
console.log(m[Symbol.iterator] === m.entries) // true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 075_5.8.5_响应式 v75 map 代理迭代.html
# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
// 报错 TypeError: p is not iterable
for (const [key, value] of p) {
console.log(key, value)
}
})

p.set('key3', 'value3')

/*
一个对象可被迭代,需要实现了 Symbol.iterator 方法。
代理对象 p 没有实现 Symbol.iterator 方法,因此报错。

for...of 循环迭代一个代理对象时,内部会试图从代理对象 p 上读取 p[Symbol.iterator] 属性,
这个操作会触发 get 拦截函数,所以我们仍然可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中。
*/

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
# 076_5.8.5_响应式 v76 map 解决代理迭代.html
# Symbol.iterator
const mutableInstrumentations = {
// ...
[Symbol.iterator]() {
// 获取原始数据对象 target
const target = this.raw
// 获取原始迭代器方法
const itr = target[Symbol.iterator]()
// 将其返回
return itr
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
for (const [key, value] of p) {
console.log(key, value)
// key1 value1
// key2 value2
}
})

p.set('key3', 'value3')// 不能响应

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
# 077_5.8.5_响应式 v77 map k v响应式数据.html
# key value 变为可响应
const mutableInstrumentations = {
// ...
[Symbol.iterator]() {
// 获取原始数据对象 target
const target = this.raw
// 获取原始迭代器方法
const itr = target[Symbol.iterator]()

// 调用 track 函数建立响应联系
track(target, ITERATE_KEY)

const wrap = (val) => typeof val === 'object' && val !== null ? reactive(val) : val
// 返回自定义的迭代器
return {
next() {
// 调用原始迭代器的 next 方法获取 value 和 done
const {value, done} = itr.next()
return {
// 如果 value 不是 undefined,则对其进行包裹
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
}
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
// 报错 TypeError: p is not iterable
for (const [key, value] of p) {
console.log(key, value)
// key1 value1
// key2 value2
}
})

p.set('key3', 'value3')// 可以响应
// key1 value1
// key2 value2
// key3 value3

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
# 078_5.8.5_响应式 v78 map entries问题.html

# 抽取公共 iterationMethod
const mutableInstrumentations = {
//...
// 共用 iterationMethod 方法
[Symbol.iterator]: iterationMethod,
entries: iterationMethod
}

// 抽离为独立的函数,便于复用
function iterationMethod() {
const target = this.raw
const itr = target[Symbol.iterator]()

track(target, ITERATE_KEY)

const wrap = (val) => typeof val === 'object' && val !== null ? reactive(val) : val
return {
next() {
const {value, done} = itr.next()
return {
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
// 报错 TypeError: p.entries is not a function or its return value is not iterable
for (const [key, value] of p.entries()) {
console.log(key, value)
}
})

p.set('key3', 'value3')

/*
p.entries() 返回一个自定义匿名迭代器对象。
这个对象具有 next方法,但不具有 Symbol.iterator方法,所以这个对象不可迭代。

可迭代协议指的是一个对象实现了 Symbol.iterator 方法
迭代器协议指的是一个对象实现了 next 方法

但一个对象可以同时实现可迭代协议 和 迭代器协议:
const obj = {
// 迭代器协议
next() {
// ...
}
// 可迭代协议
[Symbol.iterator]() {
return this
}
}
*/

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
# 079_05.8.5_响应式 v79 map 解决entries问题.html
# 自定义匿名对象 实现可迭代协议 [Symbol.iterator]
function iterationMethod() {
const target = this.raw
const itr = target[Symbol.iterator]()

track(target, ITERATE_KEY)

const wrap = (val) => typeof val === 'object' && val !== null ? reactive(val) : val
return {
next() {
const {value, done} = itr.next()
return {
value: value ? [wrap(value[0]), wrap(value[1])] : value,
done
}
},
[Symbol.iterator]() {// 实现可迭代协议
return this
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
// 报错 TypeError: p.entries is not a function or its return value is not iterable
for (const [key, value] of p.entries()) {
console.log(key, value)
// key1 value1
// key2 value2
}
})

p.set('key3', 'value3')
// key1 value1
// key2 value2
// key3 value3


【日期标记】2023-07-12 10:47:37 以上同步完成

5.8.6 values 与 keys 方法

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
# 080_5.8.5_响应式 v80 map values.html

# values
const mutableInstrumentations = {
//...
// 共用 iterationMethod 方法
[Symbol.iterator]: iterationMethod,
entries: iterationMethod,
values: valuesIterationMethod
}

function valuesIterationMethod() {
// 获取原始数据对象 target
const target = this.raw
// 通过 target.values 获取原始迭代器方法
const itr = target.values()

track(target, ITERATE_KEY)

const wrap = (val) => typeof val === 'object' ? reactive(val) : val
return {
next() {
const {value, done} = itr.next()
return {
// value 是值,而非键值对,所以只需要包裹 value 即可
value: wrap(value),
done
}
},
[Symbol.iterator]() {
return this
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
for (const value of p.values()) {
console.log(value)
// value1
// value2
}
})

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
# 081_5.8.5_响应式 v81 map keys修改问题.html
# keys
const mutableInstrumentations = {
// ...
// 共用 iterationMethod 方法
[Symbol.iterator]: iterationMethod,
entries: iterationMethod,
values: valuesIterationMethod,
keys: keysIterationMethod
}

function keysIterationMethod() {
// 获取原始数据对象 target
const target = this.raw
// 通过 target.keys 获取原始迭代器方法
const itr = target.keys()

track(target, ITERATE_KEY)

const wrap = (val) => typeof val === 'object' ? reactive(val) : val
return {
next() {
const {value, done} = itr.next()
return {
// value 是值,而非键值对,所以只需要包裹 value 即可
value: wrap(value),
done
}
},
[Symbol.iterator]() {
return this
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
for (const value of p.keys()) {
console.log(value)// key1 key2
}
})

p.set('key2', 'value3')// 这是一个 SET 类型的操作,它修改了 key2 的值(会触发响应,输出 key1 key2)


# 分析
2023-07-12 11:08:26

在 trigger 触发,对 ITERATE_KEY 特殊判断。
这对于 values 或 entries 等方法来说是必需的,但对于 keys 方法来说则没有必要,
因为 keys 方法只关心 Map 类型数据的键的变化,而不关心值的变化。


function trigger(target, key, type, newVal) {
//...

// SET && 目标对象是 Map ===> 触发 ITERATE_KEY 相关联的副作用函数
if (type === TriggerType.ADD || type === TriggerType.DELETE
|| (type === TriggerType.SET && Object.prototype.toString.call(target) === '[object Map]')
) {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

// ...
}

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
# 082_5.8.5_响应式 v82 map 解决keys修改问题.html

// Map.keys() 代理迭代问题
const MAP_KEY_ITERATE_KEY = Symbol()

# 新增代码 => 特殊处理 MAP_KEY_ITERATE_KEY
function trigger(target, key, type, newVal) {
// ...

// 类型为 ADD 或 DELETE,并且为 Map类型的数据
if ((type === TriggerType.ADD || type === TriggerType.DELETE)
&& Object.prototype.toString.call(target) === '[object Map]'
) {
// 则取出那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数并执行
const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

// ...
}

# 追踪 MAP_KEY_ITERATE_KEY
function keysIterationMethod() {
const target = this.raw
const itr = target.keys()

// 调用 track 函数追踪依赖,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系
track(target, MAP_KEY_ITERATE_KEY)

const wrap = (val) => typeof val === 'object' ? reactive(val) : val
return {
next() {
const {value, done} = itr.next()
return {
value: wrap(value),
done
}
},
[Symbol.iterator]() {
return this
}
}
}

/*---------------------------------*/

# 测试
const p = reactive(new Map([
['key1', 'value1'],
['key2', 'value2']
]))

effect(() => {
for (const value of p.keys()) {
console.log(value)// key1 key2
}
})

p.set('key2', 'value3')// 这是一个 SET 类型的操作,它修改了 key2 的值(不会触发响应)

【日期标记】2023-07-12 11:39:17 以上同步完成

5.9 总结

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
## 对象和 Proxy
- JavaScript 中有两种对象:常规对象和异质对象。
- Proxy 实现了对象的代理,可以拦截并重新定义对对象的基本操作。
- 异质对象的代理需要注意 Reflect.* 方法的使用和指定正确的 receiver。

## 对象的代理和拦截
- 代理对象的本质是拦截基本操作的方法。
- 复合操作可以通过拦截基本操作的方法间接处理。
- 添加、修改和删除属性会影响 for...in 循环的执行次数,需要触发相关的副作用函数重新执行。
- NaN 的处理需要注意 NaN === NaN 永远等于 false
- 访问原型链上的属性可能导致副作用函数重新执行两次。

## 深响应和浅响应
- 深响应和浅响应指对象的层级。
- 浅响应只代理对象的第一层属性,深响应需要对属性值进行包装。
- 深只读和浅只读与深响应和浅响应类似。

## 数组的代理和拦截
- 数组是异质对象,拦截数组的方法需要重写数组的内部方法。
- 数组的 length 属性和索引操作需要特殊处理。
- for...in 遍历数组时需要使用 length 属性作为追踪的 key。
- for...of 遍历数组基于迭代协议工作,无需额外处理。

## 数组的查找方法
- 修改数组长度的原型方法可能导致循环调用和调用栈溢出。
- 重写数组的查找方法,先在代理对象中查找,再在原始数组中查找。

## 集合类型数据的响应式方案
- 集合类型数据有特定的数据操作方法,需要注意代理的问题。
- 使用 bind 方法绑定方法的 this 指向。
- 重写集合方法实现自定义能力,触发响应使用 trigger 函数。
- 避免数据污染,通过 raw 属性访问原始数据对象。
- forEach 方法遍历集合时关注键和值的变化。

【日期标记】2023-07-12 11:54:09 以上同步完成

第6章 原始值的响应式方案

1
2
3
4
5
6
7
8
第5章          非原始值的响应式方案
第6章(本章) 原始值的响应式方案

原始值(值传递):Boolean、Number、BigInt、String、Symbol、undefined 和 null

函数接收原始值,形参与实参没有引用关系,两个独立的值,修改互不影响。

Proxy 无法对原始值代理,想要原始值变为响应式数据,要包裹一层 ref。

6.1 引入 ref 的概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 083_6.1_响应式 v83 基本值不能代理.html

# 无法拦截
let str = 'vue'
// 无法拦截对值的修改
str = 'vue3'

# 对象包裹
const wrapper = {
value: 'vue'
}
// 可以使用 Proxy 代理 wrapper,间接实现对原始值的拦截
const name = reactive(wrapper)
name.value // vue
// 修改值可以触发响应
name.value = 'vue3'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 084_6.1_响应式 v84 ref包裹基本值.html

# ref函数
// 封装一个 ref 函数
function ref(val) {
// 在 ref 函数内部创建包裹对象
const wrapper = {
value: val
}
// 将包裹对象变成响应式数据
return reactive(wrapper)
}

# ref函数的使用
// 创建原始值的响应式数据
const refVal = ref(1)

effect(() => {
// 在副作用函数内通过 value 属性读取原始值
console.log(refVal.value)
})
// 修改值能够触发副作用函数重新执行
refVal.value = 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 085_6.1_响应式 v85 ref标识.html

# ref 无法区分
const refVal1 = ref(1)
const refVal2 = reactive({ value: 1 })


# ref 标识 '__v_isRef'
function ref(val) {
const wrapper = {
value: val
}
// 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})

return reactive(wrapper)
}

6.2 响应丢失问题

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
# 数据暴露
export default {
setup() {
// 响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// 1s 后修改响应式数据的值,不会触发重新渲染
setTimeout(() => {
obj.foo = 100
}, 1000)

// 将数据暴露到模板中
return {
...obj
// 等价于
// foo: 1,
// bar: 2
}
}
}

# 模版引用
<template>
<p>{{ foo }} / {{ bar }}</p>
</template>


# 响应丢失问题
1s 后修改响应式数据的值,不会触发重新渲染

# 分析
由展开运算符 ... 导致的(下面二者等价)。
所以,这里返回一个普通对象,而不是代理对象。

return {
...obj
}

return {
foo: 1,
bar: 2
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 086_6.2_响应式 v86 ...展开符 响应丢失问题.html

# 复现 => 响应丢失问题
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// 将响应式数据展开到一个新的对象 newObj
const newObj = {
...obj
// 等价于
// foo: 1,
// bar: 2
}

effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo)
})

// 很显然,此时修改 obj.foo 并不会触发响应
obj.foo = 100

# 分析
newObj 不是一个代理对象,所以 newObj.foo 不会建立响应联系。

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
# 087_6.2_响应式 v87 重写属性 toRef toRefs.html

# 创建同名属性对象(value 读取数据)
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// newObj 对象下具有与 obj 对象同名的属性,并且每个属性值都是一个对象,
// 该对象具有一个访问器属性 value,当读取 value 的值时,其实读取的是 obj 对象下相应的属性值
const newObj = {
foo: {
get value() {
return obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}

effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo.value)// 1
})

// 这时能够触发响应了(输出 100)
obj.foo = 100

# 提取 toRef 函数
# 定义
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
}
}

return wrapper
}

# 使用
const newObj = {
foo: toRef(obj, 'foo'),
bar: toRef(obj, 'bar')
}

# 提取 toRefs 函数
# 定义
function toRefs(obj) {
const ret = {}
// 使用 for...in 循环遍历对象
for (const key in obj) {
// 逐个调用 toRef 完成转换
ret[key] = toRef(obj, key)
}
return ret
}

# 使用
const newObj = { ...toRefs(obj) }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 088_6.2_响应式 v88 完善toRef set __v_isRef.html

# 完善 toRef ===> set 与 '__v_isRef'
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
},
// 允许设置值
set value(val) {
obj[key] = val
}
}

Object.defineProperty(wrapper, '__v_isRef', {
value: true
})

return wrapper
}

6.3 自动脱 ref

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
# 089_6.3_响应式 v89 属性值访问对比.html

# 属性值访问
# 正常
const obj = reactive({ foo: 1, bar: 2 })
obj.foo // 1
obj.bar // 2

# ref 包裹
const newObj = { ...toRefs(obj) }
// 必须使用 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2

# 模版访问
# 正常
<p>{{ foo }} / {{ bar }}</p>

# ref 包裹
<p>{{ foo.value }} / {{ bar.value }}</p>

# 自动脱 ref
上面无疑是增加了用户的心智负担。

自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。
newObj.foo // 1

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
# 090_6.3_响应式 v90 字动脱 ref get.html

# 自动脱 ref => Proxy.get 代理
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值
return value.__v_isRef ? value.value : value
}
})
}

// 调用 proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })

console.log(newObj.foo) // 1
console.log(newObj.bar) // 2

# 关于 Vue.js 中的 setup() 函数
const MyComponent = {
setup() {
const count = ref(0)

// 返回的这个对象会传递给 proxyRefs
return { count }
}
}

// 这也是为什么我们可以在模板直接访问一个 ref 的值,而无须通过 value 属性来访问。
<p>{{ count }}</p>

# 091_6.3_响应式 v91 字动脱 ref set.html

# 自动设 ref => Proxy.set 代理
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
return value.__v_isRef ? value.value : value
},

set(target, key, newValue, receiver) {
// 通过 target 读取真实值
const value = target[key]
// 如果值是 Ref,则设置其对应的 value 属性值
if (value.__v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, receiver)
}
})
}

# Vue.js 中 reactive 函数也有自动脱 ref 的能力
// 这么设计旨在减轻用户的心智负担,用户不用担心是否为 ref
const count = ref(0)
const obj = reactive({ count })

obj.count // 0

6.4 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ref 的概念
- ref 是一个"包裹对象",用于间接实现对原始值的响应式。
- ref 的标识属性 __v_isRef 用于区分 ref 和普通响应式对象。

## 解决响应丢失问题
- toRef 和 toRefs 是用于解决响应丢失问题的函数。
- toRef 将响应式对象的属性包装成 ref。
- toRefs 将响应式对象的所有属性包装成 ref。

## 自动脱 ref
- 自动对暴露到模板中的响应式数据进行脱 ref 处理。
- 用户在模板中使用响应式数据时无须关心是否为 ref。

【日期标记】2023-07-13 08:33:52 以上同步完成

第7章 渲染器的设计

1
2
3
渲染器的代码量非常庞大,需要合理的架构设计来保证可维护性,不过它的实现思路并不复杂。

接下来,我们就从讨论渲染器如何与响应系统结合开始,逐步实现一个完整的渲染器。

7.1 渲染器与响应系统的结合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 渲染
function renderer(domString, container) {
container.innerHTML = domString
}

# 静态字符串
renderer('<h1>Hello</h1>', document.getElementById('app'))

# 动态拼接
let count = 1
renderer(`<h1>${count}</h1>`, document.getElementById('app'))


# 渲染 + 响应式
const count = ref(1)

effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})

count.value++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
它暴露的全局 API 名叫 VueReactivity
<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

# 完整代码
// 我们通过 VueReactivity 得到了 effect 和 ref 这两个API。
const { effect, ref } = VueReactivity

function renderer(domString, container) {
container.innerHTML = domString
}

const count = ref(1)

effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})

count.value++

7.2 渲染器的基本概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 渲染器的作用:虚拟 DOM 渲染为 真实 DOM
renderer 渲染器(名词)
render 渲染(动词)


# 虚拟 DOM(也称 “虚拟节点”)
英文 virtual DOM
简写 vdom

英文 virtual node
简写 vnode

# 挂载
英文 mount
指的是 => 渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程

Vue.js 组件中 mounted钩子就是在挂载完成时触发。
所以,mounted钩子中可以访问真实 DOM。

# 挂载点
指的是 => 渲染器把真实 DOM 挂载到哪里。

“挂载点”也是 DOM 元素,也称为容器元素 container,渲染器会把真实 DOM 渲染到 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
# 简单的 createRenderer函数
// createRenderer 函数用来创建一个渲染器。
// - 调用 createRenderer 函数会得到一个 render 函数,
// - 该 render 函数会以 container 为挂载点,
// - 将 vnode 渲染为真实 DOM 并添加到该挂载点下。
function createRenderer() {
function render(vnode, container) {
// ...
}

return render
}



# 复杂的 createRenderer函数
// 渲染器的内容非常广泛,而用来把 vnode 渲染为真实 DOM 的 render 函数只是其中一部分。
// 实际上,在 Vue.js 3 中,甚至连创建应用的 createApp 函数也是渲染器的一部分。
function createRenderer() {
function render(vnode, container) {
// ...
}

function hydrate(vnode, container) {
// ...
}

return {
render,
hydrate
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 渲染一次
const renderer = createRenderer()
// 首次渲染 => 只涉及挂载
renderer.render(vnode, document.querySelector('#app'))

# 渲染多次
const renderer = createRenderer()
// 首次渲染 => 只涉及挂载
renderer.render(oldVNode, document.querySelector('#app'))
// 第二次渲染 => 除了挂载,还要更新
renderer.render(newVNode, document.querySelector('#app'))

/*
首次渲染,把 oldVNode 渲染到 container 内。

再次渲染,比较 newVNode、oldVNode,找到并更新变更点。
这个过程叫作 “打补丁”(或更新),英文 patch。
*/

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
# 定义函数 createRenderer
function createRenderer() {
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
// => patch(旧node, 新node, 容器) 不但打补丁,也会挂载(这里暂时不给出 patch 代码)
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
// 只需要将 container 内的 DOM 清空即可
container.innerHTML = ''
}
}
// 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
container._vnode = vnode
}

return {
render
}
}

# 测试
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode1, document.querySelector('#app'))
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'))
// 第三次渲染
renderer.render(null, document.querySelector('#app'))

# 分析
首次渲染,vnode1 渲染为真实 DOM,并设置 container._vnode = vnode1。

第二次渲染,vnode2 作为新 vnode,新旧 vnode 一同传递 patch 打补丁。

第三次渲染,新 vnode 值为 null,什么都不渲染。
此时,容器中渲染是 vnode2 的内容,所以要清空容器 ===> container.innerHTML = ''

7.3 自定义渲染器

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
# ============= 大致代码结构
function createRenderer(options) {

const { createElement, insert, setElementText } = options

function mountElement(vnode, container) {...}

function patch(n1, n2, container) {...}

function render(vnode, container) {...}

return {
render
}
}



# ============= 完整代码结构

# 定义函数 createRenderer
function createRenderer(options) {

const { createElement, insert, setElementText } = options

function mountElement(vnode, container) {
// 调用 createElement 函数创建元素
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
// 调用 setElementText 设置元素的文本节点
setElementText(el, vnode.children)
}
// 调用 insert 函数将元素插入到容器内
insert(el, container)
}

function patch(n1, n2, container) {// n1=oldNode, n2=newNode
// 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
if (!n1) {
mountElement(n2, container)
} else {
// n1 存在,意味着打补丁,暂时省略
}
}

function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
container.innerHTML = ''
}
}
container._vnode = vnode
}

return {
render
}
}

# 调用函数 createRenderer
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
// 创建元素
createElement(tag) {
return document.createElement(tag)
},

// 设置元素的文本节点
setElementText(el, text) {
el.textContent = text
},

// 在给定的 parent 下添加指定元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
// A.insertBefore(B, ref); A表示父元素,B表示新子元素。ref表示指定子元素,在这个元素之前插入新子元素。
}
})

# 测试代码
const vnode = {
type: 'h1',
children: 'hello'
}

// 调用 render 函数渲染该 vnode
renderer.render(vnode, document.querySelector('#app'))

7.4 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 渲染器与响应系统
- 响应系统实现自动页面更新,与具体渲染器的实现无关。
- 构建了一个简单的渲染器,利用 innerHTML 属性进行页面渲染。

## 相关名词和概念
- 渲染器(renderer):将虚拟 DOM 渲染为平台上的真实元素。
- 虚拟 DOM(virtual DOM):用于表达要渲染的结构,简写为 vdom 或 vnode。
- 挂载和打补丁操作:用于新元素的挂载和对比新旧 vnode 进行更新。

## 自定义渲染器的实现
- 渲染器利用 DOM API 在浏览器平台上创建、修改和删除元素。
- 抽象这些操作为可配置的对象,以实现与特定平台无关的渲染器。
- 用户可以通过自定义配置对象创建渲染器,实现定制化行为。
- 实现了一个打印渲染器操作流程的自定义渲染器,可在浏览器和 Node.js 中使用。

【日期标记】2023-07-13 18:34:11 以上同步完成

第8章 挂载与更新

8.1 挂载子节点和元素的属性

children 数组:挂载多个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# children => 对象 改为 数组
const vnode = {
type: 'div',
children: [
{
type: 'p',
children: 'hello'
}
]
}

# 循环处理 children 数组
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
vnode.children.forEach(child => {
patch(null, child, el)
})
}
insert(el, 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
34
35
36
37
# 新增属性 vnode.props
const vnode = {
type: 'div',
// 使用 props 描述一个元素的属性
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}

# 处理 vnode.props
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理 string array

// 如果 vnode.props 存在才处理它
if (vnode.props) {
for (const key in vnode.props) {
// 方式一、调用 setAttribute 将属性设置到元素上
// el.setAttribute(key, vnode.props[key])

// 方式二、直接设置
el[key] = vnode.props[key]
}
}

insert(el, container)
}


实际上,无论是方式一使用 setAttribute 函数,还是方式二直接操作 DOM 对象,都存在缺陷。
为元素设置属性比想象中要复杂得多,下面我们继续来讨论。

8.2 HTML Attributes 与 DOM Properties

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
# HTML Attributes 与 DOM Properties
# HTML
<input id="my-input" type="text" value="foo" />

# javascript
const el = document.querySelector('#my-input')

HTML解析后会创建 DOM 元素,而使用 js 可以获取到 DOM 元素。
DOM元素所具有的属性,称为 “DOM Properties”。

定义在 HTML 标签上的属性,称为 “HTML Attributes”。
这里指的就是 id="my-input"type="text" 和 value="foo"

# 同名:HTML Attributes 与 DOM Properties
id="my-input" 对应 el.id
type="text" 对应 el.type
value="foo" 对应 el.value

# 不同名:HTML Attributes 与 DOM Properties
<div class="foo"></div>

class="foo" 对应 el.className。
// 之前我们解释过,className 不能使用 class 作为属性,因为 class 是 javascript 关键字。

# 无对应 DOM Properties
<div aria-valuenow="75"></div>

aria-*="xxx" 无对应 DOM Properties。

# 无对应 HTML Attributes
el.textContent 无对应 HTML Attributes。
<div id="example">
<p style="display: none;"><strong>Hidden</strong> Text</p>
<p><strong>Visible</strong> Text</p>
</div>

const element = document.getElementById('example');
console.log(element.textContent); // 输出:Hidden Text Visible Text
console.log(element.innerText); // 输出:Visible Text

/*
el.textContent:返回元素及其所有后代节点的文本内容,不会解析或执行其中的HTML标签。所有的文本内容都被视为纯文本。
el.innerText:返回元素及其后代节点的可见文本内容,但会解析和执行其中的HTML标签。它会考虑CSS样式和元素的可见性。
*/

# DOM Properties 修改不变
<input value="foo" />

// 1 修改前
console.log(el.getAttribute('value'))// 'foo'
console.log(el.value)// 'foo'
console.log(el.defaultValue)// 'foo'

// 2 用户修改为 'bar'

// 3 修改后
console.log(el.getAttribute('value'))// 仍然是 'foo'
console.log(el.value)// 'bar'
console.log(el.defaultValue)// 仍然是 'foo'

实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。
一旦值改变,那么 DOM Properties 始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值。
el.getAttribute('value') 等同于 el.defaultValue 原始值。
el.value 实时值。

el.getAttribute('value') el.defaultValue 始终指向 DOM Properties 值。
el.setAttribute('value', 'xxx') 可以改变 DOM Properties 值。
el.value 默认指向 DOM Properties 值,若手动改变了值,则指向手动改变的值。
<input value="foo"/>
<button>按钮</button>

<script>
const el = document.querySelector("input");

// 1 修改前
console.log(el.getAttribute('value'))// 'foo'
console.log(el.value)// 'foo'
console.log(el.defaultValue)// 'foo'

document.querySelector("button").onclick = function () {
// 2 用户手动修改为 'foo1',点击按钮
console.log("用户修改为 'foo1'")
// DOM Properties 修改为 'bar1'
el.setAttribute("value", "bar1")

// 3 修改后
console.log(el.getAttribute('value'))// 'bar1'
console.log(el.value)// 'foo1'
console.log(el.defaultValue)// 'bar1'
}
</script>

# 合法值
<input type="foo" />

console.log(el.type) // 'text'

type="foo" 不合法,浏览器会矫正它。
el.type 得到矫正后的值 'text',而非字符串 'foo'

记住一个核心原则:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。

8.3 正确地设置元素属性

浏览器解析、Vue.js解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 浏览器
// 浏览器解析,设置 DOM Properties el.disabled=true
<button disabled>Button</button>

# Vue.js
// 会把HTML模版编译成 vnode,等价于:
const button = {
type: 'button',
props: {
disabled: ''
}
}

// 渲染器
el.setAttribute('disabled', '')

disabled 属性存在,就会禁用

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

# vue实现
<button :disabled="false">Button</button>

# vnode表示
const vnode = {
type: 'button',
props: {
// disabled: 'false'// 禁用
// disabled: false// 不禁用 ===> 不尽人意
// disabled: 'true'// 禁用
// disabled: true// 禁用
disabled: ''// 不禁用 ===> 不尽人意
},
children: '按钮'
}

# 但是实际未禁用 => 不尽人意(需特殊处理)
// setAttribute 函数设置的值总是会被字符串化
// 二者等价:
el.setAttribute('disabled', false)
// el.setAttribute('disabled', 'false')

对于按钮来说,它的 el.disabled 属性值是布尔类型的,
并且无论 HTML Attributes 的值是什么,只要 disabled 属性存在,按钮就会被禁用。

所以,我们先设置 DOM Properties,例如:el.disabled = false

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
# 布尔值 矫正问题
const button = {
type: 'button',
props: {
disabled: ''
}
}

// 直接设置
el.disabled = ''// 希望矫正为 el.disabled = false

--------------------------------

# 特殊处理 ===> 布尔 && 空字符串
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理

if (vnode.props) {
for (const key in vnode.props) {
// 用 in 操作符判断 key 是否存在对应的 DOM Properties
if (key in el) {
// 获取该 DOM Properties 的类型
const type = typeof el[key]
const value = vnode.props[key]
// 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
// 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
el.setAttribute(key, vnode.props[key])
}
}
}

insert(el, 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
34
35
36
37
38
39
40
41
42
# 问题 => 只读属性
之前代码,
存在属性则使用 DOM Properties 进行设置,如:el[key] = value。
不存在属性,才使用 HTML Attributes 进行设置,如:el.setAttribute(key, vnode.props[key])。

但是,对于 input 标签,el.form 是只读的,我们只能通过 HTML Attributes 进行设置。
<form id="form1"></form>
<input form="form1" />

# 修改
# 抽取 shouldSetAsProps => 只读属性特殊处理
function shouldSetAsProps(el, key, value) {
// 特殊处理
if (key === 'form' && el.tagName === 'INPUT') return false
// 兜底 => 用 in 操作符判断 key 是否存在对应的 DOM Properties
return key in el
}

# 使用 shouldSetAsProps
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理

if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
// 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
if (shouldSetAsProps(el, key, value)) {
const type = typeof el[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
el.setAttribute(key, value)
}
}
}

insert(el, 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 抽取 patchProps
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag)
},
setElementText(el, text) {
el.textContent = text
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
// 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})


# 调用 patchProps
const {createElement, insert, setElementText, patchProps} = options

function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}

if (vnode.props) {
for (const key in vnode.props) {
// 调用 patchProps 函数即可
patchProps(el, key, null, vnode.props[key])
}
}

insert(el, container)
}

8.4 class 的处理

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
<p class="foo bar"></p>

const vnode = {
type: 'p',
props: {
class: 'foo bar'
}
}

# class 属性特殊处理
# 性能对比 el.className > el.classList > setAttribute
const renderer = createRenderer({
// 省略其他选项

patchProps(el, key, prevValue, nextValue) {
// 对 class 进行特殊处理
if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})

8.5 卸载操作

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
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 再次挂载新 vnode,将触发更新
renderer.render(newVNode, document.querySelector('#app'))



// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 新 vnode 为 null,意味着卸载之前渲染的内容
renderer.render(null, document.querySelector('#app'))



function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 卸载,清空容器
container.innerHTML = ''
}
}
container._vnode = 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
31
32
33
34
35
36
37
38
# vnode.el 引用
function mountElement(vnode, container) {
// 让 vnode.el 引用真实 DOM 元素
const el = vnode.el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}

if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}

insert(el, container)
}


# 移除元素
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 根据 vnode 获取要卸载的真实 DOM 元素
const el = container._vnode.el
// 获取 el 的父元素
const parent = el.parentNode
// 调用 removeChild 移除元素
if (parent) parent.removeChild(el)
}
}
container._vnode = vnode
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 抽取方法 unmount ===> 为了后续在卸载时,扩展更多的功能
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}


# 使用方法 unmount
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 调用 unmount 函数卸载 vnode
unmount(container._vnode)
}
}
container._vnode = vnode
}

8.6 区分 vnode 的类型

type不同:卸载 old_node,挂载 new_node

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
# 测试代码
const old_vnode = {
type: 'p'
}
renderer.render(old_vnode, document.querySelector('#app'))

const new_vnode = {
type: 'input'
}
renderer.render(new_vnode, document.querySelector('#app'))

# patch 打补丁
function patch(n1, n2, container) {
// 如果 n1 存在,则对比 n1 和 n2 的类型
if (n1 && n1.type !== n2.type) {
// 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
unmount(n1)
n1 = null
}

if (!n1) {
mountElement(n2, container)
} else {
// 更新
}
}

# 我的感悟
2023-07-14 18:55:15 我突然想到了,为什么组件必须为一个标签包裹。
因为 vnode 是一个对象,而不是数组。

2023-07-20 17:17:46 补充:上面说法错了,因为容器只能有一个,所以只能有一个标签包裹。

不同类型的 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
一个 vnode 可以用来描述普通标签,也可以用来描述组件,还可以用来描述 Fragment 等。
对于不同类型的 vnode,我们需要提供不同的挂载或打补丁的处理方式。

function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const {type} = n2
// 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
// TODO
patchElement(n1, n2)
}
} else if (typeof type === 'object') {
// TODO
// 如果 n2.type 的值的类型是对象,则它描述的是组件
} else if (type === 'xxx') {
// TODO
// 处理其他类型的 vnode
}
}

8.7 事件的处理

v1 添加事件监听

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
# 测试对象
const vnode = {
type: 'p',
props: {
// 使用 onXxx 描述事件
onClick: () => {
alert('clicked')
}
},
children: 'text'
}


# patchProps on开头处理
patchProps(el, key, prevValue, nextValue) {
// 匹配以 on 开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const name = key.slice(2).toLowerCase()
// 移除上一次绑定的事件处理函数
prevValue && el.removeEventListener(name, prevValue)
// 绑定事件,nextValue 为事件处理函数
el.addEventListener(name, nextValue)
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}

v2 invoker 包装单个事件处理

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
# 不用每次都移除监听,添加监听
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invoker = el._vei
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
// vei 是 vue event invoker 的首字母缩写
invoker = el._vei = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e)
}
// 将真正的事件处理函数赋值给 invoker.value
invoker.value = nextValue
// 绑定 invoker 作为事件处理函数
el.addEventListener(name, invoker)
} else {
// 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
invoker.value = nextValue
}
} else if (invoker) {
// 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}

v3 invoker 包装多个事件处理

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
# 测试对象
const vnode = {
type: 'p',
props: {
onClick: () => {
alert('clicked')
},
onContextmenu: () => {
alert('contextmenu')
}
},
children: 'text'
}
renderer.render(vnode, document.querySelector('#app'))

# invoker 对象 => 用于包装多个事件处理
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
const invokers = el._vei || (el._vei = {})
//根据事件名称获取 invoker
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
invoker = el._vei[key] = (e) => {
invoker.value(e)
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}

v4 invoker 单个事件, 多个处理

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
# addEventListener 相同事件,支持多个处理函数
el.addEventListener('click', fn1)
el.addEventListener('click', fn2)

# 测试对象
const vnode = {
type: 'p',
props: {
onClick: [
() => {// 第一个事件处理函数
alert('clicked 1')
},

() => {// 第二个事件处理函数
alert('clicked 2')
}
]
},
children: 'text'
}
renderer.render(vnode, document.querySelector('#app'))

# patchProps 支持同事件,多处理器
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
// 否则直接作为函数调用
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}

8.8 事件冒泡与更新时机问题

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
112
113
114
115
116
117
# 测试数据
const { effect, ref } = VueReactivity

const bol = ref(false)

effect(() => {
// 创建 vnode
const vnode = {
type: 'div',
props: bol.value ? {
onClick: () => {
alert('父元素 clicked')
}
} : {},
children: [
{
type: 'p',
props: {
onClick: () => {
bol.value = true
}
},
children: 'text'
}
]
}
// 渲染 vnode
renderer.render(vnode, document.querySelector('#app'))
})

# 分析
0. 初始化:div无点击事件, p有点击事件(会重新触发effect)
1. 点击p
2. 触发 p.onClick
3. bol.value = true ===> 触发副作用函数 effect
4. 渲染器 ===> 为 div.onClick 绑定事件
5. 执行 div.onClick函数


# 我的实际测试
/*
0. 初始化:div无点击事件, p有点击事件(会重新触发effect)
1. 点击p => 触发 p.onClick
3. bol.value = true ===> 触发副作用函数 effect
4. 再次渲染 ===> 为 div.onClick 绑定事件
5. 再进行第二次点击,就会执行 div.onClick函数

TODO 为什么我测试不可以触发???书上说可以触发
哦!我知道了,原因在 patchElement,
它会在第二次挂载时,类型一样,会进行比较更新来着,我在没有做任何处理,挂载的还是旧的vnode。

这里我们为了测试,把原有的卸载必须卸载就可以了。

2023-07-20 18:09:22 不行,还是演示不出来,
我的情况 “第一次点击,不会执行 div.onClick函数”,暂时放弃,

2023-07-20 18:15:04
暂时把 8.9 的 patchElement 代码补上,就可以了。
*/

function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 第一步:更新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}

// 第二步:更新 children
patchChildren(n1, n2, el)
}

# 改造 => 事件发生还未绑定,不处理
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// e.timeStamp 事件发生的时间
// invoker.attached 事件处理函数绑定的时间
// 事件发生还未绑定,不处理
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
// 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}

8.9 更新子节点

回顾一下 元素的子节点是如何被挂载的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function mountElement(vnode, container) {
const el = vnode.el = createElement(vnode.type)

// 挂载子节点,首先判断 children 的类型
// 如果是字符串类型,说明是文本子节点
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果是数组,说明是多个子节点
vnode.children.forEach(child => {
patch(null, child, el)
})
}

if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}

insert(el, 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 子节点:三种类型
# HTML
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div>
<!-- 多个子节点 -->
<div>
<p/>
Some Text
</div>


# js 对象
// 没有子节点
vnode = {
type: 'div',
children: null
}
// 文本子节点
vnode = {
type: 'div',
children: 'Some Text'
}
// 多个子节点
vnode = {
type: 'div',
children: [
{ type: 'p' },
'Some Text'
]
}

# 子节点:九种更新
1. new null
- old null
- old 文本
- old 数组

2. new 文本
- old null
- old 文本
- old 数组

3. new 数组
- old null
- old 文本
- old 数组

处理子节点

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
    function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 第一步:更新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}

// 第二步:更新 children
patchChildren(n1, n2, el)
}


# patchChildren
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 1. new 文本

// old 数组 => 逐个卸载
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}

// new 文本 => 设置给容器
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
// 2. new 数组

if (Array.isArray(n1.children)) {
// old 数组

// old 数组(后续改为 diff 算法)
// old 数组 => 逐个卸载
// new 数组 => 逐个挂载
n1.children.forEach(c => unmount(c))
n2.children.forEach(c => patch(null, c, container))
} else {
// old [null / 文本]
// 容器清空
// new 数组 => 逐个挂载
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
} else {
// 3. new null

if (Array.isArray(n1.children)) {
// old 数组 => 逐个卸载
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
// old 文本 => 容器清空
setElementText(container, '')
}

// new null => 什么也不做
}
}

8.10 文本节点和注释节点

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
# Symbol 标识 => Text Comment
// 文本节点的 type 标识
const Text = Symbol()
const newVNode1 = {
// 描述文本节点
type: Text,
children: '我是文本内容'
}

// 注释节点的 type 标识
const Comment = Symbol()
const newVNode2 = {
// 描述注释节点
type: Comment,
children: '我是注释内容'
}


# 处理 Text
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) { // 如果新 vnode 的类型是 Text(文本节点)
if (!n1) {
// old 不存在 => 创建,插入
const el = n2.el = document.createTextNode(n2.children)
insert(el, container)
} else {
// old 存在 => 更新
const el = n2.el = n1.el
if (n2.children !== n1.children) {
el.nodeValue = n2.children
}
}
}
}

跨平台处理

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
patch 函数依赖浏览器平台特有的API,即 createTextNode 和 el.nodeValue。
为了保证渲染器核心的跨平台能力,我们需要将这两个操作 DOM 的 API 封装到渲染器的选项中,
如下面的代码所示:

# 封装 => createText / setText
const renderer = createRenderer({
createElement(tag) {
// 省略部分代码
},
setElementText(el, text) {
// 省略部分代码
},
insert(el, parent, anchor = null) {
// 省略部分代码
},
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
},
patchProps(el, key, prevValue, nextValue) {
// 省略部分代码
}
})



# 使用 => createText / setText
const {createElement, insert, setElementText, createText, setText, patchProps} = options

function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
if (!n1) {
// 调用 createText 函数创建文本节点
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
// 调用 setText 函数更新文本节点的内容
setText(el, n2.children)
}
}
}
}

8.11 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
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
Fragment(片断)是 Vue.js 3 中新增 vnode 类型。



# 举个例子
假设我们要封装一组列表组件:

整体由两个组件构成,即 <List> 组件和 <Items> 组件。
<List>
<Items />
</List>

其中 <List> 组件会渲染一个 <ul> 标签作为包裹层:
<!-- List.vue -->
<template>
<ul>
...
</ul>
</template>

而 <Items> 组件负责渲染一组 <li> 列表:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>

# Vue 2
这在 Vue.js 2 中是无法实现的。
在 Vue.js 2 中,组件的模板不允许存在多个根节点。
这意味着,一个 <Items> 组件最多只能渲染一个 <li> 标签:
<!-- Item.vue -->
<template>
<li>1</li>
</template>

因此在 Vue.js 2 中,我们通常需要配合 v-for 指令来达到目的:
<List>
<Items v-for="item in list" />
</List>

# Vue 3
而 Vue.js 3 支持多根节点模板,所以不存在上述问题。
那么,Vue.js 3 是如何用 vnode 来描述多根节点模板的呢?
答案是,使用 Fragment,如下面的代码所示:
const Fragment = Symbol()
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: 'text 1' },
{ type: 'li', children: 'text 2' },
{ type: 'li', children: 'text 3' }
]
}


与文本节点和注释节点类似,片段也没有所谓的标签名称,因此我们也需要为片段创建唯一标识,即 Fragment。
对于 Fragment 类型的 vnode 的来说,它的children 存储的内容就是模板中所有根节点。
有了 Fragment 后,我们就可以用它来描述 Items.vue 组件的模板了:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>

const vnode = {
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}


# 一个模版实现
<List>
<Items />
</List>

const vnode = {
type: 'ul',// ul
children: [
{
type: Fragment,// li
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}
]
}

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
29
30
31
32
33
34
35
# 挂载 Fragment
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) { // 处理 Fragment 类型的 vnode
if (!n1) {
// old 不存在 => Fragment.children 逐个挂载
n2.children.forEach(c => patch(null, c, container))
} else {
// old 存在 => Fragment.children 更新
patchChildren(n1, n2, container)
}
}
}

# 卸载 Fragment
function unmount(vnode) {
// Fragment => 卸载 children
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}

8.12 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 节点挂载、属性和卸载
- 通过递归调用 patch 函数实现子节点的挂载。
- 节点的属性包括 HTML Attributes 和 DOM Properties,需要根据属性的特点进行正确的设置。
- 特殊属性(如 class 和 style)需要进行值的正常化处理,选择性地使用 el.className 进行设置。
- 卸载操作不能简单地使用 innerHTML 清空容器元素,需要考虑组件生命周期函数和自定义指令的执行,以及事件处理函数的移除。

## vnode 类型的区分和处理
- 在更新前需要检查新旧 vnode 是否描述相同的内容,以及它们的类型(字符串或对象)。
- 根据 vnode.type 的类型判断是挂载和打补丁普通标签元素(调用 mountElement 和 patchElement),还是组件(调用 mountComponent 和 patchComponent)。

## 事件的处理
- 在虚拟节点中以字符串 on 开头的属性表示事件。
- 通过伪造 invoker 函数,将事件处理函数存储在 invoker.value 中,更新时只需更新 invoker.value 的值。
- 利用事件绑定时间和事件触发时间的差异,屏蔽晚于事件触发时间的事件处理函数。

## 子节点的更新
- 规范化 vnode.children 属性,只允许字符串、数组和 null 三种类型。
- 更新时,需要考虑九种可能的情况,但实际代码中可以采用更高效的 Diff 算法来复用 DOM 元素。

## 文本节点、注释节点和 Fragment
- 使用唯一标识符为文本节点和注释节点创建虚拟节点,并将其作为 vnode.type 的值。
- Fragment 只是一个占位符,不会渲染任何 DOM 元素,渲染器只需要渲染其子节点即可。

【日期标记】2023-07-17 09:09:38 以上同步完成

第9章 简单 Diff 算法

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
当新旧 vnode 的子节点都是一组节点时,为了性能开销更低,需比较两组子节点的算法就叫 Diff 算法。
我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。


# 把上面两段话,给到 ChatGPT,下面是它的回答(2023-07-17 09:25:14)

# DOM 操作昂贵,Diff 算法减少 DOM 操作
当新旧 vnode 的子节点都是一组节点时,采用 Diff 算法可以在性能开销更低的情况下完成更新操作。
DOM 操作通常是比较昂贵的,所以渲染器的核心任务是通过 Diff 算法来尽量减少对 DOM 的操作次数。

# Diff 算法目标:找出差异,尽可能复用 DOM 元素
Diff 算法的目标是找出新旧节点集合之间的差异,并尽可能地复用已存在的 DOM 元素。
算法的基本思想是从新旧节点集合的开头开始,依次对比节点,并根据节点的变化情况决定采取相应的操作。

Diff 算法的过程可以概括为以下几个步骤:
1. 初始状态下,设置两个指针(oldStartIdx 和 newStartIdx)分别指向新旧节点集合的起始位置。
2. 循环对比新旧节点集合中的节点,根据节点的变化情况进行相应操作:
- 如果新旧节点相同,则移动指针并继续下一轮对比。
- 如果新旧节点不同,根据新旧节点的 key 进行进一步对比:
- 新节点在旧节点集合中找不到相同 key 的节点 => 插入新增节点。
- 新节点在旧节点集合中找到相同 key 的节点,内容有变化 => 更新。
- 新节点在旧节点集合中找到相同 key 的节点,内容无变化 => 复用节点,移动指针进行下一轮对比。
- 继续移动指针,并进行下一轮对比。
3. 对比完毕后,如果新节点集合中还有剩余节点,则认为是新增节点,进行插入操作。
4. 如果旧节点集合中还有剩余节点,则认为是多余节点,进行删除操作。

Diff 算法通过对比新旧节点集合,尽可能地复用已存在的 DOM 元素,减少了对 DOM 的操作次数,从而提高了渲染性能。

需要注意的是,Diff 算法并不是完美的,它也有一些局限性和优化策略。
例如,对于节点集合的长度差异较大时,Diff 算法可能会出现性能问题,因此可以考虑通过设置唯一 key、使用索引等策略来优化算法的执行效率。
此外,Diff 算法也会尽量避免跨层级的移动节点,因为跨层级的操作会导致 DOM 重绘,影响性能。

总的来说,Diff 算法是渲染器中非常重要的一部分,它通过比较新旧节点集合的差异,以最小的成本完成对 DOM 的更新操作,提高了渲染性能和用户体验。

9.1 减少 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
按照之前的算法,需要 6次 DOM操作:3次DOM卸载,3次DOM挂载。
下面遍历old,即3次DOM更新操作即可。

# 案例
// 旧 vnode
const oldVNode = {
type: 'div',
children: [
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'}
]
}

// 新 vnode
const newVNode = {
type: 'div',
children: [
{type: 'p', children: '4'},
{type: 'p', children: '5'},
{type: 'p', children: '6'}
]
}

# patchChildren => 改造
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 重新实现两组子节点的更新方式
// 新旧 children
const oldChildren = n1.children
const newChildren = n2.children
// 遍历旧的 children
for (let i = 0; i < oldChildren.length; i++) {
// 调用 patch 函数逐个更新子节点
patch(oldChildren[i], newChildren[i])
}
} else {
// 省略部分代码
}
}

不相同节点:挂载、卸载

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
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
const oldLen = oldChildren.length
const newLen = newChildren.length
// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)
// 遍历 commonLength 次
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i], container)
}

if (newLen > oldLen) {
// 如果 newLen > oldLen,说明有新子节点需要挂载
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container)
}
} else if (oldLen > newLen) {
// 如果 oldLen > newLen,说明有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i])
}
}
} else {
// 省略部分代码
}
}

9.2 DOM 复用与 key 的作用

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
关于这个也需要 6次DOM操作,3次循环(卸载旧的,挂载新的)。
// oldChildren
[
{ type: 'p' },
{ type: 'div' },
{ type: 'span' }
]

// newChildren
[
{ type: 'span' },
{ type: 'p' },
{ type: 'div' }
]

对于上面这种,类型相同,位置不同。可以使用移动。
(key 就是身份证号,唯一标识。 type 和 key 相同,就可以复用 DOM。)
// oldChildren
[
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: '3', key: 3 }
]

// newChildren
[
{ type: 'p', children: '3', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]

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
# 复用 DOM(key相同)
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children

// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
// 遍历旧的 children
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 相同 key,可以复用 => 调用 patch 函数更新
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
break // 这里需要 break
}
}
}

} else {
// 省略部分代码
}
}


# 测试
const oldVNode = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: 'hello', key: 3 }
]
}

const newVNode = {
type: 'div',
children: [
{ type: 'p', children: 'world', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
}

// 首次挂载
renderer.render(oldVNode, document.querySelector('#app'))
setTimeout(() => {
// 1 秒钟后更新
renderer.render(newVNode, document.querySelector('#app'))
}, 1000);

9.3 找到需要移动的元素

vue---diff 找到移动元素

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
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children

// 用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 当前在最大后面 => 需要移动(应该放在最大前面)
} else {
// 当前在最大后面 => 无需移动(更新最大)
lastIndex = j
}
break // 这里需要 break
}
}
}

} else {
// 省略部分代码
}
}

9.4 如何移动元素

vue---diff 移动元素

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
# 指向同一个真实 DOM
function patchElement(n1, n2) {
// 新的 vnode 也引用了真实 DOM 元素
const el = n2.el = n1.el
// 省略部分代码
}

# 移动
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children

let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 代码运行到这里,说明 newVNode 对应的真实 DOM 需要移动
// 先获取 newVNode 的前一个 vnode,即 prevVNode
const prevVNode = newChildren[i - 1]
// 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
if (prevVNode) {
// 由于我们要将 newVNode 对应的真实 DOM 移动到 prevVNode 所对应真实 DOM 后面,
// 所以我们需要获取 prevVNode 所对应真实 DOM 的下一个兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling
// 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面,
// 也就是 prevVNode 对应真实 DOM 的后面 anchor为null,表示尾插
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
}

} else {
// 省略部分代码
}
}

9.5 添加新元素

vue---diff 新增元素

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
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children

let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 在第一层循环中定义变量 find,代表是否在旧的一组子节点中找到可复用的节点,
// 初始值为 false,代表没找到
let find = false
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
// 一旦找到可复用的节点,则将变量 find 的值设为 true
find = true
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
// 如果代码运行到这里,find 仍然为 false
// 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
// 也就是说,当前 newVNode 是新增节点,需要挂载
if (!find) {
// 为了将节点挂载到正确位置,我们需要先获取锚点元素
// 首先获取当前 newVNode 的前一个 vnode 节点
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
// 如果有前一个 vnode 节点,则使用它的下一个兄弟节点作为锚点元素
anchor = prevVNode.el.nextSibling
} else {
// 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节点
// 这时我们使用容器元素的 firstChild 作为锚点
anchor = container.firstChild
}
// 挂载 newVNode
patch(null, newVNode, container, anchor)
}
}

} else {
// 省略部分代码
}
}

patch 第四个参数:锚点元素

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
// patch 函数需要接收第四个参数,即锚点元素
function patch(n1, n2, container, anchor) {
// 省略部分代码

if (typeof type === 'string') {
if (!n1) {
// 挂载时将锚点元素作为第三个参数传递给 mountElement 函数
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
}
}

// mountElement 函数需要增加第三个参数,即锚点元素
function mountElement(vnode, container, anchor) {
// 省略部分代码

// 在插入节点时,将锚点元素透传给 insert 函数
insert(el, container, anchor)
}

9.6 移除不存在的元素

vue---diff 删除元素

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
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children

let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
// 省略部分代码
}

// 上一步的更新操作完成后
// 遍历旧的一组子节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i]
// 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值的节点
const has = newChildren.find(
vnode => vnode.key === oldVNode.key
)
if (!has) {
// 如果没有找到具有相同 key 值的节点,则说明需要删除该节点
// 调用 unmount 函数将其卸载
unmount(oldVNode)
}
}

} else {
// 省略部分代码
}
}

9.7 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Diff算法与DOM操作优化
- Diff算法用于计算两组子节点的差异,并尽可能复用DOM元素。
- 采用简单的更新方式会导致大量DOM操作,性能消耗较大。
- 改进方案是遍历较少的子节点组进行打补丁,并根据新旧子节点数量进行挂载或卸载。

# 虚拟节点的Key属性与节点移动
- Key属性在更新中扮演"身份证号"的角色,用于复用节点。
- 渲染器通过Key属性找到可复用的节点,并尽量移动而非销毁重建。

# 简单Diff算法中的节点移动
- 简单Diff算法通过比较新旧子节点找到需要移动的节点。
- 最大索引标记了可复用节点的位置,小于最大索引的节点需要移动。

# 渲染器的节点移动、添加和删除
- 渲染器通过DOM操作实现虚拟节点的移动、添加和删除。

第10章 双端 Diff 算法

10.1 双端比较的原理

vue---diff 双端算法

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
# 分析 => 对照着图片,想想就知道了。
// * 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
// * 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
// * 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
// * 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd

# 循环:四步比较

function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 封装 patchKeyedChildren 函数处理两组子节点
patchKeyedChildren(n1, n2, container)
} else {
// 省略部分代码
}
}

function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
// 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁
patch(oldStartVNode, newStartVNode, container)
// 更新相关索引,指向下一个位置
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁
patch(oldEndVNode, newEndVNode, container)
// 更新索引和头尾部节点变量
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
// 调用 patch 函数在 oldStartVNode 和 newEndVNode 之间打补丁
patch(oldStartVNode, newEndVNode, container)
// 将旧的一组子节点的头部节点对应的真实 DOM 节点 oldStartVNode.el 移动到
// 旧的一组子节点的尾部节点对应的真实 DOM 节点后面
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
// 更新相关索引到下一个位置
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
// 仍然需要调用 patch 函数进行打补丁
patch(oldEndVNode, newStartVNode, container)
// 移动 DOM 操作
// oldEndVNode.el 移动到 oldStartVNode.el 前面
insert(oldEndVNode.el, container, oldStartVNode.el)
// 移动 DOM 完成后,更新索引值,并指向下一个位置
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
}

10.2 双端比较的优势

1
2
3
简单 Diff 算法   ===> 两次 DOM 移动

双端 Diff 算法 ===> 一次 DOM 移动

vue---diff 双端算法对比图

10.3 非理想状况的处理方式

vue---diff 双端算法 非理想状况

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
# 分析 => 对照着图片,想想就知道了。
// * 初始化:undefined 判断 ===> ++oldStartIdx 或 --oldEndIdx
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
// * 第五步:newStartVNode => old 中找到 find,移动 ++newStartIdx oldStart.prev=find find=undefined


# 循环:四步比较 + [undefined + else找到]
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 初始化:undefined 判断 ===> ++oldStartIdx 或 --oldEndIdx
// 增加两个判断分支,如果头尾部节点为 undefined,则说明该节点已经被处理过了,直接跳到下一个位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
} else {
// 第五步:newStartVNode => old 中找到 find,移动 ++newStartIdx oldStart.prev=find find=undefined
// 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实 DOM 移动到头部
if (idxInOld > 0) {
// idxInOld 位置对应的 vnode 就是需要移动的节点
const vnodeToMove = oldChildren[idxInOld]
// 不要忘记除移动操作外还应该打补丁
patch(vnodeToMove, newStartVNode, container)
// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点
insert(vnodeToMove.el, container, oldStartVNode.el)
// 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefined
oldChildren[idxInOld] = undefined
// 最后更新 newStartIdx 到下一个位置
newStartVNode = newChildren[++newStartIdx]
}
}
}
}

10.4 添加新元素

vue---diff 双端算法 新增元素

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
# 分析
// 初始化:undefined 判断 ===> ++oldStartIdx 或 --oldEndIdx
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
// 第五步:
// newStartVNode => old 中找到 find,移动 ++newStartIdx oldStart.prev=find find=undefined
// * newStartVNode => old 中找不到 ++newStartIdx oldStart.prev=newStart
// 最后一步,循环结束后
// * ===> old走完,new未走完, newStart~newEnd 依次插入 oldStart.prev



# 循环:四步比较 + undefined + else找到 + [else找不到 + 循环完old完new有]
function patchKeyedChildren(n1, n2, container) {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 初始化:undefined 判断 ===> ++oldStartIdx 或 --oldEndIdx
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
} else {
// 第五步:
// newStartVNode => old 中找到 find,移动 ++newStartIdx oldStart.prev=find find=undefined
// newStartVNode => old 中找不到 ++newStartIdx oldStart.prev=newStart
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container)
insert(vnodeToMove.el, container, oldStartVNode.el)
oldChildren[idxInOld] = undefined
} else {
// 将 newStartVNode 作为新节点挂载到头部,使用当前头部节点 oldStartVNode.el 作为锚点
patch(null, newStartVNode, container, oldStartVNode.el)
}
// 最后更新 newStartIdx 到下一个位置
newStartVNode = newChildren[++newStartIdx]
}
}

// 最后一步,循环结束后
// ===> old走完,new未走完, newStart~newEnd 依次插入 oldStart.prev
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 如果满足条件,则说明有新的节点遗留,需要挂载它们
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVNode.el)
}
}
}

10.5 移除不存在的元素

vue---diff 双端算法 删除元素.png

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
# 分析
// 初始化:undefined 判断 ===> ++oldStartIdx 或 --oldEndIdx
// 第一步:oldStartVNode 和 newStartVNode 比较 ===> 不移动 ++oldStartIdx ++newStartIdx
// 第二步:oldEndVNode 和 newEndVNode 比较 ===> 不移动 --oldEndIdx --newEndIdx
// 第三步:oldStartVNode 和 newEndVNode 比较 ===> 移动 ++oldStartIdx --newEndIdx oldEnd.next=oldStart
// 第四步:oldEndVNode 和 newStartVNode 比较 ===> 移动 --oldEndIdx ++newStartIdx oldStart.prev=oldEnd
// 第五步:
// newStartVNode => old 中找到 find,移动 ++newStartIdx oldStart.prev=find find=undefined
// newStartVNode => old 中找不到 ++newStartIdx oldStart.prev=newStart
// 最后一步,循环结束后
// ===> old走完,new未走完, newStart~newEnd 依次插入 oldStart.prev
// * ===> new走完,old未走完, oldStart~oldEnd 依次 unmount 卸载


# 循环:四步比较 + undefined + else找到 + else找不到 + 循环完old完new有 + [循环完new完old有]
function patchKeyedChildren(n1, n2, container) {
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}

// 最后一步,循环结束后
// ===> old走完,new未走完, newStart~newEnd 依次插入 oldStart.prev
// ===> new走完,old未走完, oldStart~oldEnd 依次 unmount 卸载
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// ...
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
// 移除操作
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
unmount(oldChildren[i])
}
}

}

10.6 总结

1
2
3
4
5
6
7
8
9
# 双端 Diff 算法

# 原理
顾名思义,双端Diff 算法指的是,在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。

# 优势
相比简单 Diff 算法,双端 Diff 算法的优势在于,对于同样的更新场景,执行的 DOM 移动操作次数更少。

【日期标记】2023-07-18 09:45:07 以上同步完成

第11章 快速 Diff 算法

1
速度很快,最早应用于 ivi 和 inferno 这两个框架,Vue.js 3 借鉴并扩展了它。

11.1 相同的前置元素和后置元素

纯文本 Diff 算法

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
借鉴了纯文本 Diff 算法的思路。
在纯文本 Diff 算法中,存在对两段文本进行预处理的过程。

先对它们进行全等比较:
if (text1 === text2) return

预处理过程还会处理两段文本相同的前缀和后缀。
TEXT1: I use vue for app development
TEXT2: I use react for app development

// 对于 TEXT1 和 TEXT2 来说,真正需要进行 Diff 操作的部分是:
TEXT1: vue
TEXT2: react

为什么简化:判断文本的插入和删除(预处理:去除前缀+后缀)


# 新增
TEXT1: I like you
TEXT2: I like you too

// 预处理后可知:TEXT2 在 TEXT1 的基础上增加了字符串 too。
TEXT1:
TEXT2: too

# 删除
TEXT1: I like you too
TEXT2: I like you

// 预处理后可知:TEXT2 是在 TEXT1 的基础上删除了字符串too。
TEXT1: too
TEXT2:

vue---快速 diff 算法 新增元素、删除元素

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
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 处理相同的前置节点
// 索引 j 指向新旧两组子节点的开头
let j = 0
let oldVNode = oldChildren[j]
let newVNode = newChildren[j]
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 更新索引 j,让其递增
j++
oldVNode = oldChildren[j]
newVNode = newChildren[j]
}

// -----------------------------------------------

// 更新相同的后置节点
// 索引 oldEnd 指向旧的一组子节点的最后一个节点
let oldEnd = oldChildren.length - 1
// 索引 newEnd 指向新的一组子节点的最后一个节点
let newEnd = newChildren.length - 1

oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]

// while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 递减 oldEnd 和 nextEnd
oldEnd--
newEnd--
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
}

// -----------------------------------------------

// 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1
// 锚点元素
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor)
}
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++])
}
}

}

11.2 判断是否需要进行 DOM 移动操作

vue---快速 diff 算法 判断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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
function patchKeyedChildren(n1, n2, container) {
// 处理相同的前置节点 ...

// 更新相同的后置节点 ...

// 预处理完毕后
if (j > oldEnd && j <= newEnd) {
// ...新增
} else if (j > newEnd && j <= oldEnd) {
// ...删除
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)// 数组中,每个元素初始值都是 -1

// oldStart 和 newStart 分别为起始索引,即 j
const oldStart = j
const newStart = j

// 新增两个变量,moved 和 pos
let moved = false// 是否需要移动节点
let pos = 0// 遍历旧的一组子节点的过程中遇到的最大索引值 k

// 构建索引表
const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}

// 新增 patched 变量,代表更新过的节点数量
let patched = 0

// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
// 如果更新过的节点数量小于等于需要更新的节点数量,则执行更新
if (patched <= count) {
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
// 调用 patch 函数完成更新
patch(oldVNode, newVNode, container)
// 每更新一个节点,都将 patched 变量 +1
patched++
// 填充 source 数组
source[k - newStart] = i
// 判断节点是否需要移动
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
// 没找到 => old有,new没有
unmount(oldVNode)
}
} else {
// 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
unmount(oldVNode)
}
}
}

}

11.3 如何移动元素

递增子序列

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
# 分析
// source 数组 [2, 3, 1, -1]
// 最长递增子序列 [ 0, 1 ]
// ---------------------------
// 因为 source 数组的最长递增子序列为 [2, 3],
// 其中元素 2 在该数组中的索引为 0,而数组 3 在该数组中的索引为 1,
// 所以最终结果为 [0, 1]。


# 代码 => 复制于 Vue 3 ===> https://cdn.bootcdn.net/ajax/libs/vue/3.2.47/vue.global.js
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
}
else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}

vue---快速 diff 算法 移动元素
移动元素的操作代码位置

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
# 移动元素的操作代码位置
if (j > oldEnd && j <= newEnd) {
// ...新增
} else if (j > newEnd && j <= oldEnd) {
// ...删除
} else {
// ...
for(let i = oldStart; i <= oldEnd; i++) {
// ...判断是否需要移动
}

if (moved) {
// 如果 moved 为真,则需要进行 DOM 移动操作
}
}

# 移动元素代码
if (moved) {
// 计算最长递增子序列
const seq = lis(sources) // [ 0, 1 ]

// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1
// i 指向新的一组子节点的最后一个元素
let i = count - 1
// for 循环使得 i 递减,从下往上移动
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引为 i 的节点是全新的节点,应该将其挂载
// 该节点在新 children 中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点的下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
// 挂载
patch(null, newVNode, container, anchor)
} else if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
// 该节点在新的一组子节点中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点的下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
// 移动
insert(newVNode.el, container, anchor)
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--
}
}
}

快速 diff 算法 => 完整代码

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 处理相同的前置节点
// 索引 j 指向新旧两组子节点的开头
let j = 0
let oldVNode = oldChildren[j]
let newVNode = newChildren[j]
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 更新索引 j,让其递增
j++
oldVNode = oldChildren[j]
newVNode = newChildren[j]
}

// -----------------------------------------------

// 更新相同的后置节点
// 索引 oldEnd 指向旧的一组子节点的最后一个节点
let oldEnd = oldChildren.length - 1
// 索引 newEnd 指向新的一组子节点的最后一个节点
let newEnd = newChildren.length - 1

oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]

// while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 递减 oldEnd 和 nextEnd
oldEnd--
newEnd--
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
}

// -----------------------------------------------

// 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1
// 锚点元素
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor)
}
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++])
}
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)// 数组中,每个元素初始值都是 -1

// oldStart 和 newStart 分别为起始索引,即 j
const oldStart = j
const newStart = j

// 新增两个变量,moved 和 pos
let moved = false// 是否需要移动节点
let pos = 0// 遍历旧的一组子节点的过程中遇到的最大索引值 k

// 构建索引表
const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}

// 新增 patched 变量,代表更新过的节点数量
let patched = 0

// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
// 如果更新过的节点数量小于等于需要更新的节点数量,则执行更新
if (patched <= count) {
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
// 调用 patch 函数完成更新
patch(oldVNode, newVNode, container)
// 每更新一个节点,都将 patched 变量 +1
patched++
// 填充 source 数组
source[k - newStart] = i
// 判断节点是否需要移动
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
// 没找到 => old有,new没有
unmount(oldVNode)
}
} else {
// 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
unmount(oldVNode)
}
}

if (moved) {
// 计算最长递增子序列
const seq = getSequence(source) // [ 0, 1 ]

// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1
// i 指向新的一组子节点的最后一个元素
let i = count - 1
// for 循环使得 i 递减,从下往上移动
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引为 i 的节点是全新的节点,应该将其挂载
// 该节点在新 children 中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点的下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
// 挂载
patch(null, newVNode, container, anchor)
} else if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
// 该节点在新的一组子节点中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点的下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
// 移动
insert(newVNode.el, container, anchor)
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--
}
}
}
}

}

11.4 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
快速 Diff 算法在实测中性能最优。

它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。

当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,
则需要根据节点的索引关系,构造出一个最长递增子序列。
最长递增子序列所指向的节点即为不需要移动的节点。

【日期标记】2023-07-19 09:28:56 以上同步完成

2023-07-21 09:09:41
基于笔记又把 7~11章,重跑了一遍,已提交上面的 [操作笔记 09]
在实操的时候,发现里面也有很多不对的地方,没跑通的地方,又加以改正。

2023-07-21 09:14:18
最多的一篇,并发文章 10104行笔记。
现在已经 8200行笔记。

第12章 组件的实现原理

1
2
3
有了组件,我们就可以将一个大的页面拆分为多个部分,

每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。

12.1 渲染组件

回顾 vnode.type

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
# 不同类型的 vnode.type
const vnode = {
// type: 'div' // 该 vnode 用来描述普通标签
// type: Fragment // 该 vnode 用来描述片段
type: Text // 该 vnode 用来描述文本节点
// ...
}


# patch 函数 => 处理不同类型的 vnode.type
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const { type } = n2

if (typeof type === 'string') {
// 作为普通元素处理
} else if (type === Text) {
// 作为文本节点处理
} else if (type === Fragment) {
// 作为片段处理
}
}

组件的 vnode.type表示

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
# 组件的处理  vnode.type === 'object'
function patch(n1, n2, container, anchor) {
// ...

if (typeof type === 'string') {
// 作为普通元素处理
} else if (type === Text) {
// 作为文本节点处理
} else if (type === Fragment) {
// 作为片段处理
} else if (typeof type === 'object') {
// vnode.type 的值是对象,作为组件来处理
if (!n1) {
// 挂载组件
mountComponent(n2, container, anchor)
} else {
// 更新组件
patchComponent(n1, n2, anchor)
}
}
}

function mountComponent(vnode, container, anchor) {
// 通过 vnode 获取组件的选项对象,即 vnode.type
const componentOptions = vnode.type
// 获取组件的渲染函数 render
const {render} = componentOptions
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
const subTree = render()
// 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
patch(null, subTree, container, anchor)
}



# 测试代码
const MyComponent = {
// 组件名称,可选
name: 'MyComponent',
// 组件的渲染函数,其返回值必须为虚拟 DOM
render() {
// 返回虚拟 DOM
return {
type: 'div',
children: `我是文本内容`
}
}
}

// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const vnode = {
type: MyComponent
}
// 调用渲染器来渲染组件
renderer.render(vnode, document.querySelector('#app'))

12.2 组件状态与自更新

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
# 组件渲染 data 数据
const MyComponent = {
name: 'MyComponent',
// 用 data 函数来定义组件自身的状态
data() {
return {
foo: 'hello world'
}
},
render() {
return {
type: 'div',
children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
}
}
}


# data 处理(数据响应式包装,副作用函数重新渲染,调度器只处理一次)
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions

// 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
const state = reactive(data())

// 将组件的 render 函数调用包装到 effect 内
effect(() => {
// 调用 render 函数时,将其 this 设置为 state,
// 从而 render 函数内部可以通过 this 访问组件自身状态数据
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
}, {
// 无论对响应式数据进行多少次修改,副作用函数都只会重新执行一次
// 指定该副作用函数的调度器为 queueJob 即可
scheduler: queueJob
})
}


# queueJob 函数
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve()

// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job)
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach(job => job())
} finally {
// 重置状态
isFlushing = false
queue.clear = 0
}
})
}
}

12.3 组件实例与组件的生命周期

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
# 分析
上面,我们只进行挂载,而未进行更新,下面我们来进行完善它。
patch(null, subTree, container, anchor)


# 组件实例
组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息。
例如:
- 注册到组件的生命周期函数
- 组件渲染的子树(subTree)
- 组件是否已经被挂载
- 组件自身的状态(data)
- ...

组件实例的 instance.isMounted 属性可以用来区分组件的挂载和更新。
因此,我们可以在合适的时机调用组件对应的生命周期钩子(6个)


# 代码实现
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项对象中取得组件的生命周期函数(6个)
const {render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated} = componentOptions

// 【钩子1】在这里调用 beforeCreate 钩子
beforeCreate && beforeCreate()

const state = reactive(data())

// 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
const instance = {
// 组件自身的状态数据,即 data
state,
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree: null
}

// 将组件实例设置到 vnode 上,用于后续更新
vnode.component = instance

// 【钩子2】在这里调用 created 钩子
created && created.call(state)

effect(() => {
// 调用组件的渲染函数,获得子树
const subTree = render.call(state, state)
// 检查组件是否已经被挂载
if (!instance.isMounted) {
// 【钩子3】在这里调用 beforeMount 钩子
beforeMount && beforeMount.call(state)
// 初次挂载,调用 patch 函数第一个参数传递 null
patch(null, subTree, container, anchor)
// 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
// 而是会执行更新
instance.isMounted = true
// 【钩子4】在这里调用 mounted 钩子
mounted && mounted.call(state)
} else {
// 【钩子5】在这里调用 beforeUpdate 钩子
beforeUpdate && beforeUpdate.call(state)
// 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
// 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
// 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
patch(instance.subTree, subTree, container, anchor)
// 【钩子6】在这里调用 updated 钩子
updated && updated.call(state)
}
// 更新组件实例的子树
instance.subTree = subTree
}, {scheduler: queueJob})
}

12.4 props 与组件的被动更新

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
# 模版
<MyComponent title="A Big Title" :other="val" />


# 对应 vnode
const vnode = {
type: MyComponent,
props: {
title: 'A big Title',
other: this.val
}
}

const MyComponent = {
name: 'MyComponent',
// 组件接收名为 title 的 props,并且该 props 的类型为 String
props: {
title: String
},
render() {
return {
type: 'div',
children: `count is: ${this.title}` // 访问 props 数据
}
}
}

# 处理 props
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项对象中取出 props 定义,即 propsOption
const { render, data, props: propsOption /* 其他省略 */ } = componentOptions

beforeCreate && beforeCreate()

const state = reactive(data())
// 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
const [props, attrs] = resolveProps(propsOption, vnode.props)

const instance = {
state,
// 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
props: shallowReactive(props),
isMounted: false,
subTree: null
}
vnode.component = instance

// 省略部分代码
}

// resolveProps 函数用于解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {// options=内,propsData=外
const props = {} // 外有内有
const attrs = {} // 外有内无
// 遍历为组件传递的 props 数据
for (const key in propsData) {
if (key in options) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
props[key] = propsData[key]
} else {
// 否则将其作为 attrs
attrs[key] = propsData[key]
}
}

// 最后返回 props 与 attrs 数据
return [ props, attrs ]
}

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
# 父组件模版
<template>
<MyComponent :title="title"/>
</template>

# 父组件首次渲染
// 响应式数据 title 的初始值为字符串 "A big Title"
// 首次渲染,父组件的虚拟 DOM

// 父组件要渲染的内容
const vnode = {
type: MyComponent,
props: {
title: 'A Big Title'
}
}

# 父组件第二次渲染
// 响应式数据 title的值变为字符串 "A Small Title"
// 新产生的虚拟 DOM

// 父组件要渲染的内容
const vnode = {
type: MyComponent,
props: {
title: 'A Small Title'
}
}

# 父组件自更新 => 子组件被动更新
# 在更新过程中,渲染器发现父组件的 subTree 包含组件类型的 vnode,调用 patchComponent 进行子组件的更新。
function patch(n1, n2, container, anchor) {
// ...

if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object') {
// vnode.type 的值是选项对象,作为组件来处理
if (!n1) {
mountComponent(n2, container, anchor)
} else {
// 更新组件
patchComponent(n1, n2, anchor)
}
}
}


# patchComponent 子组件更新
父组件自更新所引起的子组件更新叫作子组件的被动更新。
当子组件发生被动更新时,我们需要:
● 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的;
● 如果需要更新,则更新子组件的 props、slots 等内容。

function patchComponent(n1, n2, anchor) {
// 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
const instance = (n2.component = n1.component)
// 获取当前的 props 数据
const {props} = instance
// 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
if (hasPropsChanged(n1.props, n2.props)) {
// 调用 resolveProps 函数重新获取 props 数据
const [nextProps] = resolveProps(n2.type.props, n2.props)
// 更新 props
for (const k in nextProps) {
props[k] = nextProps[k]
}
// 删除不存在的 props
for (const k in props) {
if (!(k in nextProps)) delete props[k]
}
}
}

// 长度不一致 => true
// 任何一个key对应的值不一样 => true
function hasPropsChanged(prevProps, nextProps) {
const nextKeys = Object.keys(nextProps)
// 如果新旧 props 的数量变了,则说明有变化
if (nextKeys.length !== Object.keys(prevProps).length) {
return true
}

for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i]
// 有不相等的 props,则说明有变化
if (nextProps[key] !== prevProps[key]) return true
}
return false
}

渲染上下文对象 renderContext

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
# 代码实现
function mountComponent(vnode, container, anchor) {
// 省略部分代码

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}

vnode.component = instance

// 创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance, {
get(t, k, r) {
// 取得组件自身状态与 props 数据
const { state, props } = t
// 先尝试读取自身状态数据
if (state && k in state) {
return state[k]
} else if (k in props) { // 如果组件自身没有该数据,则尝试从 props 中读取
return props[k]
} else {
console.error('不存在')
}
},
set (t, k, v, r) {// target, key, value, receiver
const { state, props } = t
if (state && k in state) {
state[k] = v
} else if (k in props) {
console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
} else {
console.error('不存在')
}
}
})

// 生命周期函数调用时要绑定渲染上下文对象
created && created.call(renderContext)

// 省略部分代码
}

# 分析
我们为组件实例创建了一个代理对象,该对象即渲染上下文对象。
它的意义在于拦截数据状态的读取和设置操作,每当在渲染函数或生命周期钩子中通过 this 来读取数据时,
都会优先从组件的自身状态中读取,如果组件本身并没有对应的数据,则再从 props 数据中读取。
===> 先data,再props
最后我们将渲染上下文作为渲染函数以及生命周期钩子的 this 值即可。

实际上,除了组件自身的数据以及 props 数据之外,完整的组件还包含methods、computed 等选项中定义的数据和方法。
这些内容都应该在渲染上下文对象中处理。

12.5 setup 函数的作用与实现

setup 简介

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
# Vue 3 新增 setup
组件的 setup 函数是 Vue.js 3 新增的组件选项,它有别于 Vue.js 2 中存在的其他组件选项。
这是因为 setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。
在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况。

(1) 返回一个函数,该函数将作为组件的 render 函数:
const Comp = {
setup() {
// setup 函数可以返回一个函数,该函数将作为组件的渲染函数
return () => {
return { type: 'div', children: 'hello' }
}
}
}

// 这种方式常用于组件不是以模板来表达其渲染内容的情况。
// 如果组件以模板来表达其渲染的内容,那么 setup 函数不可以再返回函数,否则会与模板编译生成的渲染函数产生冲突。

(2) 返回一个对象,该对象中包含的数据将暴露给模板使用:
const Comp = {
setup() {
const count = ref(0)
// 返回一个对象,对象中的数据会暴露到渲染函数中
return {
count
}
},
render() {
// 通过 this 可以访问 setup 暴露出来的响应式数据
return { type: 'div', children: `count is: ${this.count}` }
}
}

// 可以看到,setup 函数暴露的数据可以在渲染函数中通过 this 来访问。


# setup 两个参数
// 第一个参数是 props 数据对象(取得外部为组件传递的 props 数据对象)
// 第二个参数是 setupContext 对象(保存着与组件接口相关的数据和方法)
// ● slots:组件接收到的插槽,我们会在后续章节中讲解。
// ● emit:一个函数,用来发射自定义事件。
// ● attrs:前面12.4说过,当为组件传递 props 时,那些没有显式地声明为 props 的属性会存储到 attrs 对象中(外有内无)。
// ● expose:一个函数,用来显式地对外暴露组件数据。

const Comp = {
props: {
foo: String
},
setup(props, setupContext) {
props.foo // 访问传入的 props 数据
// setupContext 中包含与组件接口相关的重要数据
const { slots, emit, attrs, expose } = setupContext
// ...
}
}

# 不要混用
通常情况下,不建议将 setup 与 Vue.js 2 中其他组件选项混合使用。
例如 data、watch、methods 等选项,我们称之为 “传统”组件选项。
这是因为在 Vue.js 3的场景下,更加提倡组合式 API,setup 函数就是为组合式 API 而生的。

混用会带来语义和理解上的负担。

setup 最小实现

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
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项中取出 setup 函数
let { render, data, setup, /* 省略其他选项 */ } = componentOptions

beforeCreate && beforeCreate()

const state = data ? reactive(data()) : null
const [props, attrs] = resolveProps(propsOption, vnode.props)

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}

// setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要 attrs
const setupContext = { attrs }
// 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
// 将 setupContext 作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// setupState 用来存储由 setup 返回的数据
let setupState = null
// (1) 返回一个函数,该函数将作为组件的 render 函数
// (2) 返回一个对象,该对象中包含的数据将暴露给模板使用
if (typeof setupResult === 'function') {
// 报告冲突
if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
// 将 setupResult 作为渲染函数
render = setupResult
} else {
// 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
setupState = setupResult
}

vnode.component = instance

const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props } = t
if (state && k in state) {
return state[k]
} else if (k in props) {
return props[k]
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
return setupState[k]
} else {
console.error('不存在')
}
},
set (t, k, v, r) {
const { state, props } = t
if (state && k in state) {
state[k] = v
} else if (k in props) {
console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
setupState[k] = v
} else {
console.error('不存在')
}
}
})

// 省略部分代码
}

12.6 组件事件与 emit 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 组件的事件处理
<MyComponent @change="handler" />

const vnode = {
type: MyComponent,
props: {
// 自定义事件 change 被编译成名为 onChange 的属性,并存储在props 数据对象中。
onChange: handler
}
}

const MyComponent = {
name: 'MyComponent',
setup(props, {emit}) {
// 发射 change 事件,并传递给事件处理函数两个参数
emit('change', 1, 2)

return () => {
return // ...
}
}
}

emit 代码实现

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
# setupContext.emit
function mountComponent(vnode, container, anchor) {
// 省略部分代码

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}

// 定义 emit 函数,它接收两个参数
// event: 事件名称
// payload: 传递给事件处理函数的参数
function emit(event, ...payload) {
// 根据约定对事件名称进行处理,例如 change --> onChange
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
// 根据处理后的事件名称去 props 中寻找对应的事件处理函数
const handler = instance.props[eventName]
if (handler) {
// 调用事件处理函数并传递参数
handler(...payload)
} else {
console.error('事件不存在')
}
}

// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
const setupContext = { attrs, emit }

// 省略部分代码
}


# resolveProps 处理事件
function resolveProps(options, propsData) {// options=内,propsData=外
const props = {} // 外有内有 || 外on开头
const attrs = {} // 外有内无
for (const key in propsData) {
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
if (key in options || key.startsWith('on')) {
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}

return [ props, attrs ]
}

12.7 插槽的工作原理与实现

插槽 slot

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
顾名思义,组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入,
如下面给出的 MyComponent 组件的模板所示:
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>


当在父组件中使用 <MyComponent> 组件时,可以根据插槽的名字来插入自定义的内容:
# 父组件模版
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent>

# 父组件渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
footer() {
return { type: 'p', children: '我是注脚' }
}
}
}
}


可以看到,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容。
组件 MyComponent 的模板则会被编译为如下渲染函数:
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
}
]
}

可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。
这与 React 中 render props 的概念非常相似。

setupContext.slots 代码实现

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
# 分析
在运行时的实现上,插槽则依赖于 setupContext 中的 slots 对象。

为了在render 函数内和生命周期钩子函数内能够通过 this.$slots 来访问插槽内容,
我们还需要在 renderContext 中特殊对待 $slots 属性,如下面的代码所示:

我们对渲染上下文 renderContext 代理对象的 get 拦截函数做了特殊处理,
当读取的键是 $slots 时,直接返回组件实例上的 slots 对象,这样用户就可以通过this.$slots 来访问插槽内容了。

# 代码实现
function mountComponent(vnode, container, anchor) {
// 省略部分代码

// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
// 将插槽添加到组件实例上
slots
}

// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }

// 省略部分代码

const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props, slots } = t
// 当 k 的值为 $slots 时,直接返回组件实例上的 slots
if (k === '$slots') return slots

// 省略部分代码
},
set (t, k, v, r) {
// 省略部分代码
}
})

// 省略部分代码
}

12.8 注册生命周期

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
Vue.js 3 生命周期钩子函数,例如 onMounted、onUpdated 等。
import { onMounted } from 'vue'

const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted 1')
})
// 可以注册多个
onMounted(() => {
console.log('mounted 2')
})

// ...
}
}


# currentInstance 分析
setup onMounted 可注册 mounted 钩子函数(可多次注册,组件挂载后被调用)。
A组件 setup 调用
在 A 组件的 setup 函数中调用onMounted 函数会将该钩子函数注册到 A 组件上;
而在 B 组件的 setup 函数中调用 onMounted 函数会将钩子函数注册到 B 组件上,
这是如何实现的呢?

实际上,我们需要维护一个变量 currentInstance,用它来存储当前组件实例,
每当初始化组件并执行组件的 setup 函数之前,先将 currentInstance 设置为当前组件实例,再执行组件的 setup 函数,
这样我们就可以通过 currentInstance 来获取当前正在被初始化的组件实例,从而将那些通过 onMounted 函数注册的钩子函数与组件实例进行关联。


# currentInstance 代码实现
# currentInstance
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}

# onMounted
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}

# mountComponent => mounted 钩子函数演示
function mountComponent(vnode, container, anchor) {
// 省略部分代码

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
// 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
mounted: []
}

// 省略部分代码

// setup
const setupContext = { attrs, emit, slots }

// 在调用 setup 函数之前,设置当前组件实例
setCurrentInstance(instance)
// 执行 setup 函数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null)

// 省略部分代码

effect(() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
// 省略部分代码

// 遍历 instance.mounted 数组并逐个执行即可
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
// 省略部分代码
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}

12.9 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 组件和组件实例
- 使用虚拟节点描述组件,vnode.type属性存储组件对象。
- 组件挂载阶段创建渲染副作用函数,实现组件的自更新。
- 通过自定义调用器实现对渲染任务的去重,避免无用的重新渲染。

# 组件的props与setup函数
- 子组件的更新称为被动更新,依赖副作用自更新引起。
- 渲染上下文(renderContext)是组件实例的代理对象,用于访问组件数据。

# setup函数与emit函数
- setup函数为组合式API设计,不与Vue.js2的组件选项混用。
- setup函数返回渲染函数或数据对象,可通过emit函数发射组件的自定义事件。

# 组件的插槽
- 插槽内容编译为插槽函数,通过执行插槽函数获取填充内容。
- <slot>标签编译为插槽函数调用,渲染填充内容到槽位中。

# 生命周期钩子函数的实现
- 使用onMounted等方法注册的生命周期函数存储在instance.mounted数组中。
- currentInstance和setCurrentInstance维护当前初始化的组件实例。

【日期标记】2023-07-24 10:51:06 以上同步完成

第13章 异步组件与函数式组件

1
2
3
4
5
异步组件:以异步的方式加载并渲染一个组件。

函数式组件:函数的返回值作为组件要渲染的内容。

在 Vue.js 3 中使用函数式组件,主要是因为它的简单性,而不是因为它的性能好。

13.1 异步组件要解决的问题

异步示例

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
# 同步渲染
import App from 'App.vue'
createApp(App).mount('#app')

# 异步渲染(整个页面)
// 动态导入语句 import() 来加载组件,它会返回一个 Promise 实例。
// 组件加载成功后,会调用 createApp 函数完成挂载,这样就实现了以异步的方式来渲染页面。

const loader = () => import('App.vue')
loader().then(App => {
createApp(App).mount('#app')
})

# 异步渲染(部分组件)
<template>
<CompA />
<component :is="asyncComp" />
</template>

<script>
import { shallowRef } from 'vue'
import CompA from 'CompA.vue'// 同步加载 CompA 组件

export default {
components: { CompA },
setup() {
const asyncComp = shallowRef(null)

// 异步加载 CompB 组件
import('CompB.vue').then(CompB => asyncComp.value = CompB)

return {
asyncComp
}
}
}
</script>

异步加载组件

1
2
3
4
5
6
7
8
9
10
11
12
#  考虑点
● 如果组件加载失败或加载超时,是否要渲染 Error 组件?
● 组件在加载时,是否要展示占位的内容?例如渲染一个 Loading 组件。
● 组件加载的速度可能很快,也可能很慢,是否要设置一个延迟展示 Loading 组件的时间?
如果组件在 200ms 内没有加载成功才展示 Loading 组件,这样可以避免由组件加载过快所导致的闪烁。
● 组件加载失败后,是否需要重试?

# 框架实现
● 允许用户指定加载出错时要渲染的组件。
● 允许用户指定 Loading 组件,以及展示该组件的延迟时间。
● 允许用户设置加载组件的超时时长。
● 组件加载失败时,为用户提供重试的能力。

13.2 异步组件的实现原理

13.2.1 封装 defineAsyncComponent 函数

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
# defineAsyncComponent 使用
<template>
<AsyncComp />
</template>

<script>
export default {
components: {
// 使用 defineAsyncComponent 定义一个异步组件,它接收一个加载器作为参数
AsyncComp: defineAsyncComponent(() => import('CompA'))
}
}
</script>

# defineAsyncComponent 基本实现
// defineAsyncComponent 函数用于定义一个异步组件,接收一个异步组件加载器作为参数
function defineAsyncComponent(loader) {
// 一个变量,用来存储异步加载的组件
let InnerComp = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件是否加载成功
const loaded = ref(false)
// 执行加载器函数,返回一个 Promise 实例
// 加载成功后,将加载成功的组件赋值给 InnerComp,并将 loaded 标记为 true,代表加载成功
loader().then(c => {
InnerComp = c
loaded.value = true
})

return () => {
// 如果异步组件加载成功,则渲染该组件,否则渲染一个占位内容
return loaded.value ? { type: InnerComp } : { type: Text, children: '' }
}
}
}
}

13.2.2 超时与 Error 组件

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
# 定义参数 timeout errorComponent
const AsyncComp = defineAsyncComponent({
loader: () => import('CompA.vue'),
timeout: 2000, // 超时时长,其单位为 ms
errorComponent: MyErrorComp // 指定出错时要渲染的组件
})


# 处理参数 timeout errorComponent
function defineAsyncComponent(options) {
// options 可以是配置项,也可以是加载器
if (typeof options === 'function') {
// 如果 options 是加载器,则将其格式化为配置项形式
options = {
loader: options
}
}

const {loader} = options

let InnerComp = null

return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
// 定义 error,当错误发生时,用来存储错误对象
const error = shallowRef(null)

// 添加 catch 语句来捕获加载过程中的错误
loader().then(c => {
InnerComp = c
loaded.value = true
}).catch((err) => error.value = err)


let timer = null
if (options.timeout) {
// 如果指定了超时时长,则开启一个定时器计时
timer = setTimeout(() => {
// 超时后创建一个错误对象,并赋值给 error.value
error.value = new Error(`Async component timed out after ${options.timeout}ms.`)
}, options.timeout)
}
// 包装组件被卸载时清除定时器
onUmounted(() => clearTimeout(timer))

// 占位内容
const placeholder = {type: Text, children: ''}

return () => {
if (loaded.value) {
// 如果组件异步加载成功,则渲染被加载的组件
return {type: InnerComp}
} else if (error.value && options.errorComponent) {
// 只有当错误存在且用户配置了 errorComponent 时才展示 Error 组件,同时将 error 作为 props 传递
return {type: options.errorComponent, props: {error: error.value}}
} else {
return placeholder
}
}
}
}
}

13.2.3 延迟与 Loading 组件

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
# 定义参数 delay loadingComponent
defineAsyncComponent({
loader: () => new Promise(r => { /* ... */ }),
// 延迟 200ms 展示 Loading 组件
delay: 200,
// Loading 组件
loadingComponent: {
setup() {
return () => {
return { type: 'h2', children: 'Loading...' }
}
}
}
})

# 处理参数 delay loadingComponent
function defineAsyncComponent(options) {
if (typeof options === 'function') {
options = {
loader: options
}
}

const { loader } = options

let InnerComp = null

return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = shallowRef(null)
// 一个标志,代表是否正在加载,默认为 false
const loading = ref(false)

let loadingTimer = null
// 如果配置项中存在 delay,则开启一个定时器计时,当延迟到时后将 loading.value 设置为 true
if (options.delay) {
loadingTimer = setTimeout(() => {
loading.value = true
}, options.delay);
} else {
// 如果配置项中没有 delay,则直接标记为加载中
loading.value = true
}
loader()
.then(c => {
InnerComp = c
loaded.value = true
})
.catch((err) => error.value = err)
.finally(() => {
loading.value = false
// 加载完毕后,无论成功与否都要清除延迟定时器
clearTimeout(loadingTimer)
})

let timer = null
if (options.timeout) {
timer = setTimeout(() => {
const err = new Error(`Async component timed out after ${options.timeout}ms.`)
error.value = err
}, options.timeout)
}

const placeholder = { type: Text, children: '' }

return () => {
if (loaded.value) {
return { type: InnerComp }
} else if (error.value && options.errorComponent) {
return { type: options.errorComponent, props: { error: error.value } }
} else if (loading.value && options.loadingComponent) {
// 如果异步组件正在加载,并且用户指定了 Loading 组件,则渲染 Loading 组件
return { type: options.loadingComponent }
} else {
return placeholder
}
}
}
}
}

Loading 组件的卸载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 当异步组件加载成功后,会卸载 Loading 组件并渲染异步加载的组件。
// 为了支持 Loading 组件的卸载,我们需要修改 unmount 函数。

function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// 对于组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree
unmount(vnode.component.subTree)
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}

13.2.4 重试机制

模拟重试

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
封装一个 fetch 函数,用来模拟接口请求:
function fetch() {
return new Promise((resolve, reject) => {
// 请求会在 1 秒后失败
setTimeout(() => {
reject('err')
}, 1000);
})
}

为了实现失败后的重试,我们需要封装一个 load 函数
// load 函数接收一个 onError 回调函数
function load(onError) {
// 请求接口,得到 Promise 实例
const p = fetch()
// 捕获错误
return p.catch(err => {
// 当错误发生时,返回一个新的 Promise 实例,并调用 onError 回调,
// 同时将 retry 函数作为 onError 回调的参数
return new Promise((resolve, reject) => {
// retry 函数,用来执行重试的函数,执行该函数会重新调用 load 函数并发送请求
const retry = () => resolve(load(onError))
const fail = () => reject(err)
onError(retry, fail)
})
})
}


load 函数内部调用了 fetch 函数来发送请求,并得到一个 Promise 实例。
接着,添加 catch 语句块来捕获该实例的错误。
当捕获到错误时,我们有两种选择:要么抛出错误,要么返回一个新的 Promise 实例,并把该实例的 resolve 和 reject 方法暴露给用户,让用户来决定下一步应该怎么做。
这里,我们将新的 Promise 实例的 resolve 和 reject 分别封装为 retry 函数和 fail 函数,并将它们作为onError 回调函数的参数。
这样,用户就可以在错误发生时主动选择重试或直接抛出错误。

下面的代码展示了用户是如何进行重试加载的:
// 调用 load 函数加载资源
load(
// onError 回调
(retry) => {
// 失败后重试
retry()
}
)

defineAsyncComponent 处理重试

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
function defineAsyncComponent(options) {
if (typeof options === 'function') {
options = {
loader: options
}
}

const { loader } = options

let InnerComp = null

// 记录重试次数
let retries = 0
// 封装 load 函数用来加载异步组件
function load() {
return loader()
// 捕获加载器的错误
.catch((err) => {
// 如果用户指定了 onError 回调,则将控制权交给用户
if (options.onError) {
// 返回一个新的 Promise 实例
return new Promise((resolve, reject) => {
// 重试
const retry = () => {
resolve(load())
retries++
}
// 失败
const fail = () => reject(err)
// 作为 onError 回调函数的参数,让用户来决定下一步怎么做
options.onError(retry, fail, retries)
})
} else {
throw error
}
})
}

return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = shallowRef(null)
const loading = ref(false)

let loadingTimer = null
if (options.delay) {
loadingTimer = setTimeout(() => {
loading.value = true
}, options.delay);
} else {
loading.value = true
}
// 调用 load 函数加载组件
load()
.then(c => {
InnerComp = c
loaded.value = true
})
.catch((err) => {
error.value = err
})
.finally(() => {
loading.value = false
clearTimeout(loadingTimer)
})

// 省略部分代码
}
}
}

13.3 函数式组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
一个函数式组件本质上就是一个普通函数,该函数的返回值是虚拟 DOM。
function MyFuncComp(props) {
return { type: 'h1', children: props.title }
}

函数式组件没有自身状态,但它仍然可以接收由外部传入的 props。
为了给函数式组件定义 props,我们需要在组件函数上添加静态的 props 属性。
function MyFuncComp(props) {
return { type: 'h1', children: props.title }
}
// 定义 props
MyFuncComp.props = {
title: String
}

函数式组件的支持

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
# patch
function patch(n1, n2, container, anchor) {
//...

if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object' || typeof type === 'function' ) {
// type 是对象 --> 有状态组件
// type 是函数 --> 函数式组件

// component
if (!n1) {
mountComponent(n2, container, anchor)
} else {
patchComponent(n1, n2, anchor)
}
}
}


# mountComponent
function mountComponent(vnode, container, anchor) {
// 检查是否是函数式组件
const isFunctional = typeof vnode.type === 'function'

let componentOptions = vnode.type
if (isFunctional) {
// 如果是函数式组件,则将 vnode.type 作为渲染函数,将 vnode.type.props 作为 props 选项定义即可
componentOptions = {
render: vnode.type,
props: vnode.type.props
}
}

// 省略部分代码
}

13.4 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 异步组件
- 异步组件解决了页面性能、拆包、服务端下发组件等场景中的问题。
- Vue.js 3提供了defineAsyncComponent函数来定义异步组件。
- 异步组件实现需考虑加载超时、加载错误和展示Loading组件的问题。

# 加载超时和错误处理
- 通过timeout选项设置加载超时时长,加载超时触发错误并渲染Error组件。
- 可通过errorComponent选项指定Error组件。

# 展示Loading组件
- 设计loadingComponent选项允许用户自定义Loading组件。
- 使用delay选项避免Loading组件导致的闪烁问题,延迟展示Loading。

# 异步组件重试机制
- 设计重试机制处理组件加载错误,类似于接口请求的重试机制。

# 函数式组件
- 函数式组件是一个函数,可以复用有状态组件的实现逻辑。
- 允许在函数式组件上添加静态的props属性来定义props。
- 函数式组件没有自身状态和生命周期,需要复用有状态组件的初始化逻辑。

【日期标记】2023-07-24 13:49:40 以上同步完成
===> 把上面的过一遍,稍微知道一下就行了。

第14章 内建组件和模块

14.1 KeepAlive 组件的实现原理

14.1.1 组件的激活与失活

KeepAlive 组件是什么?

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
# KeepAlive 组件作用
KeepAlive 一词借鉴于 HTTP 协议。
在 HTTP 协议中,KeepAlive 又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。
在没有 KeepAlive 的情况下,一个 HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。
频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive 就是为了解决这个问题而生的。

HTTP 中的 KeepAlive 可以避免连接频繁地销毁/创建,
与 HTTP 中的 KeepAlive 类似,Vue.js 内建的 KeepAlive 组件可以避免一个组件被频繁地销毁/重建。

# KeepAlive 组件使用案例
# 无 KeepAlive
根据变量 currentTab 值的不同,会渲染不同的 <Tab> 组件。
当用户频繁地切换 Tab 时,会导致不停地卸载并重建对应的 <Tab> 组件。
<template>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</template>

# 有 KeepAlive
为了避免因此产生的性能开销,可以使用 KeepAlive 组件来解决这个问题。
无论用户怎样切换 <Tab> 组件,都不会发生频繁的创建和销毁,因而会极大地优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。
<template>
<!-- 使用 KeepAlive 组件包裹 -->
<KeepAlive>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</KeepAlive>
</template>

KeepAlive 组件的实现原理

1
2
3
4
5
6
7
8
9
10
11
# KeepAlive 组件的实现原理
KeepAlive 组件的实现原理是怎样的呢?
其实 KeepAlive 的本质是缓存管理,再加上特殊的挂载/卸载逻辑。

# 卸载 原容器 -> 隐藏容器
# 挂载 隐藏容器 -> 原容器
首先,KeepAlive 组件的实现需要渲染器层面的支持。
这是因为被 KeepAlive 的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。
正确的做法是,将被 KeepAlive 的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。
当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。
这个过程对应到组件的生命周期,其实就是 activated 和 deactivated。

activated 和 deactivated


KeepAlive 具体实现

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
const KeepAlive = {
// KeepAlive 组件独有的属性,用作标识
__isKeepAlive: true,
setup(props, { slots }) {
// 创建一个缓存对象 k=vnode.type, v=vnode
const cache = new Map()
// 当前 KeepAlive 组件的实例
const instance = currentInstance
// 对于 KeepAlive 组件来说,它的实例上存在特殊的 keepAliveCtx 对象,该对象由渲染器注入
// 该对象会暴露渲染器的一些内部方法,其中 move 函数用来将一段 DOM 移动到另一个容器中
const { move, createElement } = instance.keepAliveCtx

// 创建隐藏容器
const storageContainer = createElement('div')

// KeepAlive 组件的实例上会被添加两个内部函数,分别是 _deActivate 和 _activate
// 这两个函数会在渲染器中被调用
instance._deActivate = (vnode) => {
move(vnode, storageContainer)
}
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
}

return () => {
// KeepAlive 的默认插槽就是要被 KeepAlive 的组件
let rawVNode = slots.default()
// 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAlive
if (typeof rawVNode.type !== 'object') {
return rawVNode
}

// 在挂载时先获取缓存的组件 vnode
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {
// 如果有缓存的内容,则说明不应该执行挂载,而应该执行激活
// 继承组件实例
rawVNode.component = cachedVNode.component
// 在 vnode 上添加 keptAlive 属性,标记为 true,避免渲染器重新挂载它
rawVNode.keptAlive = true
} else {
// 如果没有缓存,则将其添加到缓存中,这样下次激活组件时就不会执行新的挂载动作了
cache.set(rawVNode.type, rawVNode)
}

// 在组件 vnode 上添加 shouldKeepAlive 属性,并标记为 true,避免渲染器真的将组件卸载
rawVNode.shouldKeepAlive = true
// 将 KeepAlive 组件的实例也添加到 vnode 上,以便在渲染器中访问
rawVNode.keepAliveInstance = instance

// 渲染组件 vnode
return rawVNode
}
}
}

参数解释

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
KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,我们把这个需要被 KeepAlive 的组件称为“内部组件”。
KeepAlive 组件会对“内部组件”的 vnode 对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。

● shouldKeepAlive:该属性会被添加到“内部组件”的 vnode 对象上,这样当渲染器卸载“内部组件”时,可以通过检查该属性得知“内部组件”需要被KeepAlive。
于是,渲染器就不会真的卸载“内部组件”,而是会调用_deActivate 函数完成搬运工作。
● keepAliveInstance:“内部组件”的 vnode 对象会持有 KeepAlive 组件实例,在 unmount 函数中会通过 keepAliveInstance 来访问 _deActivate 函数。
// 卸载操作
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该被 KeepAlive
if (vnode.shouldKeepAlive) {
// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件,
// 即 KeepAlive 组件的 _deActivate 函数使其失活
vnode.keepAliveInstance._deActivate(vnode)
} else {
unmount(vnode.component.subTree)
}
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
● keptAlive:“内部组件”如果已经被缓存,则还会为其添加一个 keptAlive 标记。
这样当“内部组件”需要重新渲染时,渲染器并不会重新挂载它,而会将其激活。
function patch(n1, n2, container, anchor) {
//...

if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object' || typeof type === 'function') {
// component
if (!n1) {
// 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用 _activate 来激活它
if (n2.keptAlive) {
n2.keepAliveInstance._activate(n2, container, anchor)
} else {
mountComponent(n2, container, anchor)
}
} else {
patchComponent(n1, n2, anchor)
}
}
}

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
# 回顾 [失活 / 激活]
const { move, createElement } = instance.keepAliveCtx

// 失活
instance._deActivate = (vnode) => {
move(vnode, storageContainer)
}
// 激活
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
}


# mountComponent 特殊处理 KeepAlive
function mountComponent(vnode, container, anchor) {
// 省略部分代码

const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
mounted: [],
// 只有 KeepAlive 组件的实例下会有 keepAliveCtx 属性
keepAliveCtx: null
}

// 检查当前要挂载的组件是否是 KeepAlive 组件
const isKeepAlive = vnode.type.__isKeepAlive
if (isKeepAlive) {
// 在 KeepAlive 组件实例上添加 keepAliveCtx 对象
instance.keepAliveCtx = {
// move 函数用来移动一段 vnode
move(vnode, container, anchor) {
// 本质上是将组件渲染的内容移动到指定容器中,即隐藏容器中
insert(vnode.component.subTree.el, container, anchor)
},
createElement
}
}

// 省略部分代码
}

14.1.2 include 和 exclude

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
在默认情况下,KeepAlive 组件会对所有“内部组件”进行缓存。
但为了使用户能够自定义缓存规则,我们让 KeepAlive 组件支持两个 props,分别是 include 和exclude。

const KeepAlive = {
__isKeepAlive: true,
// 定义 include 和 exclude
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// 省略部分代码

return () => {
let rawVNode = slots.default()
if (typeof rawVNode.type !== 'object') {
return rawVNode
}
// 获取“内部组件”的 name
const name = rawVNode.type.name
// 对 name 进行匹配
if (
name &&
(
// 如果 name 无法被 include 匹配
(props.include && !props.include.test(name)) ||
// 或者被 exclude 匹配
(props.exclude && props.exclude.test(name))
)
) {
// 则直接渲染“内部组件”,不对其进行后续的缓存操作
return rawVNode
}

// 省略部分代码
}
}
}

14.1.3 缓存管理

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
# 回顾之前代码
// KeepAlive 组件的渲染函数中关于缓存的实现

// 使用组件选项对象 rawVNode.type 作为键去缓存中查找
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {
// 如果缓存存在,则无须重新创建组件实例,只需要继承即可
rawVNode.component = cachedVNode.component
rawVNode.keptAlive = true
} else {
// 如果缓存不存在,则设置缓存
cache.set(rawVNode.type, rawVNode)
}


# 缓存上限
<KeepAlive :max="2">
<component :is="dynamicComp"/>
</KeepAlive>

使用 LRU 来实现,类似 Java LinkedHashMap。


# 自定义缓存 => 缓存管理交给用户
<KeepAlive :cache="cache">
<Comp />
</KeepAlive>

// 自定义实现
const _cache = new Map()
const cache: KeepAliveCache = {
get(key) {
_cache.get(key)
},
set(key, value) {
_cache.set(key, value)
},
delete(key) {
_cache.delete(key)
},
forEach(fn) {
_cache.forEach(fn)
}
}

14.2 Teleport 组件的实现原理

14.2.1 Teleport 组件要解决的问题

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
# DOM层级结构一致问题
通常情况下,在将虚拟 DOM 渲染为真实 DOM 时,最终渲染出来的真实 DOM 的层级结构与虚拟 DOM 的层级结构一致。

在这段模板中,<Overlay> 组件的内容会被渲染到 id 为 box 的 div 标签下。然而,有时这并不是我们所期望的。
假设 <Overlay> 是一个“蒙层”组件,该组件会渲染一个“蒙层”,并要求“蒙层”能够遮挡页面上的任何元素。
换句话说,我们要求 <Overlay> 组件的 z-index 的层级最高,从而实现遮挡。
但问题是,如果 <Overlay> 组件的内容无法跨越 DOM 层级渲染,就无法实现这个目标。
还是拿上面这段模板来说,id 为 box 的 div 标签拥有一段内联样式:z-index:-1,这导致即使我们将 <Overlay> 组件所渲染内容的 z-index 值设置为无穷大,也无法实现遮挡功能。
<template>
<div id="box" style="z-index: -1;">
<Overlay />
</div>
</template>

# body标签下渲染蒙层
通常,我们在面对上述场景时,会选择直接在 <body> 标签下渲染“蒙层”内容。
在 Vue.js 2 中我们只能通过原生 DOM API 来手动搬运 DOM 元素实现需求。
这么做的缺点在于,手动操作 DOM 元素会使得元素的渲染与 Vue.js 的渲染机制脱节,并导致各种可预见或不可预见的问题。
考虑到该需求的确非常常见,用户对此也抱有迫切的期待,于是 Vue.js 3 内建了 Teleport 组件。
该组件可以将指定内容渲染到特定容器中,而不受 DOM 层级的限制。
<template>
<!-- 表示要渲染到 body 下面 -->
<Teleport to="body">
<div class="overlay"></div>
</Teleport>
</template>
<style scoped>
.overlay {
z-index: 9999;
}
</style>

14.2.2 实现 Teleport 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Teleport 组件   抽取代码的好处:
● 可以避免渲染器逻辑代码“膨胀”;
● 当用户没有使用 Teleport 组件时,由于 Teleport 的渲染逻辑被分离。
因此可以利用 TreeShaking 机制在最终的 bundle 中删除Teleport 相关的代码,使得最终构建包的体积变小。

# 多个子节点
# 模版
<Teleport to="body">
<h1>Title</h1>
<p>content</p>
</Teleport>

# 虚拟DOM
// 通常,一个组件的子节点会被编译为插槽内容,不过对于 Teleport 组件来说,直接将其子节点编译为一个数组即可
function render() {
return {
type: Teleport,
// 以普通 children 的形式代表被 Teleport 的内容
children: [
{ type: 'h1', children: 'Title' },
{ type: 'p', children: 'content' }
]
}
}

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
# patch => Teleport组件
function patch(n1, n2, container, anchor) {
//...

if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object' && type.__isTeleport) {
// 组件选项中如果存在 __isTeleport 标识,则它是 Teleport 组件,
// 调用 Teleport 组件选项中的 process 函数将控制权交接出去
// 传递给 process 函数的第五个参数是渲染器的一些内部方法
type.process(n1, n2, container, anchor, {
patch,
patchChildren,
unmount,
// 用来移动被 Teleport 的内容
move(vnode, container, anchor) {
// 移动组件 移动普通元素
insert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
}
})
} else if (typeof type === 'object' || typeof type === 'function') {
// 省略部分代码
}
}


# Teleport 代码实现
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
// 在这里处理渲染逻辑

// 通过 internals 参数取得渲染器的内部方法
const { patch, patchChildren, move } = internals
// 如果旧 VNode n1 不存在,则是全新的挂载,否则执行更新
if (!n1) {
// 挂载
// 获取容器,即挂载点
const target = typeof n2.props.to === 'string'
? document.querySelector(n2.props.to)
: n2.props.to
// 将 n2.children 渲染到指定挂载点即可
n2.children.forEach(c => patch(null, c, target, anchor))
} else {
// 更新
patchChildren(n1, n2, container)

// 如果新旧 to 参数的值不同,则需要对内容进行移动
if (n2.props.to !== n1.props.to) {
// 获取新的容器
const newTarget = typeof n2.props.to === 'string'
? document.querySelector(n2.props.to)
: n2.props.to
// 移动到新的容器
n2.children.forEach(c => move(c, newTarget))
}
}
}
}

14.3 Transition 组件的实现原理

1
2
3
# Transition 组件的实现原理
● 当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
● 当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM 元素上的动效执行完成后再卸载它。

14.3.1 原生 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
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
# 过渡
要知 Transition 组件的实现原理,先知如何为原生 DOM 创建过渡动效。
过渡效果本质上是一个 DOM 元素在两种状态间的切换,浏览器会根据过渡效果自行完成 DOM 元素的过渡。
过渡效果指的是持续时长、运动曲线、要过渡的属性等。

# 举个例子 => 进场 / 离场
现在,假设我们要为元素添加一个进场动效。
我们可以这样描述该动效:从距离左边 200px 的位置在 1 秒内运动到距离左边 0px 的位置。

<style type="text/css">
.box {width: 100px;height: 100px;background-color: red;}

/*靠近*/
/* from 初始状态:“距离左边 200px”*/
/* to 结束状态:“距离左边 0px”*/
/* active 运动过程:运动的属性transform,持续时长1s,运动曲线ease-in-out*/
.enter-from {transform: translateX(200px);}
.enter-to {transform: translateX(0);}
.enter-active {transition: transform 1s ease-in-out;}

/*离开*/
.leave-from {transform: translateX(0);}
.leave-to {transform: translateX(200px);}
.leave-active {transition: transform 2s ease-out;}
</style>

<div class="box"></div>

<script>
window.onload = function () {
// 创建 class 为 box 的 DOM 元素
const el = document.createElement('div')
el.classList.add('box')

// 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
el.classList.add('enter-from') // 初始状态
el.classList.add('enter-active') // 运动过程

// 将元素添加到页面
document.body.appendChild(el)

// 在下一帧切换元素的状态
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to

// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})

// 卸载元素 => 立即卸载
// el.addEventListener('click', () => {
// el.parentNode.removeChild(el)
// })

// 卸载元素 => 动画卸载
el.addEventListener('click', () => {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => el.parentNode.removeChild(el)

// 设置初始状态:添加 leave-from 和 leave-active 类
el.classList.add('leave-from')
el.classList.add('leave-active')

// 强制 reflow:使初始状态生效
document.body.offsetHeight

// 在下一帧切换状态
requestAnimationFrame(() => {
// 切换到结束状态
el.classList.remove('leave-from')
el.classList.add('leave-to')

// 监听 transitionend 事件做收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 当过渡完成后,记得调用 performRemove 函数将 DOM 元素移除
performRemove()
})
})
})
})
}
</script>

1
2
3
4
5
6
7
从创建 DOM 元素完成后,到把 DOM 元素添加到 body 前,整个过程可以视作 beforeEnter 阶段。
在把 DOM 元素添加到 body 之后,则可以视作 enter 阶段。

在不同的阶段执行不同的操作,即可完成整个进场过渡的实现。
● beforeEnter 阶段:添加 enter-from 和 enter-active 类。
● enter 阶段:在下一帧中移除 enter-from 类,添加 enter-to。
● 进场动效结束:移除 enter-to 和 enter-active 类。

在这里插入图片描述

14.3.2 实现 Transition 组件

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
112
113

# 模版
<template>
<Transition>
<div>我是需要过渡的元素</div>
</Transition>
</template>

# 虚拟DOM
// Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致。
function render() {
return {
type: Transition,
children: {
default() {
return { type: 'div', children: '我是需要过渡的元素' }
}
}
}
}


# 代码实现
const Transition = {
name: 'Transition',
setup(props, {slots}) {
return () => {
const innerVNode = slots.default()

innerVNode.transition = {
beforeEnter(el) {
// 设置初始状态:添加 enter-from 和 enter-active 类
el.classList.add('enter-from')
el.classList.add('enter-active')
},
enter(el) {
// 在下一帧切换到结束状态
nextFrame(() => {
// 移除 enter-from 类,添加 enter-to 类
el.classList.remove('enter-from')
el.classList.add('enter-to')
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
},
leave(el, performRemove) {
// 设置离场过渡的初始状态:添加 leave-from 和 leave-active 类
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制 reflow,使得初始状态生效
document.body.offsetHeight
// 在下一帧修改状态
nextFrame(() => {
// 移除 leave-from 类,添加 leave-to 类
el.classList.remove('leave-from')
el.classList.add('leave-to')

// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 调用 transition.leave 钩子函数的第二个参数,完成 DOM 元素的卸载
performRemove()
})
})
}
}

return innerVNode
}
}
}

# 挂载
function mountElement(vnode, container, anchor) {
//...

// 判断一个 VNode 是否需要过渡
const needTransition = vnode.transition
if (needTransition) {
// 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
vnode.transition.beforeEnter(el)
}

insert(el, container, anchor)

if (needTransition) {
// 调用 transition.enter 钩子,并将 DOM 元素作为参数传递
vnode.transition.enter(el)
}
}

# 卸载
function unmount(vnode) {
// ...
const parent = vnode.el.parentNode
if (parent) {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => parent.removeChild(vnode.el)
// 判断 VNode 是否需要过渡处理
if (vnode.transition) {
// 如果需要过渡处理,则调用 transition.leave 钩子,
// 同时将 DOM 元素和 performRemove 函数作为参数传递
vnode.transition.leave(vnode.el, performRemove)
} else {
// 如果不需要过渡处理,则直接执行卸载操作
performRemove()
}
}
}

14.4 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# KeepAlive 组件
- KeepAlive组件类似于HTTP中的持久链接,避免组件实例不断被销毁和重建。
- 实现原理:将卸载的组件搬运到隐藏容器,再挂载时从隐藏容器搬运到原容器。
- include和exclude选项用来指定哪些组件需要被KeepAlive。
- 缓存策略可定制,默认采用“最新一次访问”。

# Teleport 组件
- Teleport 跨越DOM层级完成渲染,逻辑分离,实现代码精简与Tree-Shaking优化。
- 特殊组件选项:__isTeleport和process。

# Transition 组件
- Transition实现原理类似于为原生DOM添加过渡效果。
- 将过渡相关的钩子函数定义到虚拟节点的vnode.transition对象中。
- 在挂载和卸载时执行vnode.transition中定义的过渡相关钩子函数。

【日期标记】2023-07-24 17:44:39 以上同步完成

第五篇 编译器

第15章 编译器核心技术概览

1
2
3
4
5
6
7
8

编译技术是一门庞大的学科,不用用途难度不同。

实现诸如 C、JavaScript 这类通用用途语言(general purpose language),需掌握较多编译技术。
例如,理解上下文无关文法,使用巴科斯范式(BNF),扩展巴科斯范式(EBNF)书写语法规则,完成语法推导,理解和消除左递归,递归下降算法,甚至类型系统方面的知识等。

作为前端,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。
Vue.js 的模板和 JSX 都属于领域特定语言,属于中下难度,掌握基本编译理论即可实现。

15.1 模板 DSL 的编译器

  • 完整的编译过程
    完整的编译过程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 源代码 => 编译器 => 目标代码
    编译器是一段程序,把源代码(source code)翻译为目标代码(target code),这个翻译的过程叫做编译(compile)。

    # 整个编译过程:编译前端 + 编译后端
    编译前端:词法分析 + 语法分析 + 语义分析
    - 与目标平台无关,仅分析源代码。

    编译后端:中间代码生成 + 优化 + 目标代码生成
    - 与目标平台有关
    - 不一定会包含 [中间代码生成+优化],这取决于场景和实现
    - [中间代码生成+优化] 也叫“中端”

  • 模版 DSL 编译器
    模版 DSL 编译器
    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
    # Vue.js模版编译器
    # 源代码
    <div>
    <h1 :id="dynamicId">Vue Template</h1>
    </div>

    ↓↓↓ Vue.js 模版编译器 ↓↓↓

    # 目标代码(js)
    function render() {
    return h('div', [
    h('h1', { id: dynamicId }, 'Vue Template')
    ])
    }


    # 编译完整流程
    AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。

    const template = `
    <div>
    <h1 v-if="ok">Vue Template</h1>
    </div>
    `

    // 三部分:解析器,转换器,代码生成器
    const templateAST = parse(template)
    const jsAST = transform(templateAST)
    const code = generate(jsAST)// 渲染函数的代码(字符串形式返回)

    # 模版AST => 语义分析
    有了模板 AST 后,我们就可以对其进行语义分析,并对模板 AST 进行转换了。
    什么是语义分析呢?举几个例子。
    ● 检查 v-else 指令是否存在相符的 v-if 指令。
    ● 分析属性值是否是静态的,是否是常量等。
    ● 插槽是否会引用上层作用域的变量。
    ● ……

    # 模版AST 样例
    const templateAST = {
    // 逻辑根节点
    type: 'Root',
    children: [
    // div 标签节点
    {
    type: 'Element',
    tag: 'div',
    children: [
    // h1 标签节点
    {
    type: 'Element',
    tag: 'h1',
    props: [
    // v-if 指令节点
    {
    type: 'Directive', // 类型为 Directive 代表指令
    name: 'if', // 指令名称为 if,不带有前缀 v-
    exp: {
    // 表达式节点
    type: 'Expression',
    content: 'ok'
    }
    }
    ]
    }
    ]
    }
    ]
    }

15.2 parser 的实现原理与状态机

解析器 parser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 三部分
模版 => [解析器] => 模版AST => [转换器] => jsAST => [代码生成器] => code

# 解析器
<p>Vue</p>

解析器会把这段字符串模板切割为三个 Token。
● 开始标签:<p>。
● 文本节点:Vue。
● 结束标签:</p>。

# 例如
const tokens = tokenize(`<p>Vue</p>`)
// [
// { type: 'tag', name: 'p' }, // 开始标签
// { type: 'text', content: 'Vue' }, // 文本节点
// { type: 'tagEnd', name: 'p' } // 结束标签
// ]

有限状态机 初步实现

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// 定义状态机的状态
const State = {
initial: 1, // 初始状态
tagOpen: 2, // 标签开始状态
tagName: 3, // 标签名称状态
text: 4, // 文本状态
tagEnd: 5, // 结束标签状态
tagEndName: 6 // 结束标签名称状态
}

// 一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}

// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
// switch 语句匹配当前状态
switch (currentState) {
// 初始状态
case State.initial:
// 遇到字符 <
if (char === '<') {
// 1. 状态机切换到标签开始状态
currentState = State.tagOpen
// 2. 消费字符 <
str = str.slice(1)
} else if (isAlpha(char)) {
// 1. 遇到字母,切换到文本状态
currentState = State.text
// 2. 将当前字母缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 标签开始状态
case State.tagOpen:
if (isAlpha(char)) {
// 1. 遇到字母,切换到标签名称状态
currentState = State.tagName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
} else if (char === '/') {
// 1. 遇到字符 /,切换到结束标签状态
currentState = State.tagEnd
// 2. 消费字符 /
str = str.slice(1)
}
break
// 标签名称状态
case State.tagName:
if (isAlpha(char)) {
// 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,
// 但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1.遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 同时创建一个标签 Token,并添加到 tokens 数组中
// 注意,此时 chars 数组中缓存的字符就是标签名称
tokens.push({
type: 'tag',
name: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 同时消费当前字符 >
str = str.slice(1)
}
break
// 文本状态
case State.text:
if (isAlpha(char)) {
// 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '<') {
// 1. 遇到字符 <,切换到标签开始状态
currentState = State.tagOpen
// 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组
// 注意,此时 chars 数组中的字符就是文本内容
tokens.push({
type: 'text',
content: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
// 标签结束状态
case State.tagEnd:
if (isAlpha(char)) {
// 1. 遇到字母,切换到结束标签名称状态
currentState = State.tagEndName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
// 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1. 遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token
// 注意,此时 chars 数组中缓存的内容就是标签名称
tokens.push({
type: 'tagEnd',
name: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
}// switch结束
}// while结束

// 最后,返回 tokens
return tokens
}// tokenize结束

测试与分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 测试
const tokens = tokenize(`<p>Vue</p>`)
console.log(JSON.stringify(tokens))
/*
[
{"type":"tag","name":"p"},
{"type":"text","content":"Vue"},
{"type":"tagEnd","name":"p"}
]
*/


# 分析
# 模版字符串 => 有限状态自动机 => Token数组
通过有限自动机,我们能够将模板解析为一个个 Token,进而可以用它们构建一棵 AST 了。
但在具体构建 AST 之前,我们需要思考能否简化 tokenize 函数的代码。

# 正则表达式 <=> 有限自动机
实际上,我们可以通过正则表达式来精简 tokenize 函数的代码。
上文之所以没有从最开始就采用正则表达式来实现,是因为正则表达式的本质就是有限自动机。
当你编写正则表达式的时候,其实就是在编写有限自动机。

15.3 构造 AST

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
通用用途语言(GPL)
领域特定语言(DSL)


# templateAST 表示
<div><p>Vue</p><p>Template</p></div>

Root -> Element(div)
-> Element(p) -> Text(Vue)
-> Element(p) -> Text(Template)

const templateAST = {
// AST 的逻辑根节点
type: 'Root',
children: [
// 模板的 div 根节点
{
type: 'Element',
tag: 'div',
children: [
// div 节点的第一个子节点 p
{
type: 'Element',
tag: 'p',
// p 节点的文本节点
children: [
{
type: 'Text',
content: 'Vue'
}
]
},
// div 节点的第二个子节点 p
{
type: 'Element',
tag: 'p',
// p 节点的文本节点
children: [
{
type: 'Text',
content: 'Template'
}
]
}
]
}
]
}

vue---扫描 Token 列表并构建 AST

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
# 回顾tokenize的使用
const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
const tokens = [
{type: "tag", name: "div"}, // div 开始标签节点
{type: "tag", name: "p"}, // p 开始标签节点
{type: "text", content: "Vue"}, // 文本节点
{type: "tagEnd", name: "p"}, // p 结束标签节点
{type: "tag", name: "p"}, // p 开始标签节点
{type: "text", content: "Template"}, // 文本节点
{type: "tagEnd", name: "p"}, // p 结束标签节点
{type: "tagEnd", name: "div"} // div 结束标签节点
]

# parse 解析函数
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: 'Root',
children: []
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]

// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
while (tokens.length) {
// 获取当前栈顶节点作为父节点 parent
const parent = elementStack[elementStack.length - 1]
// 当前扫描的 Token
const t = tokens[0]
switch (t.type) {
case 'tag':
// 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
}
// 将其添加到父级节点的 children 中
parent.children.push(elementNode)// 尾插
// 将当前节点压入栈
elementStack.push(elementNode)// 尾插
break
case 'text':
// 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
const textNode = {
type: 'Text',
content: t.content
}
// 将其添加到父节点的 children 中
parent.children.push(textNode)// 尾插
break
case 'tagEnd':
// 遇到结束标签,将栈顶节点弹出
elementStack.pop()// 尾删
break
}
// 消费已经扫描过的 token
tokens.shift()// 头删
}

// 最后返回 AST
return root
}

# 测试代码
const templateAST = parse(`<div><p>Vue</p><p>Template</p></div>`)
console.log(JSON.stringify(templateAST))
/*
{
"type": "Root",
"children":
[
{
"type": "Element",
"tag": "div",
"children":
[
{
"type": "Element",
"tag": "p",
"children":[{"type":"Text","content":"Vue"}]
},
{
"type": "Element",
"tag": "p",
"children":[{"type":"Text","content":"Template"}]
}
]
}
]
}
*/

15.4 AST 的转换与插件化架构

1
2
# 再回顾
模版 => [解析器] => 模版AST => [转换器] => jsAST => [代码生成器] => code

15.4.1 节点的访问

dump 打印节点信息

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
# dump函数 打印节点信息
function dump(node, indent = 0) {
// 节点的类型
const type = node.type
// 节点的描述,如果是根节点,则没有描述
// 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述
// 如果是 Text 类型的节点,则使用 node.content 作为节点的描述
const desc = node.type === 'Root'
? ''
: node.type === 'Element'
? node.tag
: node.content

// 打印节点的类型和描述信息
console.log(`${'-'.repeat(indent)}${type}: ${desc}`)

// 递归地打印子节点
if (node.children) {
node.children.forEach(n => dump(n, indent + 2))
}
}

# 测试代码
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
dump(ast)
/*
Root:
--Element: div
----Element: p
------Text: Vue
----Element: p
------Text: Template
*/

p变为h1、文本内容重复两次

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
# traverseNode函数 => 深度优先
function traverseNode(ast) {
// 当前节点,ast 本身就是 Root 节点
const currentNode = ast

// 对当前节点进行操作
if (currentNode.type === 'Element' && currentNode.tag === 'p') {
// 将所有 p 标签转换为 h1 标签
currentNode.tag = 'h1'
}

// 如果节点的类型为 Text
if (currentNode.type === 'Text') {
// 重复其内容两次,这里我们使用了字符串的 repeat() 方法
currentNode.content = currentNode.content.repeat(2)
}

// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}

# transform函数
// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {
// 调用 traverseNode 完成转换
traverseNode(ast)
// 打印 AST 信息
dump(ast)
}

# 测试代码
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
/*
Root:
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate
*/

解耦:抽取函数

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
# traverseNode => nodeTransforms使用
// 接收第二个参数 context
function traverseNode(ast, context) {
const currentNode = ast

// context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
transforms[i](currentNode, context)
}

const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i], context)
}
}
}

# 抽取函数
function transformElement(node) {
if (node.type === 'Element' && node.tag === 'p') {
node.tag = 'h1'
}
}

function transformText(node) {
if (node.type === 'Text') {
node.content = node.content.repeat(2)
}
}

# transform => nodeTransforms定义
function transform(ast) {
// 在 transform 函数内创建 context 对象
const context = {
// 注册 nodeTransforms 数组
nodeTransforms: [
transformElement, // transformElement 函数用来转换标签节点
transformText // transformText 函数用来转换文本节点
]
}
// 调用 traverseNode 完成转换
traverseNode(ast, context)
// 打印 AST 信息
dump(ast)
}

15.4.2 转换上下文与节点操作

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

# 添加五个参数:currentNode childIndex parent replaceNode removeNode
function transform(ast) {
const context = {
currentNode: null,// 当前正在转换的节点
childIndex: 0,// 当前节点在父节点的 children 中的位置索引
parent: null,// 当前转换节点的父节点

// 用于替换节点的函数,接收新节点作为参数
replaceNode(node) {
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent.children[context.childIndex] = node
// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
context.currentNode = node
},

// 用于删除当前节点。
removeNode() {
if (context.parent) {
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent.children.splice(context.childIndex, 1)
// 将 context.currentNode 置空
context.currentNode = null
}
},

nodeTransforms: [
transformElement,
transformText
]
}

traverseNode(ast, context)
dump(ast)
}

# 参数处理
function traverseNode(ast, context) {
// 设置当前转换的节点信息 context.currentNode
context.currentNode = ast

const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context)

// 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
// 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
if (!context.currentNode) return
}

const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归地调用时,将 context 透传
traverseNode(children[i], context)
}
}
}

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
# 测试1:replaceNode 替换节点
// 转换函数的第二个参数就是 context 对象
function transformText(node, context) {
if (node.type === 'Text') {
// 如果当前转换的节点是文本节点,则调用 context.replaceNode 函数将其替换为元素节点
context.replaceNode({
type: 'Element',
tag: 'span'
})
}
}

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
/*
Root:
--Element: div
----Element: h1
------Element: span
----Element: h1
------Element: span
*/



# 测试2:removeNode 删除节点
// 转换函数的第二个参数就是 context 对象
function transformText(node, context) {
if (node.type === 'Text') {
// 如果是文本节点,直接调用 context.removeNode 函数将其移除即可
context.removeNode()
}
}

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
/*
Root:
--Element: div
----Element: h1
----Element: h1
*/

15.4.3 进入与退出

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
上面,我们处理完子节点,就不能再处理父节点了。
我们需要加以改进,让处理完子节点,再处理父节点。

# traverseNode => 支持退出函数的回调
function traverseNode(ast, context) {
context.currentNode = ast
// 1. 增加退出阶段的回调函数数组
const exitFns = []
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数
const onExit = transforms[i](context.currentNode, context)
if (onExit) {
// 将退出阶段的回调函数添加到 exitFns 数组中
exitFns.push(onExit)// 尾插
}
if (!context.currentNode) return
}

const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = context.currentNode
context.childIndex = i
traverseNode(children[i], context)
}
}

// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}

# transformElement 退出节点的回调函数
function transformElement(node, context) {
// 进入节点

// 返回一个会在退出节点时执行的回调函数
return () => {
// 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换节点的子节点一定处理完毕了
}
}

# 测试执行顺序
function transformA(node) {
console.log("transformA 进入阶段执行")

// 返回一个会在退出节点时执行的回调函数
return () => {
// 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换节点的子节点一定处理完毕了
console.log("transformA 退出阶段执行")
}
}

function transformB(node) {
console.log("transformB 进入阶段执行")
return () => {
console.log("transformB 退出阶段执行")
}
}

function transform(ast) {
const context = {
// 省略部分代码

// 注册两个转换函数,transformA 先于 transformB
nodeTransforms: [
transformA,
transformB
]
}

traverseNode(ast, context)
dump(ast)
}

/*----------------------------------------*/

const ast = parse(`<div></div>`)
transform(ast)
/*
transformA 进入阶段执行
transformB 进入阶段执行
transformA 进入阶段执行
transformB 进入阶段执行
transformB 退出阶段执行
transformA 退出阶段执行
transformB 退出阶段执行
transformA 退出阶段执行
Root:
--Element: div

// =======如果将 transformA 与 transformB 的顺序调换

transformB 进入阶段执行
transformA 进入阶段执行
transformB 进入阶段执行
transformA 进入阶段执行
transformA 退出阶段执行
transformB 退出阶段执行
transformA 退出阶段执行
transformB 退出阶段执行
Root:
--Element: div
*/

15.5 将模板 AST 转为 JavaScript AST

FunctionDeclNode 函数声明节点的表示(基础版)

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
# 模版
<div><p>Vue</p><p>Template</p></div>

# js代码
function render() {
return h('div', [
h('p', 'Vue'),
h('p', 'Template')
])
}
// 我们需要的是与 js代码相对应的 jsAST,要先搞懂 js函数的组成

# js函数声明的组成
● id:函数名称,它是一个标识符 Identifier。
● params:函数的参数,它是一个数组。
● body:函数体,由于函数体可以包含多个语句,因此它也是一个数组。

# 函数声明节点 基础版(暂不考虑箭头函数、生成器函数、async 函数等情况)
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
return: null // 暂时留空,在后续讲解中补全
}
]
}

h函数的调用表示,与参数表示

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
        const CallExp = {
type: 'CallExpression',
// 被调用函数的名称,它是一个标识符
callee: {
type: 'Identifier',
name: 'h'
},
// 参数
arguments: []
}

我们再次观察渲染函数的返回值:
function render() {
// h 函数的第一个参数是一个字符串字面量
// h 函数的第二个参数是一个数组
return h('div', [/*...*/])
}

最外层的 h 函数的第一个参数是一个字符串字面量,我们可以使用类型为 StringLiteral 的节点来描述它:
const Str = {
type: 'StringLiteral',
value: 'div'
}

最外层的 h 函数的第二个参数是一个数组,我们可以使用类型为ArrayExpression 的节点来描述它:
const Arr = {
type: 'ArrayExpression',
// 数组中的元素
elements: []
}

使用上述 CallExpression、StringLiteral、ArrayExpression 等节点来填充渲染函数的返回值
const FunctionDeclNode = {
type: 'FunctionDecl' // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
// 最外层的 h 函数调用
return: {
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 第一个参数是字符串字面量 'div'
{
type: 'StringLiteral',
value: 'div'
},
// 第二个参数是一个数组
{
type: 'ArrayExpression',
elements: [
// 数组的第一个元素是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Vue' },
]
},
// 数组的第二个元素也是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Template' },
]
}
]
}
]
}
}
]
}

创建辅助函数 createStringLiteral createIdentifier createArrayExpression createCallExpression

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
编写转换函数,将模板 AST 转换为上述 JavaScript AST。
开始前,需写一些用来创建 JavaScript AST 节点的辅助函数。
// 用来创建 StringLiteral 节点
function createStringLiteral(value) {
return {
type: 'StringLiteral',
value
}
}
// 用来创建 Identifier 节点
function createIdentifier(name) {
return {
type: 'Identifier',
name
}
}
// 用来创建 ArrayExpression 节点
function createArrayExpression(elements) {
return {
type: 'ArrayExpression',
elements
}
}
// 用来创建 CallExpression 节点
function createCallExpression(callee, arguments) {
return {
type: 'CallExpression',
callee: createIdentifier(callee),
arguments
}
}

转换函数 transformElement transformText

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
模板 AST 转换为 JavaScript AST,还需两个转换函数:transformElement(处理标签节点) 和 transformText(处理文本节点)。

注意:
● 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调函数内,这样才能保证其子节点全部被处理完毕;
● 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点都存储在节点的 node.jsNode 属性下。

// 转换标签节点
function transformElement(node) {
// 将转换代码编写在退出阶段的回调函数中,
// 这样可以保证该标签节点的子节点全部被处理完毕
return () => {
// 如果被转换的节点不是元素节点,则什么都不做
if (node.type !== 'Element') {
return
}

// 1. 创建 h 函数调用语句,
// h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点
// 作为第一个参数
const callExp = createCallExpression('h', [
createStringLiteral(node.tag)
])
// 2. 处理 h 函数调用的参数
node.children.length === 1
// 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数
? callExp.arguments.push(node.children[0].jsNode)
// 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数
: callExp.arguments.push(
// 数组的每个元素都是子节点的 jsNode
createArrayExpression(node.children.map(c => c.jsNode))
)
// 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下
node.jsNode = callExp
}
}

// 转换文本节点
function transformText(node) {
// 如果不是文本节点,则什么都不做
if (node.type !== 'Text') {
return
}
// 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,
// 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可
// 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下
node.jsNode = createStringLiteral(node.content)
}

根节点处理 transformRoot

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
最后一步,补全 JavaScript AST,即把用来描述render 函数本身的函数声明语句节点附加到 JavaScript AST 中。
这需要我们编写 transformRoot 函数来实现对 Root 根节点的转换:

// 转换 Root 根节点
function transformRoot(node) {
// 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
return () => {
// 如果不是根节点,则什么都不做
if (node.type !== 'Root') {
return
}
// node 是根节点,根节点的第一个子节点就是模板的根节点,
// 当然,这里我们暂时不考虑模板存在多个根节点的情况
const vnodeJSAST = node.children[0].jsNode
// 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句
node.jsNode = {
type: 'FunctionDecl',
id: { type: 'Identifier', name: 'render' },
params: [],
body: [
{
type: 'ReturnStatement',
return: vnodeJSAST
}
]
}
}
}


# 添加转换函数 transformElement transformText transformRoot
function transform(ast) {
const context = {
//...

// 注册 nodeTransforms 数组
nodeTransforms: [
transformElement, // transformElement 函数用来转换标签节点
transformText, // transformText 函数用来转换文本节点
transformRoot
]
}

// ...
}

15.6 代码生成

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
代码生成本质上是字符串拼接的艺术。
我们需要访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。

代码生成也是编译器的最后一步:
function compile(template) {
// 模板 AST
const ast = parse(template)

// 将模板 AST 转换为 JavaScript AST
transform(ast)

// 代码生成
const code = generate(ast.jsNode)

return code
}


与 AST 转换一样,代码生成也需要上下文对象,用来维护代码生成过程中程序的运行状态。
function generate(node) {
const context = {
// 存储最终生成的渲染函数代码
code: '',
// 在生成代码时,通过调用 push 函数完成代码的拼接
push(code) {
context.code += code
},
// 当前缩进的级别,初始值为 0,即没有缩进
currentIndent: 0,
// 该函数用来换行,即在代码字符串的后面追加 \n 字符,
// 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符
newline() {
context.code += '\n' + ` `.repeat(context.currentIndent)
},
// 用来缩进,即让 currentIndent 自增后,调用换行函数
indent() {
context.currentIndent++
context.newline()
},
// 取消缩进,即让 currentIndent 自减后,调用换行函数
deIndent() {
context.currentIndent--
context.newline()
}
}

// 调用 genNode 函数完成代码生成的工作
genNode(node, context)

// 返回渲染函数代码
return context.code
}

genNode 代码实现

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
# genNode
function genNode(node, context) {
switch (node.type) {
case 'FunctionDecl':
genFunctionDecl(node, context)
break
case 'ReturnStatement':
genReturnStatement(node, context)
break
case 'CallExpression':
genCallExpression(node, context)
break
case 'StringLiteral':
genStringLiteral(node, context)
break
case 'ArrayExpression':
genArrayExpression(node, context)
break
}
}

# genNodeList
// 在节点代码之间补充逗号字符
// 如果节点数组为 const node = [节点1, 节点2, 节点3]
// 那么生成的代码将类似于 '节点1, 节点2, 节点3'
// 如果在这段代码的前后分别添加小括号,那么它将可用于函数的参数声明 '(节点1, 节点2, 节点3)'
// 如果在这段代码的前后分别添加中括号,那么它将是一个数组 '[节点1, 节点2, 节点3]'
function genNodeList(nodes, context) {
const {push} = context
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
genNode(node, context)
if (i < nodes.length - 1) {
push(', ')
}
}
}

# 函数声明
function genFunctionDecl(node, context) {
// 从 context 对象中取出工具函数
const {push, indent, deIndent} = context
// node.id 是一个标识符,用来描述函数的名称,即 node.id.name
push(`function ${node.id.name} `)
push(`(`)
// 调用 genNodeList 为函数的参数生成代码
genNodeList(node.params, context)
push(`) `)
push(`{`)
// 缩进
indent()
// 为函数体生成代码,这里递归地调用了 genNode 函数
node.body.forEach(n => genNode(n, context))
// 取消缩进
deIndent()
push(`}`)
}

# 数组
function genArrayExpression(node, context) {
const {push} = context
// 追加方括号
push('[')
// 调用 genNodeList 为数组元素生成代码
genNodeList(node.elements, context)
// 补全方括号
push(']')
}

# 函数返回值
function genReturnStatement(node, context) {
const {push} = context
// 追加 return 关键字和空格
push(`return `)
// 调用 genNode 函数递归地生成返回值代码
genNode(node.return, context)
}

# 字符串
function genStringLiteral(node, context) {
const {push} = context
// 对于字符串字面量,只需要追加与 node.value 对应的字符串即可
push(`'${node.value}'`)
}

# 函数调用
function genCallExpression(node, context) {
const {push} = context
// 取得被调用函数名称和参数列表
const {callee, arguments: args} = node
// 生成函数调用代码
push(`${callee.name}(`)
// 调用 genNodeList 生成参数代码
genNodeList(args, context)
// 补全括号
push(`)`)
}

# 测试代码
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
const code = generate(ast.jsNode)
// function render () {
// return h('div', [h('p', 'Vue'), h('p', 'Template')])
// }

15.7 总结

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
# Vue.js 模板编译器工作流程
- 将模板编译为渲染函数
- 工作流程分为三个步骤:
(1) 分析模板,将其解析为模板 AST
(2) 将模板 AST 转换为 JavaScript AST,描述渲染函数
(3) 根据 JavaScript AST 生成渲染函数代码

# Parser 实现原理与词法分析器
- 使用有限状态自动机构造词法分析器
- 通过状态机产生 Token 列表,用于构造模板 AST
- 使用开始标签栈维护节点父子关系

# AST 转换与插件化架构
- 使用深度优先遍历访问 AST 节点,实现节点转换操作。
- 插件化架构封装节点操作,注册转换函数到 context.nodeTransforms
- 上下文对象 context 维护访问状态,实现节点的替换、删除等能力
- 转换过程分为“进入阶段”与“退出阶段”,优先完成子节点的转换

# 模板 AST 转换为 JavaScript AST
- 实现模板 AST 到 JavaScript AST 的转换,为生成渲染函数做准备。

# 渲染函数代码生成
- 最后一步,生成渲染函数代码。
- 使用字符串拼接,为不同 AST 节点编写对应的代码生成函数。
- 提供代码缩进和换行功能,使生成的代码更具可读性。

【日期标记】2023-07-25 17:07:21 以上同步完成
【日期标记】2023-07-26 08:42:49 以上同步完成 实操了一遍

第16章 解析器

1
2
3
4
5
6
解析器(parser)本质上是一个状态机。
正则表达式其实也是一个状态机。
编写 parser 利用正则表达式能够让我们少写不少代码。

关于 HTML 文本的解析,是有规范可循的,即WHATWG 关于 HTML 的解析规范。
其中定义了完整的错误处理和状态机的状态迁移流程,还提及了一些特殊的状态,例如 DATA、CDATA、RCDATA、RAWTEXT 等。

16.1 文本模式及其对解析器的影响

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
解析器遇到特殊标签,会切换模式,从而影响解析行为。
●<title> 标签、<textarea> 标签,切换到 RCDATA 模式;
●<style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript> <script> 等标签,切换到 RAWTEXT 模式;
●当解析器遇到 <![CDATA[ 字符串时,会进入 CDATA 模式。

# DATA 初始模式
遇到 < 切换 标签开始状态(tag open state),处理标签元素。
遇到 & 切换 字符引用状态(character reference state),处理HTML字符实体。

# RCDATA
遇到 < 切换 RCDATA less-than sign state 状态。
遇到 / 切换 RCDATA 结束标签状态(end tag open state)
否则将当前字符 < 作为普通字符处理,然后继续处理后面的字符。

由此可知,在 RCDATA 状态下,解析器不能识别标签元素。
间接说明了在 <textarea> 内可以将字符 < 作为普通文本,解析器并不会认为字符 < 是标签开始的标志。
(textarea 标签内都会作为字符串处理,不会作为 HTML 渲染)
<textarea>
<div>asdf</div>asdfasdf
</textarea>
遇到 & 切换 字符引用状态(character reference state)
(textarea 标签内 &copy; 会显示 ©)
<textarea>&copy;</textarea>

# RAWTEXT
解析器会将 HTML 实体字符作为普通字符处理

例如:遇到 <script> 标签时进入 RAWTEXT 模式,会把 <script> 标签内的内容全部作为普通文本处理。

# CDATA
解析器将把任何字符都作为普通字符处理,直到遇到CDATA 的结束标志为止。


# 不同的模式及其特性
模式 是否解析标签 是否支持HTML实体
DATA 是 是
RCDATA 否 是
RAWTEXT 否 否
CDATA 否 否

16.2 递归下降算法构造模板 AST

解析器 基本模型

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
从现在开始,我们将着手实现一个更加完善的模板解析器。

# 解析器的基本架构模型
// 定义文本模式,作为一个状态表
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
CDATA: 'CDATA'
}

// 解析器函数,接收模板作为参数
function parse(str) {
// 定义上下文对象
const context = {
// source 是模板内容,用于在解析过程中进行消费
source: str,
// 解析器当前处于文本模式,初始模式为 DATA
mode: TextModes.DATA
}

// 【整个解析器的核心,本质上也是一个状态机】
// 调用 parseChildren 函数开始进行解析,它返回解析后得到的子节点
// 第一个参数:上下文对象 context
// 第二个参数:由父代节点构成的栈,用于维护节点间的父子级关系
const nodes = parseChildren(context, [])

// 解析器返回 Root 根节点
return {
type: 'Root',
// 使用 nodes 作为根节点的 children
children: nodes
}
}

parseChildren 函数在解析模板过程中的状态迁移过程
parseChildren 函数在解析模板过程中的状态迁移过程

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
# 元素节点
在模板中,元素的子节点可以是以下几种。
●标签节点,例如 <div>。
●文本插值节点,例如 {{ val }}。
●普通文本节点,例如:text。
●注释节点,例如 <!---->。
●CDATA 节点,例如 <![CDATA[ xxx ]]>。

# parseChildren
function parseChildren(context, ancestors) {
// 定义 nodes 数组存储子节点,它将作为最终的返回值
let nodes = []
// 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
const {mode, source} = context

// 开启 while 循环,只要满足条件就会一直对字符串进行解析,遇到父级节点的结束标签才会停止
// 关于 isEnd() 后面会详细讲解
while (!isEnd(context, ancestors)) {
let node
// 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
// 只有 DATA 模式才支持标签节点的解析
if (mode === TextModes.DATA && source[0] === '<') {
if (source[1] === '!') {
if (source.startsWith('<!--')) {
// 注释
node = parseComment(context)
} else if (source.startsWith('<![CDATA[')) {
// CDATA
node = parseCDATA(context, ancestors)
}
} else if (source[1] === '/') {
// 结束标签,这里需要抛出错误,后文会详细解释原因
} else if (/[a-z]/i.test(source[1])) {
// 标签
node = parseElement(context, ancestors)
}
} else if (source.startsWith('{{')) {
// 解析插值
node = parseInterpolation(context)
}
}

// node 不存在,说明处于其他模式,即非 DATA 模式且非 RCDATA 模式
// 这时一切内容都作为文本处理
if (!node) {
// 解析文本节点
node = parseText(context)
}

// 将节点添加到 nodes 数组中
nodes.push(node)
}

// 当 while 循环停止后,说明子节点解析完毕,返回子节点
return nodes
}

# parseElement 三步走:[parseTag + parseChildren + parseEndTag]
// 一个完整的标签元素(不是自闭合标签)的三部分 [开始标签 + 子节点 + 结束标签]
function parseElement() {
// 解析开始标签
const element = parseTag()
// 解析子节点:这里递归地调用 parseChildren 函数进行 <div> 标签子节点的解析
element.children = parseChildren()
// 解析结束标签
parseEndTag()

return element
}

举个栗子
举个栗子

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
# 模版
const template = `<div>
<p>Text1</p>
<p>Text2</p>
</div>`

# 处理流程
# 表示(为了方便演示 +换行符 -空格符)
在解析模板时,我们不能忽略空白字符。这些空白字符包括:换行符(\n)、回车符(\r)、空格(' ')、制表符(\t)以及换页符(\f)。
如果我们用加号(+)代表换行符,用减号(-)代表空格字符。
const template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`

# 消费 <div>
parseTag 解析函数执行完毕后,会消费字符串中的内容 <div>,处理后的模板内容将变为:
const template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`

# 消费子节点后
子节点处理后的模板内容将变为:
const template = `</div>`

# 状态机2 模版内容
为了解析标签的子节点,我们递归地调用了 parseChildren 函数。
这意味着,一个新的状态机开始运行了,我们称其为“状态机 2”。
“状态机 2”所处理的模板内容为:
const template = `+--<p>Text1</p>+--<p>Text2</p>+`

# 状态机2 ===> 消费 +--
遇到第一个字符 +(+代表换行符),会进入文本节点状态,并调用 parseText 函数完成文本节点的解析。
parseText 函数会将下一个 < 字符之前的所有字符都视作文本节点的内容。
换句话说,parseText 函数会消费模板内容 +--,并产生一个文本节点。
在 parseText 解析函数执行完毕后,剩下的模板内容为:
const template = `<p>Text1</p>+--<p>Text2</p>+`

# 状态机2 ===> 消费 <p>Text1</p>
接着,parseChildren 函数继续执行。此时模板的第一个字符为 <,并且下一个字符能够匹配正则 /a-z/i。
于是解析器再次进入parseElement 解析函数的执行阶段,这会消费模板内容<p>Text1</p>。
在这一步过后,剩下的模板内容为:
const template = `+--<p>Text2</p>+`

# 状态机2 ===> 消费 +--
可以看到,此时模板的第一个字符是换行符,于是调用 parseText 函数消费模板内容 +--。
现在,模板中剩下的内容是:
const template = `<p>Text2</p>+`

# 状态机2 ===> 消费 <p>Text2</p>
解析器会再次调用 parseElement 函数处理标签节点。
在这之后,剩下的模板内容为:
const template = `+`

# 状态机2 ===> 消费 + 状态机2停止
可以看到,现在模板内容只剩下一个换行符了。
parseChildren 函数会继续执行并调用 parseText 函数消费剩下的内容,并产生一个文本节点。
最终,模板被解析完毕,“状态机 2”停止运行。

# 递归 parseChildren => parseElement => parseChildren
在“状态机 2”运行期间,为了处理标签节点,我们又调用了两次parseElement 函数。
第一次调用用于处理内容 <p>Text1</p>,第二次调用用于处理内容 <p>Text2</p>。
我们知道,parseElement 函数会递归地调用 parseChildren 函数完成子节点的解析,这就意味着解析器会再开启了两个新的状态机。

# 递归 下降
通过上述例子我们能够认识到,parseChildren 解析函数是整个状态机的核心,状态迁移操作都在该函数内完成。
在 parseChildren 函数运行过程中,为了处理标签节点,会调用 parseElement 解析函数,这会间接地调用 parseChildren 函数,并产生一个新的状态机。
随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是“递归下降”中“递归”二字的含义。

而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级 parseChildren 函数则用于构造下级模板 AST 节点。
最终,会构造出一棵树型结构的模板 AST,这就是“递归下降”中“下降”二字的含义。

16.3 状态机的开启与停止

初步认识 isEnd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 标签压栈 与 状态机停止 
遇到开始标签时,会将该标签压入父级节点栈,同时开启新的状态机。
遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时,会停止当前正在运行的状态机

# isEnd
function isEnd(context, ancestors) {
// 当模板内容解析完毕后,停止
if (!context.source) return true

// 获取父级标签节点
// 如果遇到结束标签,并且该标签与父级标签节点同名,则停止
const parent = ancestors[ancestors.length - 1]
if (parent && context.source.startsWith(`</${parent.tag}`)) {
return true
}
}

错误的标签处理

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
例如:<div><span></div></span>



# 1.(不推荐) 得到错误信息:“无效的结束标签”
function parseChildren(context, ancestors) {
// ...
} else if (source[1] === '/') {
// 结束标签,这里需要抛出错误,后文会详细解释原因

// 避免:<div><span></div></span>
// 状态机遭遇了闭合标签,此时应该抛出错误,因为它缺少与之对应的开始标签
console.error('无效的结束标签')
continue
}
// ...
}

# 2.(推荐) “完整的内容”部分被解析完毕后,解析器就会打印错误信息:“<span> 标签缺少闭合标签”
# isEnd 改造
function isEnd(context, ancestors) {
if (!context.source) return true

// 与父级节点栈内所有节点做比较
for (let i = ancestors.length - 1; i >= 0; --i) {
// 只要栈中存在与当前结束标签同名的节点,就停止状态机
if (context.source.startsWith(`</${ancestors[i].tag}`)) {
return true
}
}
}

# parseElement 压栈出栈 + 缺少闭合标签
function parseElement(context, ancestors) {
const element = parseTag(context)
if (element.isSelfClosing) return element

ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()

if (context.source.startsWith(`</${element.tag}`)) {
parseTag(context, 'end')
} else {
// 缺少闭合标签
console.error(`${element.tag} 标签缺少闭合标签`)
}

return element
}

16.4 解析标签节点

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
112
113
114
115
116
117
118
119
120
121
122
123
124
# 回顾
function parseElement(context, ancestors) {
// 调用 parseTag 函数解析开始标签
const element = parseTag(context)
if (element.isSelfClosing) return element

ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()

if (context.source.startsWith(`</${element.tag}`)) {
// 再次调用 parseTag 函数解析结束标签,传递了第二个参数:'end'
parseTag(context, 'end')
} else {
// 缺少闭合标签
console.error(`${element.tag} 标签缺少闭合标签`)
}

return element
}





function parse(str) {
const context = {
// ...

// advanceBy 函数用来消费指定数量的字符,它接收一个数字作为参数
advanceBy(num) {
// 根据给定字符数 num,截取位置 num 后的模板内容,并替换当前模板内容
context.source = context.source.slice(num)
},
// 无论是开始标签还是结束标签,都可能存在无用的空白字符,例如 <div >
advanceSpaces() {
// 匹配空白字符
const match = /^[\t\r\n\f ]+/.exec(context.source)
if (match) {
// 调用 advanceBy 函数消费空白字符
context.advanceBy(match[0].length)
}
}
}

// ...
}


# parseTag
// 由于 parseTag 既用来处理开始标签,也用来处理结束标签,因此我们设计第二个参数 type
// 用来代表当前处理的是开始标签还是结束标签,type 的默认值为 'start',即默认作为开始标签处理
function parseTag(context, type = 'start') {
// 从上下文对象中拿到 advanceBy 函数
const { advanceBy, advanceSpaces } = context

// 处理开始标签和结束标签的正则表达式不同
const match = type === 'start'
// 匹配开始标签
// ●对于字符串 '<div>',会匹配出字符串 '<div',剩余 '>'
// ●对于字符串 '<div/>',会匹配出字符串 '<div',剩余 '/>'
// ●对于字符串 '<div---->',其中减号(-)代表空白符,会匹配出字符串 '<div',剩余 '---->'
? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
// 匹配结束标签
: /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
// 匹配成功后,正则表达式的第一个捕获组的值就是标签名称
const tag = match[1]
// 消费正则表达式匹配的全部内容,例如 '<div' 这段内容
advanceBy(match[0].length)
// 消费标签中无用的空白字符
advanceSpaces()

// 在消费匹配的内容后,如果字符串以 '/>' 开头,则说明这是一个自闭合标签
const isSelfClosing = context.source.startsWith('/>')
// 如果是自闭合标签,则消费 '/>', 否则消费 '>'
advanceBy(isSelfClosing ? 2 : 1)

// 返回标签节点
return {
type: 'Element',
// 标签名称
tag,
// 标签的属性暂时留空
props: [],
// 子节点留空
children: [],
// 是否自闭合
isSelfClosing
}
}



# parseElement
function parseElement(context, ancestors) {
const element = parseTag(context)
if (element.isSelfClosing) return element

// 切换到正确的文本模式
if (element.tag === 'textarea' || element.tag === 'title') {
// 如果由 parseTag 解析得到的标签是 <textarea> 或 <title>,则切换到 RCDATA 模式
context.mode = TextModes.RCDATA
} else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
// 如果由 parseTag 解析得到的标签是:
// <style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript>
// 则切换到 RAWTEXT 模式
context.mode = TextModes.RAWTEXT
} else {
// 否则切换到 DATA 模式
context.mode = TextModes.DATA
}

ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()

if (context.source.startsWith(`</${element.tag}`)) {
parseTag(context, 'end')
} else {
console.error(`${element.tag} 标签缺少闭合标签`)
}

return element
}

16.5 解析属性

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# 带属性的标签
<div id="foo" v-show="display"/>

# parseTag ===> 解析属性
function parseTag(context, type = 'start') {
const { advanceBy, advanceSpaces } = context

const match = type === 'start'
? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
: /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
const tag = match[1]

advanceBy(match[0].length)
advanceSpaces()
// 调用 parseAttributes 函数完成属性与指令的解析,并得到 props 数组,
// props 数组是由指令节点与属性节点共同组成的数组
const props = parseAttributes(context)

const isSelfClosing = context.source.startsWith('/>')
advanceBy(isSelfClosing ? 2 : 1)

return {
type: 'Element',
tag,
props, // 将 props 数组添加到标签节点上
children: [],
isSelfClosing
}
}



# parseAttributes v1 简洁版
function parseAttributes(context) {
// 用来存储解析过程中产生的属性节点和指令节点
const props = []

// 开启 while 循环,不断地消费模板内容,直至遇到标签的“结束部分”为止
while (
!context.source.startsWith('>') &&
!context.source.startsWith('/>')
) {
// 解析属性或指令
}
// 将解析结果返回
return props
}


# v2 特殊处理
// ===> 空格处理
// id="foo" v-show="display" >
// id = "foo" v-show="display" >
// ===> 引号问题
// ○属性值被双引号包裹:id="foo"
// ○属性值被单引号包裹:id='foo'
// ○属性值没有引号包裹:id=foo。

function parseAttributes(context) {
const { advanceBy, advanceSpaces } = context
const props = []

while (
!context.source.startsWith('>') &&
!context.source.startsWith('/>')
) {
// 该正则用于匹配属性名称
const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)
// 得到属性名称
const name = match[0]

// 消费属性名称
advanceBy(name.length)
// 消费属性名称与等于号之间的空白字符
advanceSpaces()
// 消费等于号
advanceBy(1)
// 消费等于号与属性值之间的空白字符
advanceSpaces()

// 属性值
let value = ''

// 获取当前模板内容的第一个字符
const quote = context.source[0]
// 判断属性值是否被引号引用
const isQuoted = quote === '"' || quote === "'"

if (isQuoted) {
// 属性值被引号引用,消费引号
advanceBy(1)
// 获取下一个引号的索引
const endQuoteIndex = context.source.indexOf(quote)
if (endQuoteIndex > -1) {
// 获取下一个引号之前的内容作为属性值
value = context.source.slice(0, endQuoteIndex)
// 消费属性值
advanceBy(value.length)
// 消费引号
advanceBy(1)
} else {
// 缺少引号错误
console.error('缺少引号')
}
} else {
// 代码运行到这里,说明属性值没有被引号引用
// 下一个空白字符之前的内容全部作为属性值
const match = /^[^\t\r\n\f >]+/.exec(context.source)
// 获取属性值
value = match[0]
// 消费属性值
advanceBy(value.length)
}
// 消费属性值后面的空白字符
advanceSpaces()

// 使用属性名称 + 属性值创建一个属性节点,添加到 props 数组中
props.push({
type: 'Attribute',
name,
value
})

}
// 返回
return props
}

Attribute 与 Directive

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
# case 1
<div id="foo" v-show="display"></div>

const ast = {
type: 'Root',
children: [
{
type: 'Element'
tag: 'div',
props: [
// 属性
{ type: 'Attribute', name: 'id', value: 'foo' },
{ type: 'Attribute', name: 'v-show', value: 'display' }
]
}
]
}

# case 2
<div :id="dynamicId" @click="handler" v-on:mousedown="onMouseDown" ></div>

const ast = {
type: 'Root',
children: [
{
type: 'Element'
tag: 'div',
props: [
// 属性
{ type: 'Attribute', name: ':id', value: 'dynamicId' },
{ type: 'Attribute', name: '@click', value: 'handler' },
{ type: 'Attribute', name: 'v-on:mousedown', value: 'onMouseDown' }
]
}
]
}

# 指令绑定
// 指令,类型为 Directive
{ type: 'Directive', name: 'v-on:mousedown', value: 'onMouseDown' }
{ type: 'Directive', name: '@click', value: 'handler' }
// 普通属性
{ type: 'Attribute', name: 'id', value: 'foo' }

16.6 解析文本与解码 HTML 实体

16.6.1 解析文本

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
解析完标签,下面就是解析文本


# <
const template = '<div>Text</div>'

const template = 'Text</div>'
第一个 < 字符索引值 4,parseText 截取索引 [0, 4) 也就是 'Text'

# {{
const template = 'Text-{{ val }}</div>'
parseText 截取 [0, 5) 也就是 'Text-'
因为在 < 前,先碰到 插值定界符 {{。

# parseText
function parseText(context) {
// endIndex 为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容
let endIndex = context.source.length
// 寻找字符 < 的位置索引
const ltIndex = context.source.indexOf('<')
// 寻找定界符 {{ 的位置索引
const delimiterIndex = context.source.indexOf('{{')

// 取 ltIndex 和当前 endIndex 中较小的一个作为新的结尾索引
if (ltIndex > -1 && ltIndex < endIndex) {
endIndex = ltIndex
}
// 取 delimiterIndex 和当前 endIndex 中较小的一个作为新的结尾索引
if (delimiterIndex > -1 && delimiterIndex < endIndex) {
endIndex = delimiterIndex
}

// 此时 endIndex 是最终的文本内容的结尾索引,调用 slice 函数截取文本内容
const content = context.source.slice(0, endIndex)
// 消耗文本内容
context.advanceBy(content.length)

// 返回文本节点
return {
// 节点类型
type: 'Text',
// 文本内容
content
}
}

# 代码测试
const ast = parse(`<div>Text</div>`)

const ast = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
props: [],
isSelfClosing: false,
children: [
// 文本节点
{ type: 'Text', content: 'Text' }
]
}
]
}

16.6.2 解码命名字符引用

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
# &lt; 变 <
<div>A&lt;B</div>
<div>A<B</div>

# Web字符引用
早期,可以省略尾分号, &lt; 可以 写成 &lt
后来,字符集很多,WHATWG 规定不加分号,解析错误
但,考虑历史(网上存在大量省略分号的情况,现代浏览器都可以解析省略分号的HTML)

# &
命名字符引用(named character reference),也叫命名实体(named entity)
例如:&lt;

WHATWG 规范中的共2000+
{
"GT": ">",
"gt": ">",
"LT": "<",
"lt": "<",
// ...
"blk34;": "▓",
"block;": "█",
"boxDL;": "╗",
"boxDl;": "╖",
"boxdL;": "╕",
// ...
}

# &# &#x
数字字符引用(numeric character reference)

(二者都与 &lt; 等价)
十进制 &#60;
十六进制 &#x3c;

# textContent 原样显示
el.textContent = '&lt;'

el.textContent 设置的文本内容是不会经过 HTML 实体解码的。
最终 el 的文本内容将会原封不动地呈现为字符串 '&lt;',而不会呈现字符 <。

# 最短原则
# case 1
a&ltb 解析为 a<b

遇到 & 进入字符引用状态
&l 有存在的
&lt 也有存在的
&ltb 无存在的,匹配结束

匹配结束,匹配为 &lt 最后一个符号 t,不是分号,解析错误,采用最短原则。

# case 2
a&ltcc; 解析为 a⪦

# case 3
a&ltcc 解析为 a<cc 在匹配的 &ltcc 中, &lt 的名称要短于 &ltcc(最短原则)

# case 4
a&ltccbbb 解析为 a<ccbbb(原理同 case 3)

# 属性字符引用
<a href="foo.com?a=1&lt=2">foo.com?a=1&lt=2</a>
属性 &lt 原封不动显示(若显示为 <,会破坏用户 URL)
内容 &lt 展示为 <

代码

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
# parseText
function parseText(context) {
// 省略部分代码

return {
type: 'Text',
content: decodeHtml(content) // 调用 decodeHtml 函数解码内容
}
}

# decodeHtml => HTML解码
// 第一个参数为要被解码的文本内容
// 第二个参数是一个布尔值,代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr = false) {
let offset = 0
const end = rawText.length
// 经过解码后的文本将作为返回值被返回
let decodedText = ''
// 引用表中实体名称的最大长度
let maxCRNameLength = 0

// advance 函数用于消费指定长度的文本
function advance(length) {
offset += length
rawText = rawText.slice(length)
}

// 消费字符串,直到处理完毕为止
while (offset < end) {
// 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值将有三种可能:
// 1. head[0] === '&',这说明该字符引用是命名字符引用
// 2. head[0] === '&#',这说明该字符引用是用十进制表示的数字字符引用
// 3. head[0] === '&#x',这说明该字符引用是用十六进制表示的数字字符引用
const head = /&(?:#x?)?/i.exec(rawText)
// 如果没有匹配,说明已经没有需要解码的内容了
if (!head) {
// 计算剩余内容的长度
const remaining = end - offset
// 将剩余内容加到 decodedText 上
decodedText += rawText.slice(0, remaining)
// 消费剩余内容
advance(remaining)
break
}

// head.index 为匹配的字符 & 在 rawText 中的位置索引
// 截取字符 & 之前的内容加到 decodedText 上
decodedText += rawText.slice(0, head.index)
// 消费字符 & 之前的内容
advance(head.index)

// 如果满足条件,则说明是命名字符引用,否则为数字字符引用
if (head[0] === '&') {
let name = ''
let value
// 字符 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
if (/[0-9a-z]/i.test(rawText[1])) {
// 根据引用表计算实体名称的最大长度,
if (!maxCRNameLength) {
maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
(max, name) => Math.max(max, name.length),
0
)
}
// 从最大长度开始对文本进行截取,并试图去引用表中找到对应的项
for (let length = maxCRNameLength; !value && length > 0; --length) {
// 截取字符 & 到最大长度之间的字符作为实体名称
name = rawText.substr(1, length)
// 使用实体名称去索引表中查找对应项的值
value = (namedCharacterReferences)[name]
}
// 如果找到了对应项的值,说明解码成功
if (value) {
// 检查实体名称的最后一个匹配字符是否是分号
const semi = name.endsWith(';')
// 如果解码的文本作为属性值,最后一个匹配的字符不是分号,
// 并且最后一个匹配字符的下一个字符是等于号(=)、ASCII 字母或数字,
// 由于历史原因,将字符 & 和实体名称 name 作为普通文本
if (
asAttr &&
!semi &&
/[=a-z0-9]/i.test(rawText[name.length + 1] || '')
) {
decodedText += '&' + name
advance(1 + name.length)
} else {
// 其他情况下,正常使用解码后的内容拼接到 decodedText 上
decodedText += value
advance(1 + name.length)
}
} else {
// 如果没有找到对应的值,说明解码失败
decodedText += '&' + name
advance(1 + name.length)
}
} else {
// 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本
decodedText += '&'
advance(1)
}
}
}
return decodedText
}

16.6.3 解码数字字符引用

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
function decodeHtml(rawText, asAttr = false) {
// 省略部分代码

// 消费字符串,直到处理完毕为止
while (offset < end) {
// 省略部分代码

// 如果满足条件,则说明是命名字符引用,否则为数字字符引用
if (head[0] === '&') {
// 省略部分代码
} else {
// 判断是十进制表示还是十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同的正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)

// 如果匹配成功,则调用 String.fromCodePoint 函数进行解码
if (body) {
// 根据对应的进制,将码点字符串转换为数字
const cp = Number.parseInt(body[1], hex ? 16 : 10)
// 码点的合法性检查
if (cp === 0) {
// 如果码点值为 0x00,替换为 0xfffd
cp = 0xfffd
} else if (cp > 0x10ffff) {
// 如果码点值超过 Unicode 的最大值,替换为 0xfffd
cp = 0xfffd
} else if (cp >= 0xd800 && cp <= 0xdfff) {
// 如果码点值处于 surrogate pair 范围内,替换为 0xfffd
cp = 0xfffd
} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
// 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
// noop
} else if (
// 控制字符集的范围是:[0x01, 0x1f] 加上 [0x7f, 0x9f]
// 去掉 ASICC 空白符:0x09(TAB)、0x0A(LF)、0x0C(FF)
// 0x0D(CR) 虽然也是 ASICC 空白符,但需要包含
(cp >= 0x01 && cp <= 0x08) ||
cp === 0x0b ||
(cp >= 0x0d && cp <= 0x1f) ||
(cp >= 0x7f && cp <= 0x9f)
) {
// 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
cp = CCR_REPLACEMENTS[cp] || cp
}
// 解码后追加到 decodedText 上
decodedText += String.fromCodePoint(cp)
// 消费整个数字字符引用的内容
advance(body[0].length)
} else {
// 如果没有匹配,则不进行解码操作,只是把 head[0] 追加到 decodedText 上并消费
decodedText += head[0]
advance(head[0].length)
}
}
}
return decodedText
}

16.7 解析插值与注释

花括号

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
# 花括号的使用
{{ count }}
{{ obj.foo }}
{{ obj.fn() }}

# 插值符(花括号)
function parseInterpolation(context) {
// 消费开始定界符
context.advanceBy('{{'.length)
// 找到结束定界符的位置索引
closeIndex = context.source.indexOf('}}')
if (closeIndex < 0) {
console.error('插值缺少结束定界符')
}
// 截取开始定界符与结束定界符之间的内容作为插值表达式
const content = context.source.slice(0, closeIndex)
// 消费表达式的内容
context.advanceBy(content.length)
// 消费结束定界符
context.advanceBy('}}'.length)

// 返回类型为 Interpolation 的节点,代表插值节点
return {
type: 'Interpolation',
// 插值节点的 content 是一个类型为 Expression 的表达式节点
content: {
type: 'Expression',
// 表达式节点的内容则是经过 HTML 解码后的插值表达式
content: decodeHtml(content)
}
}
}


# 代码测试
const ast = parse(`<div>foo {{ bar }} baz</div>`)

const ast = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
isSelfClosing: false,
props: [],
children: [
{ type: 'Text', content: 'foo ' },
{
type: 'Interpolation',
content: {
type: 'Expression',
content: ' bar '
}
},
{ type: 'Text', content: ' baz' }
]
}
]
}

注释

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
# parseComment
function parseComment(context) {
// 消费注释的开始部分
context.advanceBy('<!--'.length)
// 找到注释结束部分的位置索引
closeIndex = context.source.indexOf('-->')
// 截取注释节点的内容
const content = context.source.slice(0, closeIndex)
// 消费内容
context.advanceBy(content.length)
// 消费注释的结束部分
context.advanceBy('-->'.length)
// 返回类型为 Comment 的节点
return {
type: 'Comment',
content
}
}


# 代码测试
const ast = parse(`<div><!-- comments --></div>`)

const ast = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
isSelfClosing: false,
props: [],
children: [
{ type: 'Comment', content: ' comments ' }
]
}
]
}

16.8 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 解析器的文本模式及影响
- 解析器在不同文本模式下解析行为不同
- RCDATA、CDATA、RAWTEXT 和 DATA 模式的影响

# 递归下降算法构造模板 AST
- 使用递归下降算法构建模板 AST
- parseChildren 函数是核心,解析标签节点并递归调用下级 parseChildren 函数
- 构造一棵树型结构的模板 AST

# parseChildren 函数的运行和结束时机
- parseChildren 函数的调用开启新状态机
- 结束时机:模板内容解析完毕、遇到结束标签

# 文本节点解析和 HTML 实体解码
- 解析文本节点和 HTML 实体的解码
- HTML 实体类型:命名字符引用和数字字符引用
- 解码方案:完整匹配和最短匹配
- 数字字符引用的解码规则按 WHATWG 规范实现

第17章 编译优化

17.1 动态节点收集与补丁标志

17.1.1 传统 Diff 算法的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 传统 Diff 算法
<div id="foo">
<p class="bar">{{ text }}</p>
</div>

● 对比 div 节点,以及该节点的属性和子节点。
● 对比 p 节点,以及该节点的属性和子节点。
● 对比 p 节点的文本子节点,如果文本子节点的内容变了,则更新之,否则什么都不做。



# Vue.js 3 编译优化
思路来源:跳过无意义的操作

Vue.js 3 的编译器会将编译时得到的关键信息“附着”在它生成的虚拟 DOM 上,这些信息会通过虚拟 DOM 传递给渲染器。
最终,渲染器会根据这些关键信息执行“快捷路径”,从而提升运行时的性能。

17.1.2 Block 与 PatchFlags

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
# dynamicChildren 属性
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>

const PatchFlags = {
TEXT: 1, // 代表节点有动态的 textContent
CLASS: 2, // 代表元素有动态的 class 绑定
STYLE: 3
// 其他……
}

const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 这是动态节点
],
// 将 children 中的动态节点提取到 dynamicChildren 数组中
dynamicChildren: [
// p 标签具有 patchFlag 属性,因此它是动态节点
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}

# 块 => 收集所有后代动态节点
带有 dynamicChildren 属性的节点,称为 块 Block。
会收集所有后代动态子节点。

<div>
<div>
<p>{{ bar }}</p>
</div>
</div>

const vnode = {
tag: 'div',
children: [
{
tag: 'div',
children: [
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 这是动态节点
]
},
],
dynamicChildren: [
// Block 可以收集所有动态子代节点
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}


# 更新
跳过静态节点,只更新动态节点。根据 patchFlag: PatchFlags.TEXT 处理文本更新。

# 所有根节点都是 Block节点
<template>
<!-- 这个 div 标签是一个 Block -->
<div>
<!-- 这个 p 标签不是 Block,因为它不是根节点 -->
<p>{{ bar }}</p>
</div>
<!-- 这个 h1 标签是一个 Block -->
<h1>
<!-- 这个 span 标签不是 Block,因为它不是根节点 -->
<span :id="dynamicId"></span>
</h1>
</template>

// 除了根节点,任何带有 v-for、v-if/v-else-if/v-else 等也作为 Block 节点,后续讲。

17.4 预字符串化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 字符串优化
<div>
<p></p>
<p></p>
// ... 20 个 p 标签
<p></p>
</div>

const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20 个...<p></p>')
function render() {
return (openBlock(), createBlock('div', null, [
hoistStatic
]))
}

# 优势
● 大块的静态内容可以通过 innerHTML 进行设置,在性能上具有一定优势。
● 减少创建虚拟节点产生的性能开销。
● 减少内存占用。

17.5 缓存内联事件处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 模版
<Comp @change="a + b" />

# 无chache
function render(ctx) {
return h(Comp, {
// 内联事件处理函数
onChange: () => (ctx.a + ctx.b)
})
}

# 有cache
function render(ctx, cache) {
return h(Comp, {
// 将内联事件处理函数缓存到 cache 数组中
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}

17.6 v-once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 模版
<section>
<div v-once>{{ foo }}</div>
</section>

# 代码 cache[1] + 防止 Block收集
function render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
cache[1] || (
setBlockTracking(-1), // 阻止这段 VNode 被 Block 收集
cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),
setBlockTracking(1), // 恢复
cache[1] // 整个表达式的值
)
]))
}

17.7 总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Vue.js 3 编译优化
- 通过编译手段提取关键信息,指导生成最优代码
- 分析模板,附着关键信息到虚拟节点上
- 运行时通过关键信息执行“快捷路径”提升性能

# 区分动态节点与静态节点
- 使用 patchFlag 为动态节点打上补丁标志
- 引入 Block 概念,收集动态子代节点的数组 dynamicChildren
- Block 利用 createVNode 和 createBlock 的嵌套特性完成动态节点收集

# 处理 v-if、v-for 等结构化指令问题
- 让带指令的节点也作为 Block 角色
- 解决基于 Block 树的比对算法失效问题

# 其他编译优化努力
- 静态提升:减少更新时创建虚拟 DOM 的性能开销和内存占用
- 预字符串化:对静态节点进行字符串化,减少虚拟节点的性能开销和内存占用
- 缓存内联事件处理函数:避免不必要的组件更新
- v-once 指令:缓存虚拟节点,避免性能开销和无用 Diff 操作

第六篇 服务端渲染

第18章 同构渲染

1
2
3
4
5
6
Vue.js 可以用于构建客户端应用程序,组件的代码在浏览器中运行,并输出 DOM 元素。
同时,Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。

# Vue.js 两种渲染
客户端渲染(client-side rendering,CSR)
服务端渲染(server-side rendering,SSR)

18.1 CSR、SSR 以及同构渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 早期 SSR
1. 用户 => 浏览器 => 服务器
2. 服务器获取数据,根据 [模版 + 数据] => 拼接为 HTML 字符串
3. 浏览器解析 HTML 内容,并渲染

缺点:小操作,整个页面都刷新


# 后来 CSR(ajax web2.0)
先获取页面,此时页面为空,再获取数据,使用js填充到页面。

后续,用户点击,触发js获取数据,重新填充页面。(无需整个页面刷新)

# 同构
首次访问 或 刷新,浏览器获取渲染好的 HTML 页面。

后续,同 CSR。

18.5.3 只在某一端引入模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 环境不同,引入模版不同
<script>
let storage
if (!import.meta.env.SSR) {
// 用于客户端
storage = import('./storage.js')
} else {
// 用于服务端
storage = import('./storage-server.js')
}
export default {
// ...
}
</script>

18.6 总结

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
# CSR、SSR 和同构渲染工作机制及优缺点
- CSR(客户端渲染):前端负责渲染,速度快,SEO 差
- SSR(服务端渲染):后端负责渲染,SEO 好,首屏渲染快
- 同构渲染:结合 SSR 和 CSR,前后端共同渲染,首屏渲染快,SEO 好

# 将虚拟节点渲染为字符串
- 考虑自闭合标签、属性名称和属性值的合法性与转义
- 转义文本子节点的字符,包括 &、<、>、"、'

# 组件渲染为 HTML 字符串
- 执行组件的 render 函数得到 subTree,并渲染为 HTML 字符串
- 无需包装数据和调用 beforeMount 和 mounted 钩子

# 客户端激活
- 在客户端执行组件代码,建立虚拟节点与真实 DOM 元素的联系
- 为 DOM 元素添加事件绑定

# 编写同构的组件代码
- 注意组件生命周期钩子在服务端不会执行
- 使用跨平台的 API,保证代码的跨平台性
- 根据特定端实现功能模块
- 避免交叉请求引起的状态污染,为每个请求创建独立应用实例
- 封装<ClientOnly>组件,仅在客户端渲染部分内容

【日期标记】2023-07-28 10:49:31 以上同步完成