Vue.js设计与实现:响应式系统的作用与实现 4-2

本章目标:

  1. 计算属性的实现原理,如何实现 lazy ,缓存,解决嵌套问题?
  2. watch 是如何实现监听对象变化,并调用回调函数的?监听对象和监听getter函数的区别?
  3. 立即执行的 watch 是如何实现的?如何决定回调执行时机?
  4. ……

4.8 计算属性 computed 与 lazy

上面实现的 effect 会立即执行传入的副作用函数,有时候我们并不希望立即执行,而是需要的时候才执行。例如计算属性。可以通过在 options 中配置 lazy 属性达到目的。

1
2
3
4
5
effect(() => {
console.log(obj.foo)
}, {
lazy: true
})

在 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
function effect(fn, options={}) {
const effectFn = () => {
// 调用 cleanup 完成清除工作
cleanup(effectFn)
// 当前激活的副作用函数
activeEffect = effectFn
// 再副作用函数执行前将当前副作用压入栈中
effectStack.push(effectFn)
const res = fn()
// 在当前副作用函数执行后,从栈中弹出,并把 activeEffect 还原到之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// 定义 deps 数组,用户保存所有和当前副作用相关的依赖集合
effectFn.deps = []
// 只用非 lazy 时才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数返回
return effectFn
}

返回的 effectFn 该何时执行呢?手动执行拿到返回值。

1
2
3
4
const effectFn = effect(
() => obj.foo + obj.bar,
{lazy: true})
const value = effectFn()

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

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

return {
// 读取 value 时才执行 effectFn
get value() {
return effectFn()
},
}
}

实现原理:定义一个 computed 函数,接收 getter 作为参数。内部把 getter 作为副作用函数,创建了一个 lazy 的 effect。computed 函数的执行会返回一个对象,通过 value 访问器读取值。

1
2
3
// {foo: 1, bar: 2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log('sumRes', sumRes.value) // sumRes 3

上面只实现了计算属性的懒计算能力。多次访问会重复计算 sumRes 的值,无法做到对值的缓存。下面进行优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function computed(getter) {
// value 用来缓存上一次计算值
let value
// dirty 标识,用来标识是否需要重新计算
let dirty = true
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
})

return {
// 读取 value 时才执行 effectFn
get value() {
// 只用为脏时才进行计算
if (dirty) {
value = effectFn()
// 将 drity 设置为 false,下次访问直接读取 value 值
dirty = false
}
return value
},
}
}

新增两个属性 value 和 dirty ,其中 value 用于缓存上一次计算值。dirty 用于标识是否需要重新计算。只用 dirty 为 true 才进行调用 effectFn 重新计算。现在测试是否符合:

1
2
3
4
5
6
// {foo: 1, bar: 2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log('sumRes', sumRes.value) // 3

obj.foo++
console.log('sumRes', sumRes.value) // 3

即使修改了 obj.foo 的值,也没重新计算。问题出在我们修改 obj.foo 值后 dirty 标识并没有发生改变。解决方法在 obj.foo 或 obj.bar 发生改变时,修改 dirty 值为 true 。我们该如何做呢?当然是使用上一节学习的 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
function computed(getter) {
// value 用来缓存上一次计算值
let value
// dirty 标识,用来标识是否需要重新计算
let dirty = true
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,调度器将 dirty 重置为 true
scheduler() {
dirty = true
},
})

const obj = {
// 读取 value 时才执行 effectFn
get value() {
// 只用为脏时才进行计算
if (dirty) {
value = effectFn()
// 将 drity 设置为 false,下次访问直接读取 value 值
dirty = false
}
return value
},
}
return obj
}

通过调度器 scheduler 函数,在 getter 函数中所依赖的响应数据发生改变时,通过调度器函数将 dirty 重置为 true。当下次访问 sumRes.value 时,会重新调用 effectFn 函数。
我们设计的计算属性已经趋于完美,但是存在一个缺陷:当另一个 effect 读取计算属性值时:

1
2
3
4
5
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sumRes.value)
})
obj.foo++

在另一个 effect 读取 sumRes.value 时,即使修改 obj.foo 值,无法触发副作用重新执行。
经过分析发现,上述问题本质上就是 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
28
29
30
31
32
33
34
function computed(getter) {
// value 用来缓存上一次计算值
let value
// dirty 标识,用来标识是否需要重新计算
let dirty = true
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,调度器将 dirty 重置为 true
scheduler() {
if (!dirty) {
dirty = true
// 当计算属性依赖的响应式数据变化时,触发 trigger
trigger(obj, 'value')
}
},
})

