Vue.js 设计与实现 - 04 响应系统的作用和实现

响应系统也是 Vue.js 的重要组成部分。在本章中,首先讨论什么是响应式数据副作用函数,然后尝试实现一个相对完善的响应系统。在这个过程中,我们会遇到各种各样的问题,例如如何避免无限递归?为什么需要嵌套的副作用函数?两个副作用函数之间会产生哪些影响?以及其他需要考虑的细节。

第二篇 响应系统 响应式数据 副作用函数 Proxy WekaMap Set Map

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

副作用函数指的是会产生副作用的函数(影响其他函数执行):

1
2
3
4
5
6
7
let count = 0
const effect = () => {
document.body.innerText = 'hello world' // 其他函数也有相同的行为,导致不符合预期
}
const effect2 = () => {
count = 1 // 修改全局变量
}

响应式数据,假设在一个副作用函数中读取了某个对象的属性,该属性发生变化时,该副作用函数重新执行。

1
2
3
4
const obj = { text: '123' }
const effect = () => {
console.log(obj.text)
}

4.2 响应式数据的基本实现

如何让 obj 变成响应式对象呢 —— 使用 Proxy 实现

  • effect 执行,触发 obj.text 的读取操作,存储 effect
  • 修改 obj.text,触发字段 obj.text 的设置操作,把存储的 effect 取出执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 存储副作用函数的桶
const bucket = new Set()

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

// 原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get (target, key) {
bucket.add(effect)
return target(target[key])
},
// 拦截设置操作
set (target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true // 表示设置成功
}
})

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

明确副作用函数与被操作的的目标字段之间建立明确的关系。

角色:

  • 代理对象(target)
  • 属性(key)
  • 副作用函数 (effectFn)

关系(WeakMap):

target(Map) key(Set) effectfn
target1 key1 effectFn1
target1 key2 effectFn1
effectFn2
target2 key3 effectFn2

其他:

  • effect,注册副作用函数,effect(() => { console.log(target1.key1) })
  • activeEffect,存储被注册的副作用函数,保存副作用函数的名字,也能处理匿名函数
  • bucket,存储所有副作用函数的桶

代码:

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
const bucket = new WeakMap()
// 临时存储副作用函数
let activeEffect

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

// 在 get 拦截函数内调用 track 函数追踪变化
const track = (target, key) => {
// 没有副作用函数,直接返回
if (!activeEffect) {
return
}
// 根据 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)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
const trigger = (target, key) => {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) {
return true
}
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}

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

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)
}
})

// 注册副作用函数
effect(() => {
console.log(obj.text)
}) // hello world

obj.text = '123' // 123

tips:

  • key 对应的 Set 数据结构中存储的副作用函数集合称为依赖集合
  • WeakMap 对 key 是弱引用,一旦没有其他引用与该对象关联,则垃圾回收机制会回收该对象所占用的内存

4.4 分支切换与 cleanup

分支切换的定义:

1
2
3
4
5
6
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })

effect(() => {
document.body.innerText = obj.ok? obj.text : 'not'
})

函数内部存在一个三元表达式,obj.ok 的值发生变化时,代码执行的分支也会跟着变化,这就是所谓的分支切换。

分支的切换可能会产生遗留的副作用函数,如下面的代码,除了没有删除之前存储的副作用函数,遗留的副作用函数还会导致不必要的更新,obj.text 更新后仍然会触发副作用函数执行。

1
2
3
4
5
6
7
8
9
const data = { ok: true, text: 'hello world' }

effect(() => {
console.log(obj.ok ? obj.text : 'noOk')
}) // hello world
// bucket 存储了 ok 和 text 对应的副作用函数

obj.ok = false // noOk
// bucket 仍然存储了 ok 和 text 对应的副作用函数,没有删除 text 对应的副作用函数

解决这个问题的思路:每次副作用函数执行时,可以把它从所有与之关联的依赖集合中删除。

副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。

