框架设计概览
命令式和声明式
命令式:1
2
3const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('ok')})
声明式:1
<div @click="() => alert('ok')">hello world</div>
命令式关注过程,声明式关注结果。
在Vue.js的内部实现是命令式的,而暴露给用户的是声明式。
虚拟DOM
声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。
虚拟DOM的目的:最小化找出差异的性能消耗。
纯JavaScript
层面的操作要比DOM
操作快得多。
虚拟DOM和模板语法在创建页面时的性能:
虚拟DOM | 模板语法 | |
---|---|---|
纯JavaScript运算 | 创建JavaScript对象(VNode) | 渲染HTML字符串 |
DOM运算 | 新建所有的DOM元素 | 新建所有的DOM元素 |
虚拟DOM和模板语法在更新页面时的性能:
虚拟DOM | 模板语法 | |
---|---|---|
纯JavaScript运算 | 创建JavaScript对象 + Diff | 渲染HTML字符串 |
DOM运算 | 必要的DOM更新 | 销毁所有旧的DOM + 新建所有新的DOM |
注:虚拟DOM的出现是为了解决Vue
在选择声明式视图框架时,所带来的性能消耗问题。因为通过原生的JavaScript
来更新视图层是性能消耗最小的,但同时也是最难维护的(可读性较差),而选择通过模板语法在创建页面时的性能消耗和虚拟DOM差不多,但是在更新页面时,模板语法带来的DOM运算要远大于虚拟DOM,因为纯JavaScript
运算要远比DOM运算更快。
虚拟DOM主要包括创建VNode
和比较DOM
差异的Diff
算法。
Tree-Shaking
Tree-Shaking
指的是消除永远不会被执行的代码。
渲染器
虚拟DOM:用JavaScript
对象来描述真实的DOM
结构。
虚拟DOM——>渲染器——>真实DOM
虚拟DOM:1
2
3
4
5
6
7
8
9
10const vnode = {
// 标签名称 如<div>标签
tag: 'div',
// 标签的属性、事件等
props: {
onClick: () => alert('hello')
},
// 标签的子节点
children: 'click me'
}
渲染器: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// 渲染器函数 参数:虚拟DOM 挂载的真实DOM元素
function renderer(vnode, container) {
// 创建DOM元素
const el = document.createElement(vnode.tag)
// 遍历虚拟DOM的属性、事件 并添加到DOM元素中
for (const key in vnode.props) {
// test():检测一个字符串是否匹配某个模式
// 检测是否以 On 开头
if (/^on/.test(key)) {
el.addEventListener(
// 去掉标识符 On
key.substr(2).toLowerCase(),
// 事件处理函数
vnode.props[key]
)
}
}
// 处理children
if (typeof vnode.children === 'string') {
// 如果是字符串 则创建文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 否则递归调用 renderer函数
vnode.children.forEach(child => renderer(child.el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
// 调用渲染器
renderer(vnode, document.body)
渲染器整体分为三步:创建元素——>添加属性和事件——>处理children
组件
组件就是一组DOM
元素的封装,本质上也是虚拟DOM。
模板与编译器
编译器的作用就是将模板编译为渲染函数。
模板:1
2
3
4
5
6
7
8
9
10
11
12<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {},
methods: {},
}
经过编译器编译后:1
2
3
4
5
6
7export default {
data() {},
methods: {},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
无论是模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM。这就是模板的工作原理,也就是Vue渲染页面的流程。
编译器和渲染器是Vue.js的核心组成部分。
响应系统
基本的响应系统
1 | // 存储副作用函数的桶 |
当读取属性时,将副作用函数添加到桶里,然后返回属性值。当设置属性值时,会先更新数据,然后将副作用函数从桶里取出并重新执行。
注1:副作用函数也就是可以操作DOM
元素的函数,因此当修改响应式数据时,此时会触发桶里的副作用函数,也就是修改DOM
元素,也相当于更新了页面数据。
注2:所谓响应式数据,也就是当修改原始数据时,DOM
页面元素中的数据也会相应的修改。Vue
利用Proxy()
代理函数实现对数据的拦截操作,并将操作DOM
元素的函数隐藏到副作用函数中,并通过适当的储存操作,在适当的时机调用副作用函数,实现隐藏修改DOM
元素。
完善的响应系统
1 | // 用一个全局变量存储被注册的副作用函数 |
解决问题1:将副作用函数匿名化,并用一个全局变量存储被注册的副作用函数。
解决问题2:建立副作用函数与被操作的目标字段的联系。
由图可知,WeakMap
是由target --> Map
构成,存放的是原始对象的关联信息。而Map
又是由key --> Set
构成,Set
里面存放的是具体的副作用函数,因此Map
存放的是原始对象key
值和副作用函数的联系。
因此每个对象的每个key
值均和对应的副作用函数实现了相互关联。
附1:WeakMap
对key
是弱引用,不影响垃圾回收期的工作。因此一旦key
被垃圾回收器回收,则对应的键和值就不能访问了,如果使用Map
来存储,则会一定程度上导致内存溢出。
响应系统的其他细节
分支切换与cleanup
- 问题现象:
1
2
3
4
5const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, {})
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not'
})
当obj.ok
的初始值为true
时,会依次执行obj.ok
的和obj.text
这两个属性的读写操作,但是当将obj.ok
的值修改为false
后,此时正常的逻辑应为只会触发obj.ok
的读取操作,不会触发obj.text
的操作,但实际上,当修改obj.text
的值时,仍然会导致副作用函数执行。
- 问题分析:
出现该问题的主要原因是因为在第一次操作obj.ok
和obj.text
时,已经在内存中建立了对应的联系,而当将obj.ok
的值修改为false
时,虽然从三元表达式中不会执行obj.text
,但是关于obj.text
的联系已经建立在内存中,因此,当修改obj.text
(即只要触发对象的读写操作),仍然会重新执行之前建立好的联系。 - 问题解决:
在每次副作用函数执行前,将其从相关联的依赖集合中移除。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
81let activeEffect
const bucket = new WeakMap()
const data = { ok: true, text: 'hello world' }
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时 将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
// 遍历数组
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
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
function track(target, key) {
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)
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// 重新构造另外一个Set集合 防止出现无限循环的bug
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
}
effect(
() => {
document.body.innerText = obj.ok ? obj.text : 'not'
}
)
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
在执行副作用函数前,会先调用cleanup()
函数将其相关联的依赖清除。此时,修改obj.ok
的值为false
后,在执行obj.text
操作是不会导致副作用函数的执行。
嵌套的effect
- 问题现象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23const data = { foo: true, bar: true }
const obj = new Proxy(data, {})
// 设置全局变量
let temp1 = temp2
// 定义2个effect函数
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
// 修改 obj.foo 的值
// 正确的应该会显示 'effectFn1 执行' 因为 foo 关联的是 effectFn 函数
// 但实际显示的是 'effectFn2 执行'
obj.foo = false
在effectFn1
内部嵌套了effectFn2
,并在effectFn1
和effectFn2
内部分别读取了foo
和bar
的值,从而建立了副作用函数和属性之间的关联。
但是在修改obj.foo
的值时(obj.foo = false
),正确应该会显示相关联的effectFn1
函数,但实际上显示的effectFn2
函数。
Vue
中实际的场景:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// Bar组件
const Bar = {
render() {}
}
// Foo组件渲染了Bar组件
const Foo = {
render() {
return <Bar />
}
}
// 上述模板解析后:
effect(() => {
Foo.render()
effect(() => {
Bar.render()
})
})
问题分析
在使用activeEffect = effectFn
存储副作用函数时,此时的副作用函数只能有一个,当副作用函数发生嵌套时,内层的副作用函数的执行会覆盖activeEffect
的值,并且永远不会恢复。问题解决
建立一个副作用函数栈effectStack
,副作用函数执行时,将其压入栈中,执行后将其弹出。使得响应式数据只会收直接读取其值的副作用函数。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
58let activeEffect
// effect 函数栈
const effectStack = []
const bucket = new WeakMap()
const data = { foo: true, bar: true }
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 在调用副作用函数之前将其压入栈中
effectStack.push(effectFn)
fn()
// 调用之后将其弹出 并还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
// 不变
}
const obj = new Proxy(data, {
// 不变
})
function track(target, key) {
// 不变
}
function trigger(target, key) {
// 不变
}
let temp1, temp2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
obj.foo = false
无限递归循环
- 问题现象
1
2
3
4const data = { foo: 1 }
const obj = new Proxy(data, {})
effect(() => obj.foo++)
此时控制台会出现错误:Uncaught RangeError: Maximum call stack size exceeded
,即栈溢出错误。
问题分析
自增操作(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
45let activeEffect
const effectStack = []
const bucket = new WeakMap()
const data = { foo: 1 }
function effect(fn) {
const effectFn = () => {
// 不变
}
function cleanup(effectFn) {
// 不变
}
const obj = new Proxy(data, {
// 不变
})
function track(target, key) {
// 不变
}
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())
}
effect(() => {
obj.foo++
})
增加判断条件,避免重复调用。
调度执行
可调度,指的是当trigger
动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。也即为副作用函数增加一些附属功能。
- 问题现象
1 | const data = { foo: 1 } |
如果现在有个需求,想改变输出的顺序为: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
76let activeEffect
const effectStack = []
const bucket = new WeakMap()
const data = { foo: 1 }
function effect(fn, options = []) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
// 不变
}
const obj = new Proxy(data, {
// 不变
})
function track(target, key) {
// 不变
}
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 => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器 则调用该调度器
// 并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
effect(() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
// 这里会延迟执行
setTimeout(fn, 1000)
}
})
obj.foo++
console.log('结束了')
此时控制台会先输出1 结束了
,并延迟1秒后在输出2
。
计算属性computed
结合之前的响应系统和调度器就可以实现计算属性。
计算属性的特点: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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101let activeEffect
const effectStack = []
const bucket = new WeakMap()
const data = { foo: 1, bar: 2 }
function effect(fn, options = []) {
const effectFn = () => {
console.log('effectFn 执行')
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 返回执行结果
return res
}
effectFn.options = options
effectFn.deps = []
// 只有非 lazy 的时候才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn
}
function cleanup(effectFn) {
// 不变
}
const obj = new Proxy(data, {
// 不变
})
function track(target, key) {
// 不变
}
function trigger(target, key) {
// 不变
}
// 定义计算属性函数
function computed(getter) {
// 缓存上一次计算的值
let value
// 用来表示是否需要重新计算值
let dirty = true
const effectFn = effect(getter, {
// 设置为懒执行
lazy: true,
// 添加调度器 将 dirty 重置为 true
// 保证下次在重新设置计算属性值时 会重新计算
scheduler() {
dirty = true
}
})
const obj = {
// 当读取 obj 的 value 属性时 才会执行 effectFn
get value() {
// 只有需要重新计算时 才将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 设置为 false 下次访问直接使用缓存的值
dirty = false
}
return value
}
}
// 计算属性返回对象
// 对象中的 value 属性才是存放的计算值
return obj
}
const sunRes = computed(() => obj.foo + obj.bar)
console.log(sunRes.value)
console.log(sunRes.value)
obj.foo++
console.log(sunRes.value)
// 输出
// effectFn 执行
// 3
// 3
// effectFn 执行
// 4
计算属性返回的是一个对象,对象中的value
值才是存放实际计算的结果。由输出结果可知,多次调用计算属性不会重复执行副作用函数,而在对原始对象重新执行赋值操作时,会恢复执行副作用函数。
参考资料
- 《Vue.js设计与实现》霍春阳 2022.1