本文正在参加「金石计划 . 瓜分6万现金大奖」
前言
继上一篇文章,我们已经能够实现一个简单的响应系统了,但是仍然存在很多缺陷,本篇文章将具体叙述一下存在的缺陷以及如何解决,实现一个相对上一篇文章更完善的响应式系统
解决副作用函数硬编码问题
从上一篇文章中我们不难发现响应系统的工作流程大致如下:
- 当读取操作发生时,将副作用函数收集到“桶”中;
- 当设置操作发生时,从“桶”中取出副作用函数并执行;
而上一篇文章中我们实现的响应式系统存在硬编码问题,而我们希望的是,哪怕副作用函数是个匿名函数,也能够被正确地收集到“桶”中。
为了实现这个需求,我们需要提供一个用来注册副作用函数的机制,如:
//用一个全局变量存储被注册的副作用函数
let activeEffect
//effect 函数用于注册副作用函数
function effect(fn){
//当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect=fn
//执行副作用函数
fn()
}
如上代码,首先,定义了一个全局变量 activeEffect ,初始值是 undefined ,它的作用是存储被注册的副作用函数。接着重新定义了 effect 函数,它变成了一个用来注册副作用函数的函数, effect 函数接收一个参数 fn ,即要注册的副作用函数。
同时需要修改代理函数:
const bucket = new Set()
let data = {text:"hello world"}
let obj = new Proxy(data,{
get(target,key){
//将activeEffect中存储的副作用函数收集到“桶”中
if(activeEffect){
//将副作用函数effect添加到存储副作用函数的桶中
bucket.add(activeEffect)
}
return target[key]
},
set(target,key,newVal){
target[key]=newVal
//把副作用函数从桶中取出来并执行
bucket.forEach(fn=>fn())
return true
}
})
这样我们就解决了副作用函数硬编码的问题,我们可以写一个简单的测试代码来测试一下,如:
effect(
()=>{
console.log("我是匿名函数")
document.body.innerText=obj.text
}
)
setTimeout(()=>{
obj.text="你好 世界"
},1000)
在浏览器中运行上段代码,会发现结果与我们预期的一样。
但是如果我们运行如下代码,猜猜会发生什么:
effect(
()=>{
console.log("我是匿名函数")
document.body.innerText=obj.text
}
)
setTimeout(()=>{
//副作用函数中并没有读取abc属性的值
obj.abc="你好 世界"
},1000)
我们知道,在匿名副作用函数内并没有读取 obj . abc 属性的值,所以理论上,字段 obj . abc 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。
导致这个问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系
我们之前设计的响应系统会在读取属性时,无论读取的是什么属性都会把副作用函数收集到“桶”中,设置属性时,无论设置的是哪一个属性,都会把“桶”中的副作用函数取出来执行;副作用函数与被操作的字段之间没有联系
为了解决这个问题,我们需要重新设计“桶”结构
树形结构的“桶”
树形结构的“桶”可以很好的解决上面的问题
例如我们有这么一段代码:
effect(function fn1(){
obj.text1
obj.text2
})
那么它们的依赖关系如下:
又如这段代码:
effect(function fn1(){
obj.text
})
effect(function fn2(){
obj.text
})
它们的依赖关系如下:
出于性能考虑,我们用WeakMap代替Set作为“桶”的数据结构,然后修改get/set拦截器代码,如下:
//存储副作用函数的桶
const bucket = new WeakMap()
//原始数据
let data = {text:"hello world"}
//对原始数据的代理
let 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
const depsMap=bucket.get(target)
if(!depsMap)return
//根据key从depsMap中取得所有的副作用函数effects
const effects=depsMap.get(key)
//执行副作用函数
effects&&effects.forEach(fn=>fn())
return true
}
})
这样使得get/set拦截器非常复杂,可以将这些代码做一些封装处理,如:
let obj = new Proxy(data,{
//拦截读取操作
get(target,key){
//将副作用函数activeEffect添加到存储副作用函数的桶中
track(target,key)
//返回属性值
return target[key]
},
//拦截设置操作
set(target,key,newVal){
//设置属性值
target[key]=newVal
//把副作用函数从桶中取出来并执行
trigger(target,key)
//返回true代表设置操作成功
return true
}
})
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)
}
function trigger(target,key){
target[key]=newVal
const depsMap=bucket.get(target)
if(!depsMap)return
const effects=depsMap.get(key)
effects&&effects.forEach(fn=>fn())
}
遗留的副作用函数
上面的代码仍然有问题,比如副作用函数中有一个三元表达式,两个分支分别读取了同一个对象的不同属性,而执行的时候只能用上其中一个,我们需要的是指在执行时用上的那个属性进行副作用函数依赖绑定,也就是修改了那个属性才会触发副作用函数执行而另一个属性修改了不执行,但是我们的代码不管是哪个属性修改了,副作用函数都会执行,显然是不合理的,解决方法为:
let activeEffect;
function effect(fn){
const effectFn=()=>{
//调用cleanup函数完成清除工作
cleanup(effectFn)
//当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect=effectFn
//执行副作用函数
fn()
}
//存储所有与该副作用函数有关的依赖集合
effectFn.deps=[]
effectFn()
}
const data={foo:true,bar:true}
let obj = new Proxy(data,{
//拦截读取操作
get(target,key){
//将副作用函数activeEffect添加到存储副作用函数的桶中
track(target,key)
//返回属性值
return target[key]
},
//拦截设置操作
set(target,key,newVal){
//设置属性值
target[key]=newVal
//把副作用函数从桶中取出来并执行
trigger(target,key)
//返回true代表设置操作成功
return true
}
})
//存储副作用函数的桶
const bucket = new WeakMap()
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)
//deps就是一个与当前副作用函数存在关联的依赖集合
activeEffect.deps.push(deps)
}
function trigger(target,key){
//target[key]=newVal
const depsMap=bucket.get(target)
if(!depsMap)return
const effects=depsMap.get(key)
const effectsToRun=new Set(effects)
effectsToRun.forEach(effectFn=>effectFn())
// effects&&effects.forEach(fn=>fn())
}
function cleanup(effectFn){
for(let i=0;i<effectFn.deps.length;i++){
const deps=effectFn.deps[i]
//将effectFn从依赖集合中移除
deps.delete(effectFn)
}
//最后需要重置effectFn.deps的值
effectFn.deps.length=0
}
每次执行副作用函数前,根据 effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖中移除
避免无限递归循环
当我们执行如下这段代码时,会发现程序报错
effect(()=>{
obj.foo=obj.foo+1
})
在这个语句中,既会读取obj.foo的值又会设置它的值,从而导致无限的递归调用,导致栈溢出
为了解决这个问题,我们分析上一段代码发现:
读取和设置是在同一个副作用函数内进行的,此时无论是track时收集的副作用函数还是trigger时触发执行的副作用函数,都是activeEffect
基于此,我们将trigger函数修改为:
function trigger(target,key){
// target[key]=newVal
const depsMap=bucket.get(target)
if(!depsMap)return
const effects=depsMap.get(key)
const effectsToRun=new Set()
effects&&effects.forEach(effectFn=>{
if(effectFn!==activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn=>effectFn())
// effects&&effects.forEach(fn=>fn())
}
在上面这段代码中,我们在trigger动作发生时增加守卫条件:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行