要将一个副作用函数从所有与之关联的依赖集合中移除,需要明确知道哪些依赖集合中包含它,因此在 effect 内部定义了新的 effectFn 函数,并为其添加 effectFn.deps 属性(数组),用来存储所有包含当前副作用函数的依赖集合:

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
let activeEffect

// 在副作用函数执行前,将副作用函数从依赖集合中移除
const cleanup = effectFn => {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中删除
// Set.prototype.delete(value) 删除某个值
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}

const effect = fn => {
const effectFn = () => {
// 在调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// 存储所有与该副作用函数相关联的依赖集合
// activeEffect.deps
effectFn.deps = []
effectFn()
}

const 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)
}

cleanup 函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps 数组,将该副作用函数从数组的每一项(依赖集合,即副作用函数集合)中移除,最后重置 effectFn.deps 数组。至此,可以避免副作用函数遗留

解决无限循环执行的问题,问题出现在 trigger 函数内部的 effects && effects.forEach(fn => fn())中,当副作用函数执行时,会调用 cleanup 进行清除,实际上是从 effects 集合中将当前执行的副作用函数剔除。但是副作用函数的执行会导致其重新被收集到集合中,而此时对于 effects 集合的遍历仍在进行。

如:

1
2
3
4
5
6
7
const set = new Set([1])

set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
}) // 无限循环执行

规范:

在调用 forEach 遍历 Set 集合,如果一个值已经被访问过,但是该值被删除重新添加到集合,如果此时遍历未结束,那么该值会被重新访问。因此,上面的代码会无限执行,解决办法就是构造另一个 Set 集合并遍历它。

1
2
3
4
5
6
7
8
const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
1
2
3
4
5
6
7
8
9
const trigger = (target, key) => {
const depsMap = bucket.get(target)
if (!depsMap) {
return true
}
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // +
effectsToRun.forEach(effectFn => effectFn()) // +
}

4.5 嵌套的 effect 与 effect 栈

effect 是可以发生嵌套的(相当于 Vue 中的组件嵌套,A 组件渲染了 B 组件。),如下面的代码:

1
2
3
4
5
effect(() => {
effect(() => {
/** */
})
})
1
2
3
4
5
6
7
8
9
10
11
12
effect(() => {
console.log('effectFn1 执行')
effect(() => {
console.log('effectFn2 执行')
console.log(obj.text)
})
})

obj.text = '123'
// effectFn1 执行 -> 不符合预期
// effectFn2 执行
// 123

我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层的副作用函数会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都是内存副作用函数,这就是问题的所在。

为了解决这个问题,需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let activeEffect
const effectStack = []

const effect = fn => {
const effectFn = () => {
cleanup(effectFn)
// 调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 调用副作用函数之前,将副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 当前副作用函数执行完毕后,将其从栈中弹出,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}

4.6 避免无限递归循环

1
2
3
4
5
6
const data = { foo: 1 }
const obj = new Proxy(data, { /** */ })

effect(() => {
obj.foo++
}) // 栈溢出

obj.foo++ 中,即会读取 obj.foo 的值,又会设置 obj.foo的值,而这就是导致问题的根本原因,流程:

  • 首先读取 obj.foo 的值,触发 track 操作,将当前副作用函数收集到 obj.foo 的依赖集合中(deps)
  • 接着将其值加 1,触发 set 操作,触发 trigger 操作,将副作用函数从 obj.foo 的依赖集合中(deps)取出并执行。

问题在于副作用函数正在执行中,还没执行完毕,就要开始下一次执行。这样会导致无限递归地调用自己,于是产生了栈溢出。

解决办法并不难。通过分析可得,读取和设置操作是在同一个副作用函数内进行的,此时无论是 track 时收集的副作用函数,还是trigger时要触发执行的副作用函数,都是 activeEffect。基于此,我们可以在 trigger 动作发生时增加守卫:如果 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, { /** */ })

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

4.7 调度执行

可调度性是响应式系统非常重要的特性,什么是可调度行?

  • 指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

如何决定副作用函数的执行方式?

1
2
3
4
5
6
7
8
9
10
11
12
const data = {  foo: 1 }
const obj = new Proxy(data, { /** */ })

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

obj.foo++
console.log('结束')
// 1
// 2
// 结束

调整输出的顺序,如 1,结束,2,除了把代码语句互换,有什么能够在不调整代码的情况下实现呢 —— 这时候就需要响应式系统支持调度。

为 effect 函数设计一个选项参数 options,允许用户指定调度器

1
2
3
4
5
6
7
8
9
10
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器
scheduler: fn => {/*...*/}
}
)

