响应系统的作用与实现

响应式数据与副作用函数

副作用函数

副作用函数指的是会产生副作用的函数,如下面代码所示:

1
2
3
function effect() {
document.body.innerText = 'hello vue3'
}

effect函数执行的时候,它会设置 body 的文本内容,但是除了effect函数之外的任何函数都可以读取或者设置 body 的文本内容。也就是说,effect函数的执行会直接或者间接影响其他函数的执行,这个时候我们就说effect函数产生了副作用。

副作用很容易产生,例如一个函数修改了全局变量。这其实也是一个副作用,如下面代码所示:

1
2
3
4
5
let val = 1

function effect() {
val = 2
}

响应式数据
理解了什么是副作用函数,再来看看什么是响应式数据。 假设在一个副作用函数中读取了某个对象的属性:

1
2
3
4
const obj = { text: 'hello guozhaoxi' }
function effect() {
document.body.innerText = obj.text
}

副作用函数effect会设置body元素的innerText属性,它的值为obj.text,当obj.text发生变化的时候,我们希望副作用函数effect会重新执行:

1
obj.text = 'hello world' // 修改obj.text的值,同时希望effect函数能够重新执行

上面这句代码修改了字段 obj.text 的值,我们希望当值发生变化以后,副作用函数自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。

响应式数据的基本实现

我们来思考一下,如何才能让 obj 变成响应式数据呢?通过观察我们能够发现两点线索:

  • 当副作用函数effect执行时,会触发字段obj.text的读取操作;
  • 当修改obj.text的值时,会触发字段obj.text的设置操作。

综上所述,如果我们能够拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段obj.text时,我们可以把副作用函数effect存储到一个桶里。接着,当我们设置obj.text时,再把副作用函数effect从桶里取出并执行即可。

现在问题的关键变成了如何能够拦截一个对象属性的读取和设置操作。

  • 在 ES2015 之前,只能通过通过Object.defineProperty函数实现,这也是Vue2所采用的方式。
  • ES2015 之后,我们可以使用代理对象Proxy来实现,这也是Vue3所采用的的方式。

接下来,我们就根据如上思路,使用Proxy来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const bucket = new Set()

const data = { text: 'hello world' }

const obj = new Proxy(data, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach((fn) => fn())
return true
}
})

设计一个完善的响应系统

以上代码实现了一个微型的响应式系统,之所以说是“微型”。是因为还不够完善。从上一节的例子中不难看出,一个响应系统的工作流程如下:

  • 当读取操作发生时,将副作用函数收集到“桶”中;
  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

在上面代码的实现中硬编码了副作用函数的名字,这样就会导致一个问题那就是一旦副作用函数不叫 effect,那么这段代码就不能正确的工作了。所以我们需要提供一个用来注册副作用函数的机制,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
// 用一个全局变量来存储被注册的副作用函数
let activeEffect

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

// 执行副作用函数
fn()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bucket = new Set()

const data = { text: 'hello world' }

const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach((fn) => fn())
return true
}
})

上面代码存在的问题是:我们没有在副作用函数和目标字段之间建立明确的联系。无论设置的是哪一个属性,都会把桶里的副作用函数拿出来执行一次。那就不能再简单的使用一个Set类型的数据来作为桶了。
那么我们应该设计怎样的数据结构呢?我们来观察下面的代码。

1
2
3
effect(function effectFn() {
document.body.innerText = obj.text
})

在这段代码中存在三个角色:

  • 被操作的代理对象 obj;
  • 被操作(读取)的字段名 text;
  • 使用 effect 函数注册的副作用函数 effectFn。

所以我们要实现一个全新的“桶”。首先要使用WeakMap代替Set作为桶的数据结构。

1
const bucket = new WeakMap()

然后修改 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
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)
}

function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((fn) => fn())
}

分支切换与 cleanup

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

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

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

分支切换可能会产生遗留的副作用函数。那么怎么解决这个问题呢?每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。那么顺着这个思路去想,我们要将副作用函数从与之关联的依赖集合中删除,就要知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 用一个全局变量来存储被注册的副作用函数
let activeEffect

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

// 执行副作用函数
fn()
}
// 用来存储所有与该副作用函数有关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

那么effectFn.deps数组中的依赖集合是如何收集的呢?其实是在track函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
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.push(deps)
}

通过track函数收集好了依赖以后,就建立起了联系,那么我们就可以在每次副作用函数执行时,根据effectsFn.deps获取所有相关的依赖集合,进而将副作用函数从依赖集合中移除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 用一个全局变量来存储被注册的副作用函数
let activeEffect

// effect函数用于注册副作用函数
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn

// 执行副作用函数
fn()
}
// 用来存储所有与该副作用函数有关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
1
2
3
4
5
6
7
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}

至此我们的响应式系统已经可以避免副作用函数产生遗留了。但是如果你尝试运行代码,会发现目前的实现会导致无限循环执行,而问题是出在trigger函数中。那么我们如何来解决这个问题呢?

