1. 副作用函数注册
在上节中,简易实现了一个响应式系统,这种实现方式存在很多问题需要解决。 首先,对于副作用函数的收集,我们通过将变量名硬编码为effect来实现。这种硬编码方式存在很大局限,当我们的副作用函数命名变更时就无法正确收集,我们所期望的是即使副作用函数是一个匿名函数也能正确将其收集。 为了实现这一点,我们需要提供一个用来注册副作用函数的机制:
js
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
在代码中,我们设置了一个全局变量activeEffect,其作用是指向实际执行的副作用函数fn。在effect函数中传递实际的副作用函数fn作为参数,在执行前,将activeEffect变量指向fn,随后执行副作用函数fn。
如下列代码所示,副作用函数以匿名函数的形式传递给effect函数,完成副作用函数的注册。
js
effect(() => {
document.body.innerText = obj.text
})
相应的,我们需要修改响应式数据的收集过程。
js
const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到"桶"中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
整个依赖收集流程如下:在effect函数执行过程中,首先将全局变量activeEffect指向副作用函数fn,随后执行fn;在fn执行过程中,读取了obj.text属性,因此会触发代理对象的get拦截函数;在get函数中对activeEffect进行判断,此时应该指向的是副作用函数fn,因此实现了将当前正在执行的副作用函数fn收集到bucket中;后续的依赖触发过程如前所述。
2. 副作用函数与对象属性建立联系
js
effect(
// 匿名副作用函数
() => {
console.log('effect run') // 会打印 2 次
document.body.innerText = obj.text
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)
在上面这段代码中,我们在副作用函数中读取了obj.text,我们希望实现的效果是当obj的text属性值发生变化时,会自动调用副作用函数,将document.body.innerText重新赋值。然而,在定时器中我们设定一秒后对obj的notExist属性值进行修改,按照预期副作用函数不应该重新执行。但是事实是副作用函数在一秒后重新执行了。
产生上述问题的主要原因是,在依赖的收集和触发过程中,我们并未在副作用函数和对象属性间建立联系。
解决方案
在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的"桶"。导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到"桶"里;当设置属性时,无论设置的是哪一个属性,也都会把"桶"里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计"桶"的数据结构,而不能简单地使用一个Set类型的数据作为"桶"了。
在依赖收集中存在三个角色: 被操作(读取)的代理对象 obj; 被操作(读取)的字段名 text; 使用 effect 函数注册的副作用函数 fn。
如果用 target 来表示一个代理对象所代理的原始对象,用 key来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
markdown
target
└── key
└── fn
如果有两个副作用函数fn1和fn2同时读取同一个对象的属性值,那么关系如下:
js
target
└── text
└── fn1
└── fn2
如果有两个副作用函数fn同时读取同一个对象的属性值text1和text2,那么关系如下:
js
target
└── text1
└── fn
└── text2
└── fn
如果在副作用函数fn中读取了两个不同对象的不同属性,那么关系如下:
js
target1
└── text1
└── fn
target2
└── text2
└── fn
总而言之,我们需要建立一个树形结构,最上层为对象,下一层是对象属性,最下层是收集到的副作用函数。为实现这种结构,使用Set作为桶显然不能满足要求,我们很容易想到用多层Map来实现替换。再进一步思考,我们可以发现最上层的Map需要用Object类型作为key来实现收集,对于key是Object形式,使用WeakMap显然比Map更为合适,其弱引用的特性可以更好地实现垃圾回收机制。
基于以上思路,我们可以对依赖收集和触发的过程进行一定改进:
js
// 存储副作用函数的桶
const bucket = new WeakMap()
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]
// 根据 target 从"桶"中取得 depsMap,它也是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到"桶"里
deps.add(activeEffect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})
最终我们建立的关系如下图所示,WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。
可以看到,相比于最初的简易版响应式系统,目前的get拦截函数和set拦截函数的内容已经增加了不少,为例代码的易读性,同时降低耦合性,我们需要将get和set拦截函数中的部分代码进行抽离,分装成依赖收集函数track和依赖触发函数trigger。
js
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
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)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
至此,我们已经实现了一个基础版响应式系统,可以建立不同对象不同属性和副作用函数的依赖关系,在修改对象属性时可以执行对应的副作用函数。
在后续文章中我们将继续对一些其它问题进行优化,继续完善响应式系统。