# effect的实现原理
# 内部是一个响应式对象
根据使用方法可以看出,effect方法接收一个函数作为参数,effect中传入的函数首先会被执行一次,当函数中的属性发生变化的时候,函数会被再次执行。和react中的useEffect特别类似。
内部封装了一个类,每次都会通过ReactiveEffect
这个类创建一个_effect
对象。
这里用到了面向切面编程的技巧,将用户传入的函数执行放入了run方法内,这样,就可以在fn函数执行之前做一些事情。
class ReactiveEffect {
// 这里有一个标识,用来告知是否响应式,默认都是true
public active: boolean = true
public fn
constructor(fn) {
this.fn = fn
}
run() {
// 执行这个函数的时候,就会到proxy上去取值,就会触发get方法。
this.fn()
}
}
export function effect(fn) {
// 这个方法的作用是将用户传递进来的函数,变成一个响应式的effect
// 这个属性就会记住effect 当属性发生变化的时候,重新执行函数。
const _effect = new ReactiveEffect(fn)
// 初始化的时候这个函数会先执行一次
_effect.run()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
接下来我们要思考依赖收集的操作,就像我们上面描述的那样,我们希望使用的key能够记住响应式对象的实例。这样当属性变化的时候就重新执行函数,重新渲染视图,这就是依赖收集。
具体的我们可以借助js单线程的特性,在全局设置一个变量,执行run方法时候,在调用用户传入的函数之前,先将响应式对象赋值给变量,在触发get取值操作的时候,就能获取到这个变量。
export let avtiveEffect = undefined // 全局变量
class ReactiveEffect {
public active: boolean = true
public fn
constructor(fn) {
this.fn = fn
}
run() {
// 执行fn这个函数的时候,就会到proxy上去取值,就会触发get方法。
// 取值的时候,要让当前的属性和对应的effect关联起来 这就是依赖收集
// 当我执行run函数的时候,将当前的这个实例赋值给这个变量, 也就放在了全局上
try {
avtiveEffect = this
this.fn()
} finally {
avtiveEffect = undefined
}
}
}
export function effect(fn) {
// 这个方法的作用是将用户传递进来的函数,变成一个响应式的effect
// 这个属性就会记住effect 当属性发生变化的时候,重新执行函数。
const _effect = new ReactiveEffect(fn)
// 初始化的时候这个函数会先执行一次
_effect.run()
}
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
因为我们的变量是放在全局上的,当我们函数执行完毕之后,还应该把这个变量清空。因为不在函数内部使用的属性,是不需要进行依赖收集的,这里使用到了 try finally 代码块。
语句在 try 和 catch 之后无论有无异常都会执行。所以这里清空是在 finally 中执行的。
try {
tryCode - 尝试执行代码块
}
catch(err) {
catchCode - 捕获错误的代码块
}
finally {
finallyCode - 无论 try / catch 结果如何都会执行的代码块
}
2
3
4
5
6
7
8
9
# 思考:如何准确的保证key能够访问到正确的effect
我们看如下代码:
effect(() => { // e1
app.innerHTML = state.name
effect(() => { // e2
app.innerHTML = state.age
})
app.innerHTML = state.address
})
2
3
4
5
6
7
上面代码中, effect出现了嵌套使用的场景,name属性会记住 e1,age属性会记住e2, 这样就会存在一个问题,当我们进入e2函数取age属性完毕之后,这个函数就是执行完毕了,完毕之后,会将avtiveEffect变量置为undefined,但是当我们的访问 address 属性的时候,就找不到了。
为此,我们需要维护一种关系。在早期的vue3版本中,使用的是栈这种数据结构来维护的关系。就拿上面的例子来说,进入函数之后,name属性依赖于e1, e1入栈,age属性依赖于e2, 这个时候将e2入栈,执行完毕之后将e2出栈,这个时候address就能够找到e1了。
在新的版本中,使用的是一个属性标识,简单来说,就是让每个effect记住自己的父亲是谁,当自己运行完毕之后,再把全局变量赋值回自己的父亲。
来看一下具体的代码实现。
// 借助js单线程的特性,先设置一个全局的变量
export let avtiveEffect = undefined
class ReactiveEffect {
public active: boolean = true
public fn
public parent = null
constructor(fn) {
this.fn = fn
}
run() {
// 执行这个函数的时候,就会到proxy上去取值,就会触发get方法。
// 取值的时候,要让当前的属性和对应的effect关联起来 这就是依赖收集
// 执行run函数的时候,将当前的这个实例赋值给这个变量, 也就放在了全局上
try {
this.parent = avtiveEffect
avtiveEffect = this
this.fn()
} finally {
// 因为我们的变量是放在全局上的,当我们函数执行完毕之后,还应该把这个值清空
avtiveEffect = this.parent
this.parent = undefined
}
}
}
export function effect(fn) {
// 这个方法的作用是将用户传递进来的函数,变成一个响应式的effect
// 这个属性就会记住effect 当属性发生变化的时候,重新执行函数。
const _effect = new ReactiveEffect(fn)
// 初始化的时候这个函数会先执行一次
_effect.run()
}
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
try finnally的用法 (opens new window)
还拿上面的例子说明,执行外层的effect时,avtiveEffect 是个undefined,this.parent就是undefined, 进入内层的时候e2的 this.parent 属性记录为e1, avtiveEffect此时为e2, 当e2执行完毕之后,avtiveEffect 重置为e1, 最后,当e1也执行完毕之后,avtiveEffect重置为最初的undefined。
# 依赖收集实现
在具体的使用场景中,一个属性可以对应多个effect,同样的,一个effect可以对应多个属性,如下面的代码所示:
effect(() => {
app.innerHTML = state.name + state.address
})
effect(() => {
app.innerHTML = state.name
})
2
3
4
5
6
7
外部采用weakMap,内部使用 map effect为了去重使用 set 存储。
// 借助js单线程的特性,先设置一个全局的变量
export let activeEffect = undefined
class ReactiveEffect {
public active: boolean = true
public fn
public parent = null
constructor(fn) {
this.fn = fn
}
run() {
// 执行这个函数的时候,就会到proxy上去取值,就会触发get方法。
// 取值的时候,要让当前的属性和对应的effect关联起来 这就是依赖收集
// 执行run函数的时候,将当前的这个实例赋值给这个变量, 也就放在了全局上
try {
this.parent = activeEffect
activeEffect = this
this.fn()
} finally {
// 因为我们的变量是放在全局上的,当我们函数执行完毕之后,还应该把这个值清空
activeEffect = this.parent
this.parent = undefined
}
}
}
// 整体的映射关系,使用 WeakMap存储
const targetMap = new WeakMap()
// 这个函数接收两个参数,第一个参数是具体的对象,第二个参数是 propKey 具体到哪一个key。
// 一个属性可以被被多次引用,因此可以对应多个effect。因为可能存在同名的属性,所以用target
// 作为map的key。
// 它的数据结构课程是这样的
// {
// target: {
// name: [effect,effect],
// age: [effect,effect]
// }
// }
export function track(target, propKey) {
// 如果在effect外部使用某个属性,不会走run方法,activeEffect不会被赋值,就不会走依赖收集
if (activeEffect) {
// 这里做依赖收集, 首先在weakmap中查找搜索target对象是否存在
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果不存在,就创建这样一个数据结构 value 还是一个map
targetMap.set(target, (depsMap = new Map()))
}
// 开始处理key相关 name 或者 age
let deps = depsMap.get(propKey)
if (!deps) {
// 这里把deps设计成一个set,因为在同一个effect中
// 可能会多次使用同一个属性,无需重复收集
depsMap.set(propKey, (deps = new Set()))
}
// 假设 name 对应的set中 没有收集这个effect 才去添加
let shouldTrack = !deps.has(activeEffect)
if (shouldTrack) {
// 就把当前activeEffect放进去
deps.add(activeEffect)
}
}
}
export function effect(fn) {
// 这个方法的作用是将用户传递进来的函数,变成一个响应式的effect
// 这个属性就会记住effect 当属性发生变化的时候,重新执行函数。
const _effect = new ReactiveEffect(fn)
// 初始化的时候这个函数会先执行一次
_effect.run()
}
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
这个函数接收两个参数,第一个参数是具体的对象,第二个参数是 propKey 具体到哪一个key。一个属性可以被被多次引用,因此可以对应多个effect。因为可能存在同名的属性,所以用target作为map的key。
# 依赖需要双向记忆
上面的代码中,我们已经实现让属性记住自己的effect,做了这样的映射关系,我们接下来要实现双向记录。
双向记录是非常有必要的,因为当某一个effect不存在或者失效的时候,我们还应该通知收集它的属性把这个effect忘记。
// 借助js单线程的特性,先设置一个全局的变量
export let activeEffect = undefined
class ReactiveEffect {
public active: boolean = true
public fn
public parent = null
public deps = [] // 实例上挂载一个deps数组
constructor(fn) {
this.fn = fn
}
run() {
// 执行这个函数的时候,就会到proxy上去取值,就会触发get方法。
// 取值的时候,要让当前的属性和对应的effect关联起来 这就是依赖收集
// 执行run函数的时候,将当前的这个实例赋值给这个变量, 也就放在了全局上
try {
this.parent = activeEffect
activeEffect = this
this.fn()
} finally {
// 因为我们的变量是放在全局上的,当我们函数执行完毕之后,还应该把这个值清空
activeEffect = this.parent
this.parent = null
}
}
}
// 整体的映射关系,使用 WeakMap存储
const targetMap = new WeakMap()
// 这个函数接收两个参数,第一个参数是具体的对象,第二个参数是 propKey 具体到哪一个key。
// 一个属性可以被被多次引用,因此可以对应多个effect。因为可能存在同名的属性,所以用target
// 作为map的key。
// 它的数据结构课程是这样的
// {
// target: {
// name: [effect,effect],
// age: [effect,effect]
// }
// }
export function track(target, propKey) {
// 如果在 effect外部使用某个属性,就不需要收集,这里做个判空处理
if (activeEffect) {
// 这里做依赖收集, 首先在weakmap中查找搜索target对象是否存在
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果不存在,就创建这样一个数据结构
targetMap.set(target, (depsMap = new Map()))
}
// 开始处理key相关
let deps = depsMap.get(propKey)
if (!deps) {
// 这里把deps 设计成一个set,因为在同一个effect中
// 可能会多次使用同一个属性,无需重复收集
depsMap.set(propKey, (deps = new Set()))
}
// 没有收集这个依赖
let shouldTrack = !deps.has(activeEffect)
if (shouldTrack) {
// 就把 activeEffect 放进去
deps.add(activeEffect)
// 双向记忆deps activeEffect.deps 记录的是当前effect关联属性对应的effect
activeEffect.deps.push(deps)
}
}
}
export function effect(fn) {
// 这个方法的作用是将用户传递进来的函数,变成一个响应式的effect
// 这个属性就会记住effect 当属性发生变化的时候,重新执行函数。
const _effect = new ReactiveEffect(fn)
// 初始化的时候这个函数会先执行一次
_effect.run()
}
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
# 触发更新操作
当数据改变的时候,会触视图的更新逻辑。本质上还是一个发布订阅的模式,先在effect里面订阅一个函数,当属性更新的时候,发布执行。
// ....
export function trigger(target, propKey, value) {
// 一层一层的查找
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果没有找到,说明没有依赖任何effect
return
}
const effects = depsMap.get(propKey)
// 获取到对应的set之后,遍历执行里面的run方法
effects && effects.forEach((effect) => { effect.run() })
}
// ...
2
3
4
5
6
7
8
9
10
11
12
13
# cleanup操作。
有如下代码:
const { effect, reactive } = VueReactivity
const state = reactive({ name: 'louis', age: 25, flag: true })
effect(() => { // 副作用函数 (effect执行渲染了页面)
console.log('render')
document.body.innerHTML = state.flag ? state.name : state.age
});
setTimeout(() => {
state.flag = false;
setTimeout(() => {
console.log('修改name,原则上不更新')
state.name = 'zf'
}, 1000);
}, 1000)
2
3
4
5
6
7
8
9
10
11
12
13
14
上面片段中涉及一个新的场景,我们希望根据 flag 属性的值决定在页面中展示name还是age。当flag属性更新之后,页面重新渲染,但是当name已经不再页面中显示的时候,它的值改变,不应该再重新渲染。这个时候就要用到清理的逻辑。
其实本质的问题出在run方法里面,我们应该每次在执行用户传入的fn之前,先清理上一次的effect。
class ReactiveEffect {
// ......
run() {
try {
this.parent = activeEffect
activeEffect = this
cleanEffect(this) // 先清理
this.fn()
} finally {
activeEffect = this.parent
this.parent = null
}
}
// ......
}
// ...
export function cleanEffect(effect) {
// 每次执行之前将之前存放的set清理掉
let deps = effect.deps // deps中存放的是所有属性的set
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
effect.deps.length = 0
}
// ...
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
在每次渲染之前,都先清理之前的依赖,但是这会有一个问题,在执行this.fn()
的时候会进行收集依赖,这样边删除,边收集会造成死循环。
为了解决这个问题,需要在trigger方法中做一些处理,创建一个副本,这样就不会死循环了。
// ....
export function trigger(target, propKey, value) {
// 一层一层的查找
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果没有找到,说明没有依赖任何effect
return
}
let effects = depsMap.get(propKey)
// 创建一个副本
if (effects) {
effects = new Set(effects)
}
// 获取到对应的set之后,遍历执行里面的run方法
effects && effects.forEach((effect) => { effect.run() })
}
// ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 停止effect
看这样一个使用场景:如果我们手动的停止依赖收集的操作,当我们在更改属性的时候,页面就不应该更新了。这里需要注意,目前的代码中effect函数是直接调用的run方法,源码的设计中,返回的是一个runner。
const { effect, reactive } = VueReactivity
const state = reactive({ name: 'louis', age: 25, flag: true })
const runner = effect(() => {
document.body.innerHTML = state.flag ? state.name : state.age
});
runner.effect.stop()
setTimeout(() => {
state.flag = false;
}, 1000)
2
3
4
5
6
7
8
9
10
11
12
实现stop方法:
class ReactiveEffect {
public active: boolean = true
public fn
public parent = null
public deps = [] // 实例上挂载一个deps数组
constructor(fn) {
this.fn = fn
}
run() {
// 执行这个函数的时候,就会到proxy上去取值,就会触发get方法。
// 取值的时候,要让当前的属性和对应的effect关联起来 这就是依赖收集
// 执行run函数的时候,将当前的这个实例赋值给这个变量, 也就放在了全局上
try {
this.parent = activeEffect
activeEffect = this
this.fn()
} finally {
// 因为我们的变量是放在全局上的,当我们函数执行完毕之后,还应该把这个值清空
activeEffect = this.parent
this.parent = null
}
}
stop() {
if (this.active) {
this.active = false
cleanEffect(this)
}
}
}
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
同样的,针对返回runner,需要修改effect函数的实现
export function effect(fn) {
// 将用户传递的函数编程响应式的effect
const _effect = new ReactiveEffect(fn)
// 更改runner中的this
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect // 暴露effect的实例
return runner // 用户可以手动调用runner重新执行
}
2
3
4
5
6
7
8
9
# 批量更新
effect函数接收接收两个参数,一个是fn, 还可以提供一个调度函数,这个调度函数允许用户自定义指定一些操作。
const { effect, reactive } = VueReactivity;
// 会对属性进行劫持 proxy, 监听用户的获取操作和设置操作
const state = reactive({ flag: true, name: 'jw', age: 30, n: { n: 100 } })
let waiting = false
const runner = effect(() => { // 副作用函数 (effect执行渲染了页面)
console.log('runner')
document.body.innerHTML = state.age;
}, {
scheduler() { // 调度函数
if (!waiting) {
waiting = true
Promise.resolve().then(() => {
runner();
waiting = false;
})
}
}
});
setTimeout(() => {
state.age++
state.age++
state.age++
}, 1000)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scheduler 函数内部模拟了一个微任务,当宏任务执行完成后,开始执行内部的代码。因为是支持用户的自定义操作,trigger对应的内容也应该做相应的修改。
// ....
export function trigger(target, propKey, value) {
// 一层一层的查找
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果没有找到,说明没有依赖任何effect
return
}
let effects = depsMap.get(propKey)
// 创建一个副本
if (effects) {
effects = new Set(effects)
}
// 获取到对应的set之后,遍历执行里面的run方法
effects && effects.forEach((effect) => {
if (effect.scheduler) {
effect.scheduler(); // 可以提供一个调度函数,用户实现自己的逻辑
} else {
effect.run()
}
})
}
// ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24