const obj = {
// 读取 value 时才执行 effectFn
get value() {
// 只用为脏时才进行计算
if (dirty) {
value = effectFn()
// 将 drity 设置为 false,下次访问直接读取 value 值
dirty = false
}
// 当读取 value 时,手动触发 track 函数追踪函数
track(obj, 'value')
return value
},
}
return obj
}

读取计算属性的 value 时,手动调用 track 函数,计算属性返回的对象 obj 作为 target 传递给 track 函数。当依赖数据变化时,手动调用 trigger 函数触发响应。

1
2
3
4
5
const sumRes = computed(() => obj.foo + obj.bar)
effect(function effectFn() {
console.log(sumRes.value)
})
obj.foo++

他们建立的联系:

1
2
3
01 computed(obj)
02 └── value
03 └── effectFn

以上,我们实现了 computed 的功能:

  1. 实现 effect 懒计算,通过 lazy 标识控制 effect 是否执行,将 effectFn 返回给用户决定执行时机。
  2. 缓存上一次计算值,在 computed 内部定义一个 value 和 dirty 标识,在数据为脏时才重新计算,否则直接读取缓存值。关联的响应式数据变化时,通过 scheduler 修改 dirty 的值为 true 触发重新执行。
  3. 最后我们还解决了嵌套导致外层 effect 无法和内层的响应数据关键的问题,通过手动调用 track 函数和 trigger 解决问题。

4.9 watch 实现原理

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

举个🌰,如何实现 watch 函数实现下面监听呢?

1
2
3
4
5
watch(obj, () => {
console.log('数据变了')
})
// 修改数据值
obj.foo++

聪明的你已经发现,通过 effect 配合 scheduler 可是轻松实现:

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

副作用的中通过 scheduler 调度回调函数,watch 实现利用这个点。但上面的实现存在一个问题,只能监听 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
function watch(source, cb) {
effect(
// 触发读取操作,建立联系
// 调用 traverse 递归读取
() => traverse(source),
{
scheduler() {
// 数据变化触发 cb 回调函数
cb()
},
}
)
}
function traverse(value, seen = new Set()) {
// 如果 value 是原始值或者已经被读取过,直接返回
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
}

通过递归 traverse 读取对象的任意属性,从而任意属性发生变化时都能触发回调函数执行。在 Vue 中,watch 第一个参数不仅接收响应式数据,也可以接收一个 getter 函数。在 gettter 内部用户执行 watch 依赖哪些响应式数据,只用当前数据变化时,触发回调函数,例如:

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

需要对 watch 进行修改,处理 source 为函数的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function watch(source, cb) {
let getter
// 如果传入的是函数
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
effect(
// 触发读取操作,建立联系
// 调用 traverse 递归读取
() => getter(),
{
scheduler() {
// 数据变化触发 cb 回调函数
cb()
},
}
)
}
function traverse(value, seen = new Set()) {
// ...
}

对传入的 source 做一层判断,如果是函数,说明传递了 getter 函数,直接使用用户传入的 getter 函数即可。否则调用 traverse 函数递归读取。
上面还缺少一个功能,在回调时无法获取新旧值。如何解决这个问题呢?

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
// 回调函数携带新旧值
function watch(source, cb) {
let getter
// 如果传入的是函数
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const effectFn = effect(
// 触发读取操作,建立联系
// 调用 traverse 递归读取
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 数据变化触发 cb 回调函数,返回新值和旧值
cb(newValue, oldValue)
// 更新旧值,不然下一次得到错误的旧值
oldValue = newValue
},
}
)
// 手动调用副作用函数,拿到的值是旧值
oldValue = effectFn()
}
function traverse(value, seen = new Set()) {
// ...
}

核心修改是使用 lazy 选项创建一个懒执行的 effect。在最后手动调用 effectFn 函数得到的返回值是旧值,第一次执行得到的值。数据变化后触发 scheduler 调度函数执行时,会重新调用 effectFn 函数得到新值。这样就拿到了新值和旧值,将他们作为 cb 的参数传递出去。最后需要刷新旧值,将新旧赋给旧值即可。

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