options 对象可以指定一个 scheduler 调度函数,同时在 effect 函数内部我们需要把 options 挂载到对应的副作用函数上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const effect = (fn, options = {}) => {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}

effectFn.options = options // 将 options 挂载到 effectFn 上
effectFn.deps = []
effectFn()
}

有了调度函数,我们在 trigger 函数中触发执行副作用函数时,就可以直接调用用户传递的调度器函数,从而把控制权浇给用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const trigger = (target, key) => {
const depsMap = bucket.get(target)
if (!depsMap) {
return true
}
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(item => {
if (item !== activeEffect) {
effectsToRun.add(item)
}
})
effectsToRun.forEach(effectFn => {
// 如果存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否则执行副作用函数
effectFn()
}
})
}

如上面代码所示,在 trigger 函数中,我们判断如果存在调度器,则调用该调度器,并将副作用函数作为参数传递,否则执行副作用函数,由用户自己控制如何执行;否则保留之前的行为,即执行副作用函数。

回到开头的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const data = {  foo: 1 }
const obj = new Proxy(data, { /** */ })

effect(
() => {
console.log(obj.foo)
},
{
// 调度器
scheduler: fn => {
// 将副作用函数放倒到“宏任务”队列中执行
// 在这里我们开启一个宏任务来执行副作用函数 fn,实现期望的打印顺序
setTimeout(fn)
}
}
)

obj.foo++
console.log('结束')
// 1
// 结束
// 2

除了控制副作用函数的执行顺序,通过调度器还可以控制它的执行次数,这点也尤为重要,如:

1
2
3
4
5
6
7
8
9
effect(() => {
console.log(obj.foo)
})

obj.foo++
obj.foo++
// 1
// 2
// 3

由输出可知,字段 obj.foo 的值一定会从 1 自增到 3,2 只是它的过度状态,如果我们只关心结果,不关心过程,那么执行三次打印操作就是多余的,期望的打印结果是 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
// 任务队列,Set 的自动去重能力
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列中
const p = Promise.resolve()

// 是否正在刷新队列
let isFlushing = false

const flushJob = () => {
// 如果队列正在刷新,则什么都不做
if (isFlushing) {
return
}
// 设置为 true,代表正在刷新
isFlushing = true
p.then(() => {
// 清空队列
jobQueue.forEach(job => job())
}).finally(() => {
// 重置 isFlushing
isFlushing = false
})
}

