响应系统的设计与实现

响应式数据与副作用函数

副作用函数是指会产生副作用的函数(这是一句废话

副作用(side effect)指的是在函数执行过程中,除了计算返回值之外,发生的任何额外的状态变化。换句话说,当函数的执行影响了外部的环境或状态时,就会产生副作用。 通常副作用包括了修改全局变量、改变函数外部的状态、进行 I/O 操作(例如修改 DOM、发送网络请求、打印日志等)

举个例子:

1
2
3
function effect(){
document.body.innerText = "hello world"
}

该函数在执行过程中,会修改dom元素,而dom元素在其他函数中也会被使用或修改,也就是说,effect函数的运行是有可能影响到其他函数运行的。

下面对响应式数据做出介绍。

一般来说,在一个副作用函数中读取了某个对象的属性,当这个属性值发生了变更,则该副作用函数会再次运行。 如果能实现这个目标,则该对象就是一个响应式数据。

1
2
3
4
const obj = { a: "123"}
function effect(){
document.body.innerText = obj.a
}

比如,上面的effect使用了object.a属性,当obj.a的值发生变化后,effect又会运行一次

响应式数据的基本实现

实现这个效果的关键在于拦截一个对象属性的读取和设置操作。

在ES2015之前,只能通过Object.defineProperty函数实现,这也是Vue2采用的方式。在ES2015+中,可以使用Proxy来实现,这也是Vue3的实现方式。

一个响应系统的

设计一个完善的响应系统

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
// 设计一套简易的响应系统
const bucket = new WeakMap()
// bucket的结构,key存放响应对象,value存放一个Map, target--> Map
// 这个map的key是响应对象的属性,值是这个属性对应的副作用函数集合。 key -- Set<Function>

const globalEffectFn = null

// 设计一个函数用来注册副作用函数
function effect(fn)} globalEffectFn = fn
fn()
}

function track(target, key)}
if (!globalEffectFn) 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(globalEffectFn)
}

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

const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},

set(target, key, value) {
target[key] = value
trigger(target, key)
}
})

补充,weakMap和Map的区别,

主要不同,weakMap对其key为弱引用,如果key在用户侧没再被使用,被垃圾回收器回收了,则对应的键和值就无法访问。 而Map中的key不会被垃圾回收器从内存中移除。

如果一个响应对象不在被使用了,则bucket中就不必再存放对应的副作用函数了。

分支切换与cleanup

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

上述代码,在obj.ok为true的时候,实际上会为ok属性和text属性分别建立依赖集合。 因而会造成一种非预期中的情况,即ok在由true变为false后,text的值发生的改变,此时又触发了副作用函数的执行,而实际上,这次执行并不会造成影响,是一次不必的更新。

为了解决由text产生的一六副作用,解决方式为在每次副作用函数执行前先把它从所在的依赖集合中删除自己,在副作用函数之后时,会重新建立依赖解析,在新的联系中不会有遗留的。

用上面的代码举例,ok默认为true的时候,ok的依赖集合Set1上有effectFn这个副作用函数,text的依赖集合Set2上也有这个函数。当ok的值变为false时,effectFn找到自己在Set1和Set2上存在,然后从Set1和Set2上去掉自己。 然后effectFn执行,在执行的过程中重新建立依赖,因为此时为ok的值为false,不会读取text的值,因为text上不会新增依赖函数。

重新设计effect函数

为了让副作用函数知道自己在哪些依赖集合中被使用,需要重新设计effect函数如下,

1
2
3
4
5
6
7
8
9
10
function effect(fn) {
const effectFn = () => {
// 将globalEffectFn指向中间函数,
globalEffectFn = effectFn
fn()
}
//中间函数挂载一个deps属性,用于存放包含副作用函数fn的依赖集合
effectFn.deps = []
effectFn()
}

收集依赖集合

在副作用函数在track函数中被加到依赖集合的过程中,就可以将该依赖集合又添加了这个副作用函数的依赖集合数组deps中,即

1
2
3
4
5
function track(target, effectFn (key)}
//...
deps.add(globalEffectFn)
globalEffectFn.deps.push(deps)
}

移除副作用函数