通过上一节学习,我们知道 watch 的本质是对 effect 的二次封装。本节我们继续学习 watch 的两个特性:

  1. 立即执行的回调函数,在 Vue.js 通过选项参数 immediate指定是否需要立即执行。
  2. 回调函数执行时机,在 Vue.js 通过 flush指定回调函数的执行时机。

首先来看立即执行的回调函数,默认情况下,一个 watch 的回调函数只会在响应式数据发生改变时才执行。回调函数的立即执行和后续执行本质上没有区别,只是 scheduler 调度函数执行时机不同,分别在初始化执行和变化时执行。那么我们对 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
function 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 回调,返回新旧值
cb(newValue, oldValue)
// 刷新旧值
oldValue = newValue
}
const effectFn = effect(
// 触发读操作,建立联系
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数 <------------ 修改
scheduler: job
}
)
// <------------------ 新增
if (options.immediate) {
// 当 immediate 为 true 时,立即执行 job 从而触发回调执行
job()
} else {
// 手动调用副作用函数,拿到旧值(第一调用)
oldValue = effectFn()
}
}

实现思路很简单:将 scheduler 调度函数独立为 job 函数,在 scheduler 使用 job 函数作为调度器函数。另外再添加选项配置项 immediate 属性,在属性为 true 的时候,立即执行 job 函数,从而触发回调执行。

另外,通过 flush 选项参数来指定函数的执行时机。在 Vue.js 3 中:

1
2
3
4
5
watch(obj, () => {
console.log('---', obj.foo)
}, {
flush: 'pre', // flush?: 'pre' | 'post' | 'sync' default: 'pre'
})
  • flush 的值为 pre 表示组件更新前执行,暂时无法模拟,因为设计组件更新时机。
  • flush 的值为 post 时,调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后执行。
  • flush 的值为 sync 时,同步执行,相当于直接调用 job 函数执行。

下面模拟实现 post 异步执行副作用函数:

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
function 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 回调,返回新旧值
cb(newValue, oldValue)
// 刷新旧值
oldValue = newValue
}
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 {
// 手动调用副作用函数,拿到旧值(第一调用)
oldValue = effectFn()
}
}

4.11 过期的副作用

竞态问题通常是多线程或多进程编程被提及的问题。但是在一些情况下前端也会遇到竞态问题,例如下面的例子:

1
2
3
4
5
6
7
8
9
let finalData

watch(obj, async () => {
// 发生网络请求,
const res = await fetch('xxxx')
// 将请求结果赋值给 data
finalData = res
})

这段代码,监听 obj 变化时发送网络请求,将请求结果赋值给 finalData。看起来没有问题,仔细思考会发现这段代码会发生竞态问题。
第一次请求 A 发送后,又重新发生请求B,因为网络原因,请求 B 先返回数据,请求 A 才返回数据,此时 finalData 是请求 A 产生的数据。理论上,请求 B 返回的数据是“最新”的,请求A 返回的数据就是过期数据,我们希望变量 finalData 存储的值是请求 B 返回的结果。
我们对上面问题做进一步总结,请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是第二次执行产生的副作用。请求 B 发生后,请求 A 已经过期,应该把请求 A 产生的结果视为无效,这样就避免竞态问题产生。
总之,我们需要一种手段让副作用过期,在 Vue.js 中, watch 函数的回调函数接受第三个参数 onInvalidate 是一个函数,类似于事件监听器,可以使用它注册一个回调,这个回调函数会在当前副作用函数过期时执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个表示,代表当前副作用是否过期,false 没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('path/to/request')
// 只有当该副作用函数的执行没有过期时,才执行赋值操作
if (!expired) {
finalData = res
}
})

Vue.js 如何实现 onInvalidate 的呢?在 watch 每次检测到变化后,在副作用函数重新执行前,会先调用 onInvalidate 函数注册的过期回调。那么我们对 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
function watch(source, cb, options={}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 用于存储用户注册的过期回调
let cleanup
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
let oldValue, newValue
// 提取 scheduler 调度函数为独立的 job 函数
const job = () => {
// 当数据变化时,调用回调函数
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数
cb(newValue, oldValue, onInvalidate)
// 刷新旧值
oldValue = newValue
}
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 {
// 手动调用副作用函数,拿到旧值(第一调用)
oldValue = effectFn()
}
}

使用 cleanup 变量将用户定义的过期回调存储,在 job 函数中,每次回调 cb 之前先检查是否存在过期回调,存在执行回调函数 cleanup,最后把 onInvalidate 作为第三个 cb 参数返回给用户。

参考