Vue3设计与实现笔记整理

框架设计概览

命令式和声明式

  命令式:

1
2
3
const 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
10
const 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: {},
}
<script>

  经过编译器编译后:

1
2
3
4
5
6
7
export default {
data() {},
methods: {},
render() {
return h('div', { onClick: handler }, 'click me')
}
}

  无论是模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM。这就是模板的工作原理,也就是Vue渲染页面的流程。
  编译器渲染器是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
38
39
40
// 存储副作用函数的桶
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() {
// DOM操作
document.body.innerText = obj.text
}

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

// 1s后修改响应式数据
setTimeout(() => {
// 触发设置操作
obj.text = 'hello vue3'
}, 1000)

  当读取属性时,将副作用函数添加到桶里,然后返回属性值。当设置属性值时,会先更新数据,然后将副作用函数从桶里取出并重新执行。
  注1:副作用函数也就是可以操作DOM元素的函数,因此当修改响应式数据时,此时会触发桶里的副作用函数,也就是修改DOM元素,也相当于更新了页面数据。
  注2:所谓响应式数据,也就是当修改原始数据时,DOM页面元素中的数据也会相应的修改。Vue利用Proxy()代理函数实现对数据的拦截操作,并将操作DOM元素的函数隐藏到副作用函数中,并通过适当的储存操作,在适当的时机调用副作用函数,实现隐藏修改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
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// 存储副作用函数的桶
const bucket = new WeakMap()

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

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

// 对原始数据的代理
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)
}
})

function track(target, key) {
// 没有 activeEffect 直接 return
if (!activeEffect) return

// 根据 target 从桶中取得 depsMap
let depsMap = bucket.get(target)
// 如果不存在 则新建
if (!depsMap) {
// 建立 target 和 Map 的联系
bucket.set(target, (depsMap = new Map()))
}

// 根据 key 从 depsMap 中取得 deps
let deps = depsMap.get(key)
// 如果不存在 则新建
if (!deps) {
// 建立 key 和 Set 联系
depsMap.set(key, (deps = new Set()))
}

// 将当前激活的副作用函数添加到桶中
deps.add(activeEffect)
}

function trigger(target, key) {
// 根据 target 从桶中取得 depsMap
const depsMap = bucket.get(target)
if (!depsMap) return

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

// 调用副作用函数
effect(
// 传入一个匿名函数参数
() => {
// DOM元素操作
document.body.innerText = obj.text
}
)

// 1s后修改响应式数据
setTimeout(() => {
// 触发设置操作
obj.text = 'hello vue3'
}, 1000)

  解决问题1:将副作用函数匿名化,并用一个全局变量存储被注册的副作用函数。
  解决问题2:建立副作用函数与被操作的目标字段的联系。
  WeakMap,Map和Set之间的关系
  由图可知,WeakMap是由target --> Map构成,存放的是原始对象的关联信息。而Map又是由key --> Set构成,Set里面存放的是具体的副作用函数,因此Map存放的是原始对象key值和副作用函数的联系。
  因此每个对象的每个key值均和对应的副作用函数实现了相互关联。
  附1:WeakMapkey是弱引用,不影响垃圾回收期的工作。因此一旦key被垃圾回收器回收,则对应的键和值就不能访问了,如果使用Map来存储,则会一定程度上导致内存溢出。

响应系统的其他细节

分支切换与cleanup

  • 问题现象:
    1
    2
    3
    4
    5
    const 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.okobj.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
    81
    let 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
    23
    const 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,并在effectFn1effectFn2内部分别读取了foobar的值,从而建立了副作用函数和属性之间的关联。
  但是在修改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
    58
    let 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
    4
    const 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
    45
    let 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const data = { foo: 1 }
const obj = new Proxy(data, {})

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

// 只要对 对象进行 set 操作 就会进入 trigger() 中
// 从而执行之前保存的副作用函数,即传入 effect() 中的函数参数
// 本例中也就是 console.log(obj.foo)
obj.foo++

console.log('结束了')

// 输出
// 1
// 2
// 结束了

  如果现在有个需求,想改变输出的顺序为: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
let 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
101
let 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值才是存放实际计算的结果。由输出结果可知,多次调用计算属性不会重复执行副作用函数,而在对原始对象重新执行赋值操作时,会恢复执行副作用函数。

参考资料

  1. 《Vue.js设计与实现》霍春阳 2022.1
谢谢老板!
-------------本文结束感谢您的阅读给个五星好评吧~~-------------