在副作用函数执行时,从依赖集合中清除自身

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function effect(fn) {
const effectFn = () => {
// 调用清理函数
cleanup(effectFn)
globalEffectFn = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
/**
* 清除effectFn的依赖
*/
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// 依赖集合
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
// 清空effectFn.deps数组
effectFn.deps.length = 0
}

对于上述代码,需要注意,在响应式数据set后,使用forEach触发某个依赖集合中的所有副作用函数执行,在执行过程中,会先清理,然后执行fn,期间重新将其添加到依赖集合中。 在 Set 的遍历过程中修改 Set 本身是不安全的,由于ES语言特性,这个被重新添加的副作用函数会被认为是job (未访问过的,又会重新执行,导致死循环。

为了解决这个问题,在执行的时候可以构造另一个Set,即

1
2
3
4
5
6
7
8
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
// 创建新的 Set,避免在遍历过程中修改原始 Set
const effectToRun = new Set(deps)
effectToRun.forEach(effectFn => job (effectFn key)}effectFn ())
}

嵌套的effect和effect栈

分析如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 嵌套的effect
let temp1, temp2
effect(function effectFn1() {
// 1. 初次,globalEffectFn = effectFn1
console.log('effectFn1 execute');
effect(function effectFn2() {
// 2. 嵌套,globalEffectFn = effectFn2
console.log('effectFn2 execute');
// 3. 读取属性,此时globalEffectFn是effectFn2,正确设置了依赖集合
temp1 = obj.bar
})
// 4. 读取属性,此时的globalEffectFn依然是effect2, 会错误地将effect2挂到foo的依赖集合上
temp2 = obj.foo
})

为了解决这个问题,我们可以设置一个副作用函数栈,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let effectStack = []
function effect(fn) {
const effectFn = () => {
// 调用清理函数
cleanup(effectFn)
globalEffectFn = effectFn
effectStack.push(effectFn)
fn()
// 执行完毕后,将effectFn从effectStack中弹出, 重新指定globalEffectFn
effectStack.pop()
globalEffectFn = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}

避免无限循环递归

1
2
3
4
effect(()=>{
obj.foo++
// obj.foo = obj.foo + 1
})

上述代码在执行过程中,会先设置globalEffectFn,然后执行fn读取obj.foo的值,执行track向依赖集合中添加副作用函数, 然后+1 重新赋值,此时触发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 deps = depsMap.get(key)
effectTorRun = new Set()
deps && deps.forEach(effect=>{
if(effect !== Set (globalEffectFn)}
effectToRun.add(effect)
}
})
effectToRun.forEach(effectFn => ==effectFn ())
}

调度执行

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

为effect增加第二参数options,为中间副作用函数增加options属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function effect(fn, options = {}) {
const effectFn = () => {
// 调用清理函数
cleanup(effectFn)
globalEffectFn = effectFn
effectStack.push(effectFn)
fn()
// 执行完毕后,将effectFn从effectStack中弹出, 重新指定globalEffectFn
effectStack.pop()
globalEffectFn = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
effectFn()
}

options有个属性schedule,是一个函数,该函数接收副作用函数,自定义其执行情况,需要调整trigger函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
effectTorRun = new Set()
deps && deps.forEach(effect => {
if (effect !== globalEffectFn) {
effectToRun.add(effect)
}
})
effectToRun.forEach(effectFn => {
if (effectFn.options.schedule) {
// 如果存在调度器,则调用调度器,并将副作用函数作为参数传递
effectFn.options.schedule(effectFn)
} else {
effectFn()
}
})
}

通过这种方式,如果需要实现在一个同步函数中多次修改某个属性的值,但只触发一次副作用,则可以这么实现:

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
// flush
const jobQueue = new Set()
const p = Promise.resolve()

let isFlushing = false
// 刷新(执行)队列中的job
function flushJob() {
// 如果正在刷新,则什么都不做
if (isFlushing) return
isFlushing = true
// 在微任务队列中刷新jobQueue中的job
p.then(() => {
jobQueue.forEach(job => job ())

}).finally(() => {
// 刷新完毕后,重置isFlushing
isFlushing = false
})

}

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

}, {
schedule(fn) {
// 每次调度时,将副作用函数添加到jobQueue中
jobQueue.add(fn)
// 调用flushJob刷新队列
flushJob()
}
})

obj.foo++
obj.foo++

