前文提要:3.0 响应式系统的设计与实现
1、设置一个合理的effect副作用函数
如上文所说,如果我们直接将简单的effect
函数作为副作用函数,如果一个副作用函数不叫effect
岂不是找不到了。解决方案也很简单,我们设定一个全局变量用于注册副作用函数,代码如下:
let activeEffect = null
function effect(fn) {
// 当调用effect函数注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = fn
fn()
}
此时effect
函数只是用于注册的功能,它接收一个参数fn
,这个参数就是要注册的副作用函数,可以如下的方式使用effect
函数:
effect(
() => {document.body.innerText = obj.text}
)
此时将一个匿名函数作为副作用函数传递给effect
,这个匿名函数将被赋值给activeEffect
,这样只需要每次将activeEffect
放入桶中就行了。如:
const bucket = new Set()
const obj = new Proxy(data, {
get(target, key) {
// 如果对象存在副作用函数,则放入桶中
if(activeEffect) bucket.set(target, activeEffect)
return target[key]
},
set(target, key, newVal) {
// 每次赋值取出对象的副作用函数执行
bucket.forEach(fn => fn())
target[key] = newVal
return true
}
})
上述代码中的执行逻辑可以很清晰的看出,由于副作用函数存储到了activeEffect
中,在get
的时候可以直接将activeEffect
函数放入桶中,这样就不需要依赖副作用函数叫什么名字了。
2、更加细化地绑定副作用函数
我们上面说所的所有的副作用函数其实都是直接绑定在对象上的,因为我们只给对象设定了桶,所以每次改变对象内无论哪个值都会将桶内的副作用函数全部执行。
这会导致什么问题呢,最明显的就是性能浪费,如果一个很大的对象,里面的每个元素都有很多副作用函数,那么我们在改变一个无关元素时也会执行全部副作用函数。
此时我们可以改变下桶的方式,使用一个WeakMap作为桶,WeakMap中的每一个键为一个响应式对象,它的值为一个Map,此时这个Map中的键为对象内的属性名,这个键的值为Set,此时Set内部装的是对象元素的副作用函数。如图所示:
代码实现如下:
// WeakMap的说明见下文
const bucket = new WeakMap()
const obj = new Proxy(data, {
get(target, key) {
// 没有副作用函数直接返回
if(!activeEffect) return target[key]
// 获取当前对象内的所有元素的Map
let depsMap = bucket.get(target)
// 当前对象还没有Map时新建一个
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 获取当前元素的所有副作用函数的Set
let deps = depsMap.get(key)
// 没有时新建并添加副作用函数
if(!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
const depsMap = bucket.get(target)
if(!depsMap) return
const deps = depsMap.get(key)
deps && deps.forEach(fn => fn())
return true
}
})
}
其实代码看起来很复杂,其实只有三个容器,WeakMap,Map,Set,这里WeakMap是用于将所有的对象作为键值(key)放入的,映射的是一个Map,这个Map以这个对象的所有元素属性名作为键,其值为一个Set,这个Set内存储当前键的所有副作用函数。
这样每个副作用函数就精细地绑定到一个对象的一个元素上。
3、关于WeakMap
这里还需要说下WeakMap和Map的区别,其实这两者的映射关系是相同的,区分是WeakMap为弱引用。什么是弱引用呢,假设我们有个对象仅仅被Map引用,此时对象没有其他引用,这时对象是不会被垃圾回收器回收的,因为这个Map存在则对象的引用一直存在,但是如果这个对象仅仅被一个WeakMap引用,在无其他引用时会被垃圾回收器回收。即WeakMap不会影响垃圾回收器,如下代码可以很好解释:
const weakMap = new WeakMap()
const map = new Map()
(function () {
const foo = {foo: 1}
const bar = {bar: 1}
map.set(foo, 1)
weakMap.set(bar, 1)
})()
在立即执行函数内,两个对象foo被map引用,bar被weakMap引用,当执行完之后,bar会被回收掉,而foo依旧存在。
WeakMap还有一点即没有迭代器,无法像Map一样直接迭代遍历值,所以一般WeakMap常用于不会影响对象本身的映射,或者用于标记。
在Vue中使用WeakMap作为最外面的桶也很好理解,这不会导致对象被桶长期引用而无法被回收,桶并不会影响程序本身的执行。
4、分支切换与cleanup
在介绍分支切换之前可以先将上述代码做一个封装,将get
拦截函数理中关于副作用的收集封装成track
函数,将set
函数中副作用函数的触发分装到trigger
函数中,如下代码:
// 使用桶将所有的包含副作用函数的对象放入
const bucket = new WeakMap()
const obj = new Proxy(data, {
get(target, key) {
// 追踪函数
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
// 触发函数
trigger(target, key)
return true
}
})
function track(target, key){
// 没有副作用函数直接返回
if(!activeEffect) return target[key]
// 获取当前对象内的所有元素的Map
let depsMap = bucket.get(target)
// 当前对象还没有Map时新建一个
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 获取当前元素的所有副作用函数的Set
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 deps = depsMap.get(key)
deps && deps.forEach(fn => fn())
}
}
这样在关于Proxy的代理中就简单明了很多。
下面我们看一段代码:
const data = {ok: true, text: 'hello world'}
const obj = new Proxy(data, {/* 省略 */})
effect(() => {
// 如果obj.ok为true则读取obj.text的值
document.body.innerText = obj.ok? obj.text: 'not'
})
在我们写的响应式系统中,这个副作用函数会同时和obj.ok和obj.text关联,无论修改obj.ok还是修改obj.text都会触发这个副作用函数。但是仔细想想这真的有必要吗,其实是没有必要的。
在这段代码里如果obj.ok为true,这时这个副作用函数和obj.text是关联的,否则不关联,因为此时body的文本内容永远为not。
那怎么优化这一点呢,其实我们在上一篇文章中说过,一个响应式数据执行流程为:
- 修改obj.content的值,这会触发effect函数的执行 。
- 触发effect函数,这会获取obj.content的值。
之前在这里讲述过第二点,是否触发effect函数一定会获取obj.content的值,回答是肯定的,如果这里不获取obj.content的值则不需要建立响应式数据了。
我们根据这一点,在副作用函数执行之前将这个副作用函数从所有元素中删去,然后再执行副作用函数,如果副作用函数执行过程中需要读取当前对象的元素值,这会重新建立副作用函数。
这样我们代码需要增加三个部分,第一部分是cleanup
用于从所有元素中删除副作用函数,第二个部分是在副作用函数中增加一个数组,用于记录和这个副作用函数相关的所有元素的Map,第三个部分是在track
的时候将与该副作用关联的元素记录下来。
function cleanup(effectFn) {
// 遍历包含副作用函数effctFn的集合
for(let i=0;i<effectFn.deps.length;i++) {
const deps = effectFn.deps[i]
// 在集合中将effctFn副作用函数删去,在执行的时候会重新建立
deps.delete(effectFn)
}
// 包含该副作用函数的集合目前为0
effectFn.deps.length = 0
}
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
//先清除再执行,自然就形成了分支切换
cleanup(effectFn)
fn()
}
effectFn.deps = []
effectFn()
}
function track(target, key){
// 没有副作用函数直接返回
if(!activeEffect) return target[key]
// 获取当前对象内的所有元素的Map
let depsMap = bucket.get(target)
// 当前对象还没有Map时新建一个
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 获取当前元素的所有副作用函数的Set
let deps = depsMap.get(key)
// 没有时新建并添加副作用函数
if(!deps) depsMap.set(key, (deps = new Set()))
deps.add(key, activeEffect)
// 这里将副作用函数相关的Map记录下来
activeEffect.deps.push(deps)
}
其实分支切换的思想很简单,就是在副作用函数执行前将当前副作用函数从关联的元素中全部删除,然后再执行副作用函数,在执行的时候如果读取了该元素,副作用函数又会重新关联,自然就形成了分支切换。
这样下来我们的响应式系统又完善了不少,其实还有不少的问题还没结局,比如嵌套的effect如何执行,如何给副作用函数做调度,是否会存在无限递归等问题,这会在后面讲解