上章问题分析
// vue/examples/reactivity/problem.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="../../dist/vue.js"></script>
</head>
<body>
</body>
<script>
const { reactive, effect } = Vue
const obj = reactive({
name: 'echo',
})
effect(() => {
// 打印两次
console.log('effect run')
document.body.innerText = obj.name
})
setTimeout(() => {
// 副作用函数并没有对此字段进行读取
obj.noExist = 'hello yahaha'
}, 2000)
</script>
</html>
上文我们完成了一个最基础的响应式系统,并指出了系统实现后后带来的问题,即我们在定时器中修改代理对象的字段,在作为effect参数传递的匿名函数中并没有对这个字段进行读取,但副作用函数依然进行了执行。导致这个问题的根本原因是被操作的字段并没有与副作用函数之间建立关联,而是代理对象与副作用函数之间建立了连接。这样的结果就是无论读取代理对象的哪一个字段都一样,副作用函数都会被放入到“桶”中,而设置任何字段也会把“桶”中的副作用函数取出并执行。
而要解决上述问题,我们只需要将关联的建立从副作用函数与代理对象上对应到代理对象的特定字段即可。这就需要我们对“桶”的数据结构进行重新设计了。
三要素
观察下面代码:
effect(()= => {
document.body.innerText = obj.name
})
在这段代码中我们可以看到三个要素
- 代理对象obj
- 被读取操作的字段name
- 使用effect函数注册的副作用函数
如果我们用target表示一个代理对象所代理的原始对象,用key表示被操作的字段,用effectFn表示被注册的副作用函数,那么我们可以为以上三元素建立如下关系:
- 基本关系
- 如果是两个副作用函数读取同一个字段:
- 如果是一个副作用函数读取了同一个对象两个字段:
- 如果在不同的副作用函数中读取了两个不同对象的不同属性
接下来我们尝试用代码实现这个新的“桶”。
代码实现
首先我们先修改桶的数据结构:
// reactivity/src/effect.ts
import type { Dep } from './dep'
type KeyToDepMap = Map<any, Dep>
// 存储副作用的容器
const targetMap = new WeakMap<any, KeyToDepMap>()
// reactivity/src/dep.ts
export type Dep = Set<() => any>
WeakMap的键是原始对象target,值是一个Map实例,而Map的键是原始对象target的key,值是一个副作用函数组成的Set。它们的关系如下图:
然后修改get拦截器:
// reactivity/src/baseHandlers.ts
function createGetter() {
return function get(target: Record<string, any>, key: string) {
if (!activeEffect)
return
// 根据target从桶中获取depsMap, 它也是一个Map类型:key -> effects
let depsMap = targetMap.get(target)
// 如果不存在depsMap,则新建一个Map与target关联
if (!depsMap)
targetMap.set(target, (depsMap = new Map()))
// 再根据key从depsMap中取得deps,它是一个Set类型
// 用于存储当前key关联的副作用函数effects
let deps = depsMap.get(key)
// 如果不存在就新建一个Set与key关联
if (!deps)
depsMap.set(key, (deps = new Set()))
// 将当前激活的副作用函数收集到桶中
deps.add(activeEffect)
return target[key]
}
}
修改set拦截器:
// reactivity/src/baseHandlers.ts
function createSetter() {
return function set(target: Record<string, any>, key: string, newValue: unknown) {
target[key] = newValue
// 根据target从桶中获取depsMap
const depsMap = targetMap.get(target)
if (!depsMap)
return true
// 根据key获取所有的副作用函数
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
return true
}
}
track和trigger封装
在上面的代码实现中,我们都是将副作用函数收集到“桶”中的逻辑写在get拦截器中,将副作用函数重新执行的逻辑写在set拦截器中,但更好的实现应该是将这部分逻辑单独封装到一个函数中,使用track作为函数名表达追踪的含义,使用trigger作为函数名表达触发的含义。
// reactivity/src/effect.ts
export function track(target: object, key: unknown) {
if (!activeEffect)
return
// 根据target从桶中获取depsMap, 它也是一个Map类型:key -> effects
let depsMap = targetMap.get(target)
// 如果不存在depsMap,则新建一个Map与target关联
if (!depsMap)
targetSet.set(target, (depsMap = new Map()))
// 再根据key从depsMap中取得deps,它是一个Set类型
// 用于存储当前key关联的副作用函数effects
let deps = depsMap.get(key as string)
// 如果不存在就新建一个Set与key关联
if (!deps)
depsMap.set(key as string, (deps = new Set()))
// 将当前激活的副作用函数收集到桶中
deps.add(activeEffect)
}
export function trigger(target: object, key: unknown) {
// 根据target从桶中获取depsMap
const depsMap = reactiveMap.get(target)
if (!depsMap)
return
// 根据key获取所有的副作用函数
const effects = depsMap.get(key as string)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
问题
// vue/examples/reactivity/problem.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="../../dist/vue.js"></script>
<style>
</style>
</head>
<body>
</body>
<script>
const { reactive, effect } = Vue
const obj = reactive({
name: 'echo',
ok: true,
})
effect(() => {
// 打印三次
console.log('effect run')
document.body.innerText = obj.ok ? obj.name : 'not'
})
obj.ok = false
obj.name = 'yahaa'
</script>
</html>
执行上述代码,按设想的情况,我们希望副作用函数执行2次,即在字段ok设置为false后,即使设置name也不需要重新执行副作用函数,但是最终的结果effect run被打印了三次。这个问题我将在下一篇文章中分析并解决。