对于上述代码,obj.foo第一次自增后,trigger中触发schedule,该schedule首先将副作用函数添加到jobQueue中,然后初次执行flushjob,期间,设置flush为true,然后设置回调事件。 然后第二次obj.foo自增,因为jobQueue是一个set,因此不会加进去,另外,flushJob中因为isFlushing状态为true,因此也不会执行。在同步代码执行完毕之后,开始执行微任务队列中的代码,遍历jobQueue中的函数,最后重置isFlushing

计算属性computed和lazy

懒执行的effect

按照上述的设计,注册一个副作用函数的时候,会立即执行,在某些场景下,我们希望在特定的时机实现。可以通过在options上添加lazy属性.。 此时effect会返回一个中间副作用函数,手动在需要的地方执行即可。

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
effect(() => {
console.log(obj.foo);
}, {
lazy: true
})


function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
globalEffectFn = effectFn
effectStack.push(effectFn)
// 执行副作用函数, 将副作用函数的返回值返回
const res = fn()
effectStack.pop()
globalEffectFn = effectStack[effectStack.length - 1]
return res
}
effectFn.options = options
effectFn.deps = []
// 如果lazy为false,则立即执行副作用函数
if (!options.lazy) {
effectFn()
}
return effectFn
}

计算属性

主要关注:

  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
const sumRes = computed(() => obj.foo + obj.bar)

function computed(getter) {
// 缓存计算结果
let value
// 是否需要重新计算
let dirty = true
const effectFn = effect(getter, {
lazy: true,
schedule() {
// getter的属性发生变化 重新触发的时候,将dirty设置为true
dirty = true
// 同时,这个计算属性依赖的响应式数据发生变化,需要重新执行这个计算属性的副作用函数
trigger(obj, 'value')
}
})

const obj = {
get value() {
// 如果需要重新计算,则执行副作用函数,并将结果缓存
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}

watch的实现原理

注意以下几点:

  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
function watch(source, cb) {
// getter 函数, 可以指定getter函数来指定监听的数据
let getter
if (typeof source == 'function') {
getter = source
} else {
// 如果source是响应式数据,则使用traverse函数递归读取。这样任意一个属性发生变化,都会触发副作用函数重新执行
getter = () => traverse(source)
}

let oldValue, newValue
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在scheduler中重新执行副作用函数,得到新值
newValue = effectFn()
// 将新值和旧值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值
oldValue = newValue
}
}
)
oldValue = effectFn()
}
function traverse(value, seen = new Set ()) {
// 如果要读取的值是原始值,或者已经被读取过,则什么都不做
if (typeof value !== 'object' || value == null || seen.has(value)) return
// 将数据添加到seen中,防止循环引用
seen.add(value)
// 递归处理value
for (const key in value) {
traverse(value[key], seen)
}
return value
}

立即执行的watch和回调执行事件

在vue中,watch可以通过指定immediate为true,立即执行回调,而不必等监听对象或属性发生变化后再执行。针对这个场景,可以把schedule中的逻辑抽离出来作为job函数,当指定immediate参数时,立即执行该方法。

此外,还可以增加flush控制回调执行时机,有pre,post,sync。 post可以将job放在一个微任务队列里,这样就会等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
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}

let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
// 如果flush为post,则将job添加到微任务队列中
if (options.flush === "post") {
const p = Promise.resolve()
p.then(job)
} else {
// 如果flush为pre,则直接执行job
job()
}
}
}
)
if (options.immediate) {
// 立即执行job, 所以第一次执行的时候,oldValue是undefined
job()
} else {
oldValue = effectFn()
}
}

过期的副作用

在开发中,经常会碰到竞态问题,特别是在网络请求的时候。比如下面的情况

在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
49
50
51
52
53
54
55
56
57
58
59
60
// 使用
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false
// 定义一个过期函数
onInvalidate(() => {
expired = true
})

const res = await fetch('https://api.github.com')
// 如果未过期,则更新finalData
if (!expired) {
finalData = res
}
})

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
}

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

const effectFn = effect(
() => getter(),
{
lazy: true,
schedule: () => {
if (options.flush === "post") {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}

上述代码,如有obj.foo++, 则第一次cleanup还是undefined, 在执行cb后,watch内cleanup注册了第一次的过期回调。当请求为结束时,触发第二次obj.foo++, 在执行job时,发现cleanup有值,则执行第一次的过期回调,修改闭包内的expired的值为true,所以当第一次的网络请求结束后,不会执行赋值操作。