概要
接上文,我们已经写出了基础的effect收集,但是还是会有些问题。这一篇,我们就是来解决这些问题的
分支切换与cleanup
首先,我们需要明确分钟切换的定义,看下以下代码:
const data = {
text:'hello word',
ok:true
}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
document.body.innerText = obj.ok?obj.text:'not'
})
当effectFn (effect(effectFn) effectFn是effect传参的函数) 函数内部是一个三元表达式的时候,会根据obj.ok的值执行不同的代码分支,当obj.ok的值发生变化时,代码执行的分支也会跟着变化,这就是所谓的分支切换
执行以上代码,我们发现当obj.ok的值时false的时候,修改obj.text的值。effectFn也会重新执行,这个肯定是有问题的,因为在effectFn中,我们走的分支并没有读取到obj.text的值。原因在于第一次执行的时候读取了obj.text的值,已经把effectFn对应的key值 text 放到了依赖收集的桶中,所以更改obj.text值的时候,就会取出effectFn执行。
我们已经知道原因了,那想必各位同学都有想法了吧?对的我们只要每次执行的副作用函数的时候,把它从之前的所有已关联的依赖集合中删除,当副作用函数执行完毕后,就会建立新的联系。这里就有一个问题,我们要怎么知道它是和哪些依赖集合中呢?所有我们修改effect函数和tarck函数,如以下代码:
let activeEffect
function effect(fn){
const effectFn = ()=>{
cleanup(effectFn)
activeEffect = effectFn
fn()
}
//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
function cleanup(effectFn){
for(let i =0 ;i <effectFn.deps.length;i++){
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
function tarck(target,key){
if(!activeEffect) return
const desMap = bucket.get(target)
if(!desMap){
bucket.set(target,desMap = new Map())
}
const deps = desMap.get(key)
if(!deps){
desMap.set(key,deps = new Set())
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
意思就是:我们在effectFn副作用函数上添加deps属性来记录所关联的依赖,在每次执行effectFn函数的时候,都会把effectFn.deps清空,我们可以看到在tarck函数的
activeEffect.deps.push(deps)
这句,deps是一个数组,数组里面存了deps这个Set的数据,Set是一个引用数据类型,所有push进入effectFn.deps的也是一个地址,这个地址指向了一个堆空间,我们不管在Set里面更改还是在effectFn.deps里面更改,更改的都是这个堆里面的内容。
我们执行上面代码,我们会发现,会一直无限循环,这是为什么呢?
我们看下下面这段代码:
const set = new Set([1])
set.forEach(item=>{
set.delete(1)
set.add(1)
console.log('遍历中)
})
在语言规范中有明确的提到,在调用forEach遍历Set集合的时候,如果一个值被访问过,单该值被删除并重新添加到集合,如果此时forEach遍历还没有结束,就会重新访问,所以会进行无限递归。
那么问题又来了,要怎么解决呢?
我们可以在用一个Set集合去遍历它
,如以下代码:
const set = new Set([1])
const newSet = newSet(set)
newSet .forEach(item=>{
set.delete(1)
set.add(1)
console.log('遍历中)
})
这里,同学们可以理解吗?set和newSet是两个不同的东西了,所以newSet遍历去更改set里面的值是对newSet没有任何影响的。所以就不会无限递归了
所以我们修改修改trigger函数里面的内容了,如下:
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun && effectsToRun.forEach(effectFn=>effectFn())
}
嵌套的effect与effect栈
我们可以设想一下,如果effect函数不支持嵌套,会出现什么的问题的:我们看下以下代码:
const data = {foo:true,bar:true}
const obj = new Proxy(data,{/*...*/})
let temp1,temp2
effect(function effectFn1(){
console.log('effectFn1执行')
effect(function effectFn2(){
console.log('effectFn2执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
然后我们在修改一下 obj.foo的值,我们会发现 他执行的是 effectFn2,这是为什么呢?我们按照代码走下去:
- 创建了一个data对象
- 对data对象进行proxy代理
- 创建了两个变量
执行effect函数
,这里我们要着重分析一下,我们先回顾一下 effect的函数内容,如下:
function effect(fn){
const effectFn = ()=>{
cleanup(effectFn)
activeEffect = effectFn
fn()
}
//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
effect函数执行的时候 首先会把effectFn1函数放进来,activeEffect函数执行的就是effectFn1函数,到这里时没有问题,但是在fn执行的时候,我们就把effectFn2函数赋值给了activeEffect函数,这时候我们做了get操作,获取了obj.foo,此事,对应的activeEffect函数对应的effectFn2函数,所以foo这个key值对应的副作用函数就变成了effectFn2而不是effectFn1了。我们已经知道问题所在,那我们就把effect函数改造一下,如下:
//我们新建一个数组,来模拟栈
const effectStack = []
function effect(fn){
const effectFn = ()=>{
cleanup(effectFn)
effectStack.push(effectFn)
activeEffect = effectFn
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
我们在fn调用完成后 在effectStack里面弹出最后一个,再把activeEffect赋值effectStack的最后一项,这么做的目的就是为了保证 当前执行的key值的副作用函数,在执行get操作的时候,可以准确副作用函数添加到Set集合中,我们改完后再来执行一下的嵌套函数,我们再来分析一下:
- 创建了一个data对象
- 对data对象进行proxy代理
- 创建了两个变量
执行effect函数
,先拿到effectFn1,把他加入到effectStack里面,然后执行effectfn1,然后我们就碰到了effectFn2,然后执行effectFn2,这时候,我们的activeEffect就会等于 effectFn2, 我们把effectFn2 push 到了effectStack里面,当effectFn2执行完成后,我们在effectStack弹出了effectFn2,把activeEffect赋值成effectFn1,我们在执行了obj.foo的get操作,我们就把key为foo的副作用函数对应到了effectFn1,这样就满足了我们的要求。
避免无限递归循环
我们在实现一个完美的响应式系统的时候,需要考虑到诸多的细节,我们试下下面的代码会造成什么样的情况
const data = { foo : 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{obj.foo++})
执行上面的代码,我们会发现栈溢出,这是为什么呢?obj.foo++ 是不是等于 obj.foo = obj.foo +1,我们就会发现一个问题,这个代码执行get操作也执行了set操作,当我们执行set操作的时候,又会把副作用函数拿出来执行一次,就会往复循环,我们会知道在vue中,这种代码的话,就是只会执行一次,那我们就往一次的方向想。
我们会发现 它执行set和get的时候 activeEffect指向的函数都是一样的,那我们是不是可以加一个守卫条件,当set的时候 如果副作用函数和activeEffect时一样的时候,我们就不触发呢?
我们来试下,直接修改trigger函数:
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
//const effectsToRun = new Set(effects) //直接在遍历赋值的时候,把activeEffect给排除掉
const effectsToRun = new Set()
effects && effects.forEach((effectFn)=>{
if(effectFn != activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun && effectsToRun.forEach(effectFn=>effectFn())
}
这样我们就可以避免无限递归调用了
调度执行
可调度性也是响应式系统非常重要的的特性,首先我们要知道什么是可调度性。所谓的可调度性,是指trigger函数执行副作用函数的时候,可以决定副作用函数的执行时机、次数以及方式
我们看下以下代码:
const data = { foo : 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
console.log(obj.foo)
})
obj.foo++
console.log('结束了!')
这样代码输出结果是:
1
2
结束了
如果需求有变,我们输出的顺序要改成:
1
结束了
2
我们需要怎么不改变代码的顺序的情况下来改变执行结果呢?我们这个功能可以给用户自由调配。我们添加一个options来给用户自己配置,那我们需要更改以下代码了,如下:
effect(()=>{
console.log(obj.foo)
},{
scheduler(fn){
//...
}
})
function effect(fn,options={}){
const effectFn = ()=>{
cleanup(effectFn)
effectStack.push(effectFn)
activeEffect = effectFn
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
effectFn.options = options //新增 把他挂载在副作用函数上
//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
//在tirgger函数更改以下
function tirgger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
//const effectsToRun = new Set(effects) //直接在遍历赋值的时候,把activeEffect给排除掉
const effectsToRun = new Set()
effects && effects.forEach((effectFn)=>{
if(effectFn != activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun && effectsToRun.forEach(effectFn=>{
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn)
}else{
effectFn()
}
})
}
我们值执行副作用函数的时候,先判断以下是否存在调度器,如果存在就把副作用函数放到调度器中执行,由用户自己控制如何执行。
这样实现要求 我们只要在调用effect方法的时候,传入一个调度器就好了,如以下:
effect(()=>{
console.log(obj.foo)
},{
scheduler(fn){
setTimeout(fn)
}
})
这样可以控制副作用函数执行的时机,我们也要控制副作用函数的次数,如以下代码:
const data = { foo: 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
console.log(obj.foo)
})
obj.foo++
obj.foo++
通过运行以上代码,我们发现会打印
1
2
3
2
只是过渡状态,我们只关注结果,而不关注过程,在这里,我们调度器可以很简单的实现:
//定义一个任务队列
const jobQueue = new Set()
//使用promise.resolve()创建一个promise实例,我们用它将一个任务放入微任务执行
const p = promise.resolve()
//给一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob(){
if(isFlushing) return
isFlushing = true
p.then(()=>{
jobQueue.forEach(job => job())
}).finally(()=>{
isFlushing = false
})
}
effect(()=>{
console.log(obj.foo)
},{
scheduler(fn){
jobQueue.add(fn)
flushJob()
}
})
我们可以看到 我们把副作用函数的执行放到了微任务执行,使用set集合是为了set集合的去重能力,只有当副作用函数的调度器执行完成后,才会进行微任务的执行,这样就把重复的副作用函数过滤掉了
小结
到这里我们就可以发现,我们以及写出了相对完善的响应式系统了,下一章,我们学习一下,vue的计算属性和lazy