effect(
() => {
console.log(obj.foo)
},
{
// 调度器
scheduler: fn => {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
}
)

obj.foo++
obj.foo++
// 1
// 3

每次 scheduler 调度函数执行时,将副作用函数添加到 jobQueue 队列中,然后调用 flushJob 刷新队列。

关于 flushJob,该函数通过 isFlushing 判断是否执行,只有为 false 时才会执行,并将 isFlushing 设为 true,无论调用多少次 flushJob 函数,在 jobQueue 的副作用函数执行完之前只会执行一次。

在这里借助了 js 执行机制,同步代码 -> 微任务 -> 下一个宏任务。该功能类似 Vue 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue 内部实现了更完善的调度器。

4.8 计算属性 computed 与 lazy

在深入讲解计算属性之前,需要先聊聊懒执行的 effect,即 lazy 的 effect。

1
2
3
4
5
6
effect(
// 这个函数会立即执行
() => {
console.log(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
effect(
// 这个函数会立即执行
() => {
console.log(obj.foo)
},
{
lazy: true
}
)

const effectFn = (fn, options = {}) => {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
// 只有非懒执行的时候才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn
}

通过 options.lazy 实现了让副作用函数不立即执行的功能,那么副作用函数该什么时候执行呢?这里将副作用函数作为返回值返回,只要调用 effect,就能拿到对应的副作用函数,在需要的时候执行。

1
2
3
4
5
const effectFn = effect(() => {
console.log(obj.foo)
}, { lazy: true })
// 手动执行副作用函数
effectFn()

❗如果我们把传递给 effect 的函数当作一个 getter,返回一个返回值:

1
2
3
4
5
const effectFn = effect(() => {
return obj.foo + obj.bar
}, { lazy: true })
// value 是 传入 effect 函数的返回值
const value = effectFn()

为了实现这个目标,需要对 effect 函数做一些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const effect = (fn, options = {}) => {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 将 res 作为 effectFn 的返回值
return res
}
effectFn.options = options
effectFn.deps = []
// 只有非懒执行的时候才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn
}

通过代码可知,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。

现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果,接下来可以实现计算属性了:

1
2
3
4
5
6
7
8
9
10
11
12
13
const computed = getter => {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, { lazy: true })

const obj = {
// 当读取 value 时,手动调用副作用函数,获取到的值就是返回值
get value () {
return effectFn()
}
}

return obj
}

例子

1
2
3
4
5
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /** */ })

const sum = computed(() => obj.foo + obj.bar)
console.log(sum.value) // 3

可以看到它能正确工作,不过现在的计算属性只做到了懒计算(只有真正读取 sum.value 的值时,才会进行计算并得到值),还做不到对值进行缓存,假如我们多次访问 sum.value,会导致 effectFn 进行多次计算(每次访问,都会调用 effectFn 重新计算),即使 obj.bar 和 obj.bar 的值并没有发生变化。

为了解决这个问题,需要在 computed 函数中添加缓存功能:

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
const computed = getter => {
// 定义一个变量存储上一次计算的值
let value
// dirty 标志,代表是否需要重新计算值,为 true 则意味着需要重新计算
let dirty = true
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
// 当数据变化时,将 dirty 置为 true,表示下一次读取需要重新计算
scheduler: () => {
dirty = true
}
})

const obj = {
// 当读取 value 时,手动调用副作用函数,获取到的值就是返回值
get value () {
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}

return obj
}

现在,我们设计的计算属性已经趋于完美,但还有一个缺陷(不会触发读取该计算属性的副作用函数的执行),它体现在当我们在另一个 effect 中读取计算属性的值时:

1
2
3
4
5
6
7
const sum = computed(() => obj.foo + obj.bar)

effect(() => {
console.log(sum.value)
})

obj.foo++ // 并不会触发副作用函数的执行

分析问题的原因,我们发现,这本质上是一个 effect 的嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集为依赖。而把计算属性用作与另一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。

解决办法很简单,当读取计算属性的值时,我们可以手动调用 track 函数进行追踪,当计算属性依赖的响应式数据发生变化时,我们可以手动调用 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
25
26
27
const computed = getter => {
let value
let dirty = true

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

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

return obj
}

现在,计算属性的实现已经基本完成。对于如下代码:

1
2
3
effect(function effectFn () {
console.log(sum.value)
})

它会建立这样的关系:

computed(obj)
    - value
        - effectFn

4.9 watch 的实现原理

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

1
2
3
4
5
6
watch(obj, () => {
console.log('数据变了')
})

// 修改响应数据的值,触发回调函数
obj.foo++

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

1
2
3
4
5
6
7
8
9
effect(
() => {
console.log(obj.foo)
}, {
scheduler: () => {
// 当 obj.foo 发生变化时,会执行 scheduler 函数
}
}
)

当响应式数据发生变化时,会触发 scheduler 执行,基于此,简单的 watch 实现:

1
2
3
4
5
6
7
8
9
10
11
12
const watch = (source, cb) => {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler: () => {
// 当数据变化时,执行回调函数
cb()
}
}
)
}

例子:

1
2
3
4
watch(obj, () => {
console.log('数据变了')
})

