解决这个问题,我们需要每次 副作用函数执行前,把 它从相关联的依赖集合中删除。 当副作用函数执行完毕之后,读取值的时候又会重新建立联系。在新建立的联系中不会包含遗留的副作用函数了。
let activeEffect
function effect(fn){
const effectFn = ()=>{
activeEffect = effectFn
fn()
}
//函数上面是可以添加属性的哦
//activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effect.deps = []
effectFn()
}
// 然后在 track 函数中添加
//deps就是与当前副作用函数存在关系的依赖集合(Set) 也就是建立起互相对应的关系
activeEffect.deps.push(deps)
//至此,我们就可以在副作用函数执行的时候,断开副作用函数与依赖集合的关系
let activeEffect2
function effect2(fn){
const effectFn = ()=>{
//调用cleanup函数 完成清除
cleanup(effectFn)
activeEffect = effectFn
fn()
}
effect2.deps = []
effectFn()
}
//cleanup函数接收副作用函数作为参数,遍历effectFn.deps数组中的依赖集合,将传入的effectFn副作用函数从对应的effectFn.deps数组中移除
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
}
此时,我们的响应系统已经可以避免副作用函数产生的遗留问题了。但是如果运行代码时,会发现 trigger 函数无限循环。这是因为依赖集合时一个Set数据结构,当副作用函数执行时,会调用cleanup进行清除,但是副作用函数的执行,会导致其被重新收集起来,而对于Set数据结构的依赖集合,会仍然在遍历
// 可以用以下代码来表示
const set = new Set([1])
set.forEach(item=>{
set.delete(1)
set.add(1)
console.log('函数仍然在执行')
})
//上述代码会导致循环执行
// 语言规范中,对此有明确的说明,在调用forEach遍历Set时,如果一个值已经被访问过,但该值被删除并重新添加至集合中,此时forEach没有结束,就会被重新 访问。因此,导致无限执行。
// 所以我们可以重新构建一个set遍历它即可
const set1 = new Set([1])
const newSet = new Set(...set1)
newSet.forEach(item=>{
set1.delete(1)
set1.add(1)
console.log('不会无限循环')
})
所以我们的trigger函数也可以用同样办法修改
function trigger(target,key){
const depMaps = bucket.get(target)
if(!depMaps) return
const effects = depMaps.get(key)
if(!effects) return
const newEffects = new Set(...effects)
newEffects.forEach(effect=>effect())
}
effect是可以嵌套的
实际上Vue.js的渲染函数就是在一个effect中执行的。因为组件是互相嵌套的,例如我们就是在App.vue中写其他组件的
//类似于这样
effect(function effectFn(){
App.render()
effect(function effectFn1(){
//其他组件的 render 嵌套执行
})
})
这就说明我们effect要设置为可嵌套的。但目前我们的effect并不支持嵌套,可以用下述代码测试一下
//原始数据
const data = {foo:true,bar:true}
//代理对象
const obj = new Proxy(data,{/*...*/})
//全局变量
let temp1,temp2
effect(function effect1(){
console.log('effectFn1 执行')
effect(function effect2(){
console.log('effect2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
**这段代码 effect1中嵌套了effect2 。effect1的执行会导致effect2的执行。此时foo属性对应effect1 , bar属性对应effect2 **
当我们修改foo字段的值会发现输出为
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'
我们试着分析一下代码的执行。首先注册effect函数会先执行一遍,也就是
'effectFn1 执行'
'effectFn2 执行'
当我们修改foo字段,会触发foo字段对应的effect1执行. effect1执行不是会输出 'effectFn1 执行' 'effectFn2 执行'
吗
这里问题就出在我们的全局变量activeEffect。当副作用函数发生嵌套时,内部的副作用函数会覆盖activeEffect的值,并且不会恢复到原来的值
所以,我们需要将嵌套的effect加入到栈中,执行时压入栈,执行完弹出栈。用activeEffect记录当前激活的effect函数
//记录当前激活状态的副作用函数
let activeEffect
// effect栈
const stack = []
function effect(fn){
const effectFn = ()=>{
//清除与当前副作用函数相关联的依赖集合
clean(fn)
activeEffect = fn
stack.push(effectFn)
fn()
stack.pop()
activeEffect = stack[stack.length-1]
}
effectFn.deps = []
effectFn()
}
避免循环执行
effect(()=>obj.foo++)
当我们在effect函数中注册上述副作用函数,会发现产生了栈溢出。这句话拆开看可以看为
effect(()=>obj.foo = obj.foo + 1)
可以看到在副作用函数中执行了对值的读取以及设置,当读取时,会加入依赖集合中,设置时,会执行。会产生无限递归调用自己,于是产生栈溢出
我们需要判断 当前活跃的activeEffect和传进来的副作用函数是不是同一个函数,如果是同一个就不执行了
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
if(!effects) return
// 创建 一个新的Set 集合,避免使用Set进行forEach时,无限递归
const newEffects = new Set()
effects.forEach(effect => {
//如果trigger触发的副作用函数与当前执行的effect不同,就加入newEffects中,然后执行
if(effect !== activeEffect){
newEffects.push(effect)
}
})
newEffects.forEach(effectFn=>effectFn())
}
调度执行
可调度性时响应式系统非常重要的特性,可调度性就是指有能力决定副作用函数执行的时机,次数,以及方式
首先,我们先实现副作用函数执行的时机,让用户自己去决定
//我们可以为effect设置一个选项参数 options
effect(
()=>{
console.log('哈哈哈哈')
},
// options 。 scheduler是为用户提供的 指定调度器 。参数为副作用函数,用户可以指定什么时候调用
{
scheduler(fn){
/* ... */
}
}
)
首先,我们应该将options放在effectFn上
//在定义 effect 函数时,与定义依赖集合 deps 一样
effectFn.options = options
然后在 trigger 函数中,决定effectFn的执行
newEffects.forEach(effectFn=>{
//新增
if(effectFn.options.scheduler){
// 有的话,交给用户去执行
effectFn.options.scheduler(effectFn)
}else{
//没有的话,直接调用副作用函数
effectFn()
}
})
此时,我们就可以在调度函数中,加入setTimeout来让副作用函数延迟执行,或者其他操作
控制执行次数
当我们不关心执行过程,只关心结果时,基于调度器我们能够实现此功能
// 定义一个任务队列
const queue = new Set()
//使用Promise.resolve 创建一个promise实例,用于添加至微任务队列
const p = Promise.resolve()
// 此处 用到了类似节流的功能 ,定义一个标志表示是否在刷新
let isFlag = false
function flushJob(){
// 如果正在刷新,就什么也不做
if(isFlag) return
isFlag = true
// 开启微任务队列,执行副作用函数
p.then(()=>{
queue.forEach(job=>job())
}).finally(()=>{
// finally无论promise状态为成功或者失败,都是可以执行的。当队列执行完毕,就表示此时没在刷新
isFlag = false
})
}
当我们注册 effect 函数时,就可以通过调度器来实现
effect(()=>{
console.log('zzz')
},{
scheduler(fn){
//每次调度时,将副作用函数添加到queue队列中
queue.add(fn)
//调用flushJob刷新队列
flushJob()
}
})
// 测试一下
const data = {foo:1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
console.log(obj.data)
})
obj.foo++
obj.foo++
//在没有实现上述函数前,这段代码会执行三次,分别为
1
2
3
//实现上述函数,以及调度器后,控制台只会输出两次
1
3
首先,我们利用了Set的自动去重,并且用到了异步的知识。当往队列中添加相同副作用函数时,队列中只会有一个,这个函数的执行是在promise.then中执行,所以,会先执行同步代码,obj.foo++会执行两次,此时obj.foo已经为3。同步代码执行完毕,此时执行异步代码,执行副作用函数,输出obj.foo。也就是3。第一次effect自动执行一次输出1,最后一次输出3。
计算属性与 lazy
当我们不希望effect中的副作用函数立即执行时,我们可以在options添加一个变量来让用户控制它执行与否
//我们希望通过
effect(()=>{
console.log('哈哈哈')
},{
lazy:true
})
//我们就要在 effect 函数中修改 effectFn
function effect(fn,options = {}){
const effectFn = ()=>{
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
//只有当lazy为false的时候执行
if(!options.lazy){
effectFn()
}
// 否则,不执行。将当前这个副作用函数返回
return effectFn
}
但仅仅时这样的话,意义不是很大,如果我们将传递给effect的函数作为一个getter,这个getter可以返回任何值。这就要求我们在手动调用副作用函数时,就能够拿到其返回值,但我们目前的effectFn是没有返回值的(undefined)
const effectFn = ()=>{
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const res = fn() //新增
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res // 新增
}
//当lazy为true时,effect返回值就为 ()=>obj.foo + obj.bar 。与effectFn对应
const effectFn = effect(()=>obj.foo + obj.bar,{lazy:true})
// 执行effectFn ,就能拿到这个 getter 的返回值
const value = effectFn()