1
2
3
4
5
6
7
8
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())
// effects && effects.forEach((fn) => fn())
}

如上所示,我们新构造了一个effectToRun集合并遍历它,代表直接遍历effects集合,从而避免了无限执行。

嵌套的effect与effect栈

effect是会发生嵌套的。effectFn1内部嵌套了effectFn2. effectFn1的执行会导致effectFn2的执行。问题原因是因为:我们用activeEffect来存储通过effect函数注册的副作用函数。这意味着同一时刻activeEffect存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会被恢复到原来的值。

为了解决这个问题我们要借助一个副作用函数栈effectStack。在副作用函数执行的时候将当前副作用函数入栈,等到副作用函数执行完毕后将其从栈中弹出,并且呢让activeEffect指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let activeEffect

const effectStack = []

function effect(fn){
const effectFn = ()=> {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}

避免无限递归循环

问题产生的原因是:读取obj.foo这个值的时候,又设置obj.foo这个值。读取值的时候会触发track操作,将当前副作用函数收集到“桶里”,接着设置值的时候又会触发trigger操作,也就是说把桶里的副作用函数拿出来执行。但问题是这个副作用函数正在执行中,还没有执行完毕就又开始下一次的执行,这样就会导致无限递归的调用自己,从而导致了栈溢出。

解决问题的办法:读取和设置都是同一个副作用函数,此时无论是track时收集的副作用函数还是trigger时要触发的副作用函数其实都是activeEffect。基于此呢,我们可以在trigger动作发生的时候增加一个守卫条件:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,那么我们不触发执行。如下代码展示:

1
2
3
4
5
6
7
8
9
10
11
12
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) => effectFn())
}

这样我们就能避免无限递归调用,从而避免栈溢出。

调度执行

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

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

const effectStack = []

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()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()
}
})
}

想要实现一个调度系统,需要依赖异步:Promise队列:jobQueue来进行实现。咱们需要基于Set构建出一个基本的队列数组jobQueue,利用Promise的异步特性,来控制执行的顺序。如下代码所示:

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
// 定一个队列
const queue = new Set()

// 使用Promise.resolve()创建一个promise实例,用它将一个任务添加进微任务队列
const p = Promise.resolve()
// 标志位代表是否正在刷新队列
let isFlushing = false

function flushJob() {
// 如果当前处于刷新队列状态,则什么都不做
if(isFlushing) {
return
}
// 设置为true 标明正在处于刷新队列状态
isFlushing = true
// 在微任务队列中刷新队列
p.then(() => {
jobQueue.forEach((job) => job())
}).finally(() => {
// 结束后重置isFlushing
isFlushing = false
})
}

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

obj.foo++
obj.foo++
  • 使用Set来定义任务队列jobQueue其目的是利用Set数据结构自动去重的能力;
  • 定义一个标志位isFlushing判断是否需要执行,只有当其为false的时候才会执行,所以无论调用多少次jobQueue函数,在一个周期内只会执行一次;
  • 利用Promise.resolve将一个函数添加进微任务队列,在微任务队列内完成对jobQueue的遍历执行。

立即执行的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
function watch(source, cb, options={}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
let cleanup
function onInvalidate(fn) {
cleanup = fn
}
// 提取scheduler调度函数为一个独立的job函数
const job = () => {
// 在scheduler函数中重新执行副作用函数,拿到的是新值
newValue = effectFn()
if (cleanup) {
cleanup()
}
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue, onInvalidate)
// 更新旧值,不然下次会拿到错误的旧值
oldValue = newValue
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect(
// 执行getter
() => getter(),
{
lazy: true,
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()
}
}

总结:

如何解决竟态问题?

竞态问题是因为过期的副作用函数产生的。为了解决这个问题,Vue.js为watch的回调函数提供了第三个参数,也就是onInvalidate。它是一个函数,用来注册过期回调。每当watch的回调函数执行之前,会优先执行用户通过onInvalidate注册的过期回调。这样用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。

计算属性:实际上就是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式来让其执行。利用这个特点设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化时,会通过schduler将dirty标记为true,代表脏。等下次读取计算属性时,我们会重新计算真正的值。

侦听器:本质上是利用了副作用函数重新执行时的可调度性。一个watch会创建一个effect,当这个effect依赖的响应式数据发生变化的时候,会执行该effect的调度器函数,也就是scheduler。这里的scheduler可以理解为回调函数,所以只需要在scheduler中执行用户通过watch函数注册的回调函数即可。通过添加immediate来实现立即执行的回调watch。通过flush选项来指定回调函数具体的执行时机,本质上也是利用了调用器和异步的微任务队列。

可调度性:所谓的可调度就是指当trigger这个动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,增加了第二个参数scheduler,可以通过它来指定调用器,这样用户可以通过调度器自行完成任务的调度。此外呢,调度器实现任务去重就是通过微任务队列对任务进行缓存,从而实现去重。

一个响应式数据最基本的实现依赖对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶中”;当“设置”操作发生时,再将副作用函数从“桶”里拿出来执行。这就是响应式系统的根本实现原理。

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