这里硬编码了 obj.foo,为了让 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
const watch = (source, cb) => {
effect(
// 调用 traverse 递归读取
() => traverse(source),
{
scheduler: () => {
// 当数据变化时,执行回调函数
cb()
}
}
)
}

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

当对象任意属性发生变化时,都能触发回调函数执行。

watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:

1
2
3
4
5
6
7
8
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 变了')
}
)

传递给 watch 函数的第一个参数不再是一个响应式数据,二是一个 getter 函数,在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据发生变化时,才会触发回调函数执行,下面的代码实现了这一功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const watch = (source, cb) => {
// 定义一个 getter 函数
let getter
// 如果 source 是函数,说明用户传递的是 getter,将其赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的逻辑进行处理
getter = () => traverse(source)
}

effect(
() => getter(source),
{
scheduler: () => {
// 当数据变化时,执行回调函数
cb()
}
}
)
}

通常在 Vue 的 watch 函数中能拿到变化前后的值,那么如何获得新旧值呢?这需要充分利用 effect 函数的 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
const watch = (source, cb) => {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}

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

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

上一节介绍了 watch 的基本实现,在这个过程中,我们认识到 watch 的本质是对 effect 的二次封装,本节我们继续讨论:

  • 立即执行的函数回调
  • 回调函数的执行时机

立即执行的函数回调

默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行,但是在 Vue 中可以通过选项 immediate 来指定是否需要立即执行:

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

当 immediate 为 true 时,回调函数辉在该 watch 创建时立刻执行一次。仔细思考发现,回调函数的立即执行与后续执行,没有本质的区别,所以我们可以把 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
27
28
29
30
31
32
const watch = (source, cb, options = {}) => {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue

// 提取 scheduler 为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}

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

if (options.immediate) {
// 当 imediate 为 true 时,执行 job,从而触发回调执行
job()
} else {
// 否则执行副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}

除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定函数的执行时机,例如 Vue3 中使用 flush 选项来指定:

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

flush 本质上是在指定调度函数的执行时机,前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 ‘post’ 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:

1
2
3
const watch = (source, cb, options) => {
let getter
}

4.11 过期的副作用

1
2
3
4
5
6
let finalData

watch(obj, async () => {
const res = await fetch('path')
finalData = res
})

obj 每次发生变化时候,都会触发副作用函数的执行,如果第一次的结果覆盖了最后一次的结果,就会导致数据数据无效(状态同步问题)。这就需要一个让副作用过期的手段。

在 Vue 中,在 watch 内部每次监测到更新变更后,在副作用函数重新执行之前,会先调用通过 onInvalidate 函数注册过的过期回调函数:

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
const watch = (source, cb, options = {}) => {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}

let oldValue, newValue

// cleanup 用来存储用户注册的过期回调
let cleanup
const onInvalidate = fn => {
cleanup = fn
}

const job = () => {
newVal = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调函数
if (cleanup) {
cleanup()
}
cb(newVal, oldVal, onInvalidate)
oldValue = newValue
}

const effectFn = effect(
// 执行 getter 函数,触发读取
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)

if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}

在这段代码中,我们首先定义了 cleanup 变量,这个变量用来存储用户通过 onInvalidate 函数注册的过期回调函数。在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。最后我们把 onInvalidate 函数作为参数传递给 cb,以便用户使用。

4.12 总结

在本章中,首先介绍了副作用函数和响应式数据的概念,以及它们的关系。一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶中”;当“设置”操作发生时,再将副作用函数从“桶”中取出并执行。这就是相应系统的根本实现原理。

接着我们实现了一个相对完善的响应式系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。同时,我们也介绍了 WeakMap 和 Map 这两个数据结构之间的区别。WeakMap 是弱引用的,它不会影响垃圾回收器的工作。当用户代码对一个对象没有引用关系时,WeakMap 不会组织垃圾回收器回收该对象。

我们还讨论了分支切换导致的冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用的问题。但在此过程中,我们还遇到了遍历 Set 数导致无限循环的新问题(forEach 遍历 Set 集合,如果一个值被访问过了,但这个值被删除并重新添加到集合,如果 forEach 遍历没结束,那么这个值会被重新访问),解决方案是建立一个新的 Set 数据结构进行遍历。

然后我们讨论了关于嵌套的副作用函数的问题(父子组件),为了避免响应数据和副作用函数之间建立的关系错乱,我们需要使用栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈顶弹出。当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应关系。而后,我们还遇到了副作用函数无限递归调用自身,导致栈溢出的问题,该问题的原因在于对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法为:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

随后,我们讨论了响应系统的可调度行(指 trigger 触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式)。为了实现调度能力,我们为 effecct 函数增加了第二个选项参数,可以通过 scheduler 指定调用器,这样用户可以通过调度器自行完成任务的调度。我们还讲解了如何通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。

而后,我们讲解了计算属性,它实际上是一个懒执行的副作用函数,我们通过 lazy 使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。

之后,我们讨论了 watch 的实现原理,它本质上是利用了副作用函数重新执行时的可调度行,一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 注册的回调函数即可。此外还讲解了立即执行回调的 watch,通过添加新的 immediate 实现,还讨论了如何控制回调函数的执行时机,通过 flush 选项指定,本质上是利用了调用器和异步的微任务队列。

最后,我们讨论了过期的副作用函数,会导致状态同步的问题,为了解决这个问题,为 watch 设计了第三个参数 —— onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调函数。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决“竞态”的问题。

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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// 原始数据
const data = { ok: true, foo: 1, bar: 2 }
// 存储副作用函数的桶
const bucket = new WeakMap()
// 用一个栈存储当前激活的副作用函数
const effectStack = []
// 任务队列
// const jobQueue = new Set()
// 存储被注册的副作用函数
let activeEffect

// 从所有的依赖集合中删除当前的副作用函数
const 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
}

// 注册副作用函数
const effect = (fn, options = {}) => {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
// 执行副作用函数,并保存返回值
const res = fn()
// 当前副作用函数执行完毕后,将其从栈中弹出,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 返回副作用函数的返回值
return res
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
// activeEffect.deps(effectFn.deps) 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 非 lazy 懒执行时,立即执行副作用函数
if (!options.lazy) {
// 执行副作用函数
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn
}

// 在 get 拦截函数内调用 track 函数追踪变化
const track = (target, key) => {
// 没有 activeEffect,直接 return
if (!activeEffect) return
// 根据 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)
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
const trigger = (target, key) => {
// 根据 target 从桶中取得 depsMap,即 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 拷贝原有的 Set 数据,避免无限循环的问题
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 obj = new Proxy(data, {
// 拦截读取操作
get (target, key) {
track(target, key)
return target[key]
},
// 拦截设置操作
set (target, key, newVal) {
// 设置属性值
target[key] = newVal
trigger(target, key)
// 返回 true 代表设置操作成功
return true
}
})

// computed 计算属性
const computed = getter => {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true

const effectFn = effect(getter, {
lazy: true,
scheduler () {
if (!dirty) {
// 当读取 value 时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
// 当调度器调度副作用函数时,将 dirty 设置为 true
dirty = true
}
}
})

const obj = {
// 当读取 obj.value 时才执行 effectFn
get value () {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}

return obj
}

// 实现 watch 函数
const watch = () => {

}

// 控制副作用函数执行的时机
/**
effect(
() => {
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler (fn) {
// 将副作用函数添加到宏任务队列中
setTimeout(fn)
// 或者使用微任务队列
// p.then(fn)
}
}
)
*/

// 控制副作用函数执行的次数,如当前宏任务对同一个响应数据的修改,只会执行一次副作用函数
/**
// 使用 Set 数据结构,保证任务只会执行一次
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列中
const p = Promise.resolve()
// 使用一个标志位,标识当前是否正在刷新队列
let isFlushing = false

const flushJon = () => {
// 等待当前的微任务队列清空
if (!isFlushing) {
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
}

effect(
() => {
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler (fn) {
// 将副作用函数添加到微任务队列中
jobQueue.add(fn)
flushJon()
}
}
)
*/