本人已参与[新人创作礼]活动,一起开启掘金创作之路
响应式探究
- 当我们副作用函数里调用了某个obj对象,当obj对象发生改变时,副作用函数会重新执行,则说明该数据为响应式的。
- 触发响应式最关键的是,当对象数据发生改变时,如何通知副作用函数重新执行,或者再进一步说,如何得知数据发生改变?
- 我们很自然的可以想到 Object.defineProperty()和proxy来监测数据,vue2借助#Object.defineProperty()方法来监测数据,vue3借助proxy对象来监测数据,如此,可以很简单的实现一个小型的响应式数据
let color = {
red:'red',
green:'green',
blue:'blue'
}
function effect(){
document.getElementsByTagName('body')[0].style.backgroundColor = color.blue
}
effect()
setTimeout(()=>{
color.blue = 'yellow'
},1000)
- 基于上述非响应式代码做修改
let color = {
red:'red',
green:'green',
blue:'blue'
}
let obj = new Proxy(color,{
get(target,key){
return target[key]
},
set(target,key,newV){
if(target[key] !== newV){
target[key] = newV
effect()
return true
}
}
})
function effect(){
document.getElementsByTagName('body')[0].style.backgroundColor = obj.blue
}
effect()
setTimeout(()=>{
obj.blue = 'yellow'
},1000)
- 可以看到,此时数据发生改变之后,effect函数会成功再次执行,从而页面发生改变
- 但是,这只是最简单的响应式,会有很多问题产生:
- effect函数属于硬编码,并不是所有用户编写的副作用函数都叫effect
- 类似于effect的副作用函数可能有多个,当数据改变时,如何执行对应的副作用函数呢
let obj = new Proxy(color,{
get(target,key){
return target[key]
},
set(target,key,newV){
if(target[key] !== newV){
target[key] = newV
effect()
return true
}
}
})
function effect(){
document.getElementsByTagName('body')[0].style.backgroundColor = obj.blue
document.getElementById('app').style.color = obj.red
console.log('sx')
}
effect()
setTimeout(()=>{
obj.green = 'yellow'
},1000)
* 可以看到, 当obj属性发生改变,effect会被无条件执行,但是effect里面的数据却跟obj.green没有任何关系
- 解决思路:
- 副作用函数会默认执行一次,第一次执行会获取obj数据,从而触发proxy中的get操作,在触发Get操作的时候,设计一个存储结构,将对应target -> [key] -> effect,这样在数据发生改变时,就可以调用该数据所对应的effect
let obj = new Proxy(color,{
get(target,key){
if(!activeEffect){
//如果此时activeEffect为null,说明读取的数据不在副作用函数内,不需要进行依赖收集
return target[key]
}
//获取对应的变量,将数据与依赖它的副作用函数进行绑定
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()))
}
//找到对应的key集合后,将对应的副函数追加进去
deps.add(activeEffect)
},
set(target,key,newV){
if(target[key] !== newV){
target[key] = newV
//找到对应的副作用函数集合,并依次执行
let depsMap = bucket.get(target)
if(!depsMap) return
let effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
return true
}
}
})
* js是单线程执行,同一时间,只会执行一个effect,设置一个全局指针记录这个effect,当触发proxy时,就知道是哪一个effect所触发的,当这个effect执行完,指针重新置空,直到下一个effect执行,再次指向它
* 为方便对用户的副作用函数进行包装操作,可以创建一个ReactEffect类,内部实现一个run方法,对effect进行调用,此时响应式需要执行的函数为effect传入的回调函数。
let activeEffect = null
class ReactEffect{
public fn = null
constructor( fn){
this.fn = fn
}
run(){
try{
activeEffect = this
return this.fn()
}
finally{
activeEffect = null
}
}
}
export function effect(fn){
let _effect = new ReactEffect(fn)
_effect.run()
}
在实例化ReactEffect类的时候,系统会自动执行一次内部run方法,该方法会执行传入的副作用函数,因此响应式数据会进行依赖收集,收集到相关的数据对应的实例,如果对应数据发生改变,在set中获取该对应数据对应的依赖集合,依次执行该依赖集合。
响应式完善
上述思路中,用于只需要在effect函数中传入副作用函数,即可实现对数据的依赖收集,当数据发生改变时,会自动重新执行副作用函数,刷新页面数据,但是这并不是一个很通用的响应式设计,在很多常见的应用场景中,都会出现很多问题:
effect嵌套问题
effect嵌套问题是指effect函数中含有一个effect函数
effect(()=>{
effect(()=>{
document.getElementsByTagName('body')[0].backGroundColor = obj.red
console.log(1)
})
document.getElementById('app').style.color = obj.blue
console.log(2)
})
setTimeout(()=>{
obj.blue = 'yello'
},1000)
观察这个例子,当obj.blue改变时,不是输出2 原因就在run方法里,当执行外部effect的副作用的前,activeEffect指向它,随后执行副作用函数,副作用函数中还有一个effect,所以会执行这个effect的run方法,activeEffect执行内部的effect,然后内部执行完之后,obj.red收集到内部的effect后,activeEffect置为null,
document.getElementById('app').style.color = obj.blue,由于activeEffect为Null,则不会进行依赖收集
- 解决思路:
- 只需要在ReactEffect类中再添加一个parent属性,每次赋值activeEffect时,记录其上一层activeEffect指针
run(){
try{
//当前指针为其父指针
this.parent = activeEffect
//获取当前环境指针
activeEffect = this
return this.fn()
}
finally{
// 执行完当前环境,将上层指针恢复
activeEffect = this.parent
this.parent = null
}
}
分支切换问题
effect(()=>{
document.getElementById('app').style.color = obj.show?obj.blue:'yellow'
})
setTimeout(()=>{
obj.show = false
},1000)
setTimeout(()=>{
obj.blue = 'black'
},2000)
- 在响应式数据中添加一个新属性show:true,之后改为false后,数据obj.blue如何改变都和页面没有关系,但事实上,obj.blue中还收集着当前副作用函数的依赖,当其改变,依然会重新执行副作用函数
- 解决的办法是,当重新执行副作用函数前,将当前副作用函数所对应的属性全部去除,这样后面执行副作用函数时,会重新进行依赖收集,多余的数据依赖就会被清除掉
- 但是,我们数据依赖的映射是 target -> key -> effect,如何通过effect找到对应的key呢,最好的办法是,当key收集effect时,也让effect记住它对应的key,这样双向记忆,都能彼此找到对应关系
deps.add(activeEffect)
activeEffect.deps.push(deps)
在ReactEffect上创建公开属性deps = [],这样在key收集activeEffect时,activeEffect记住当前key收集的集合
run(){
try{
//当前指针为其父指针
this.parent = activeEffect
//获取当前环境指针
activeEffect = this
let deps = this.deps
//获取effect对应的key
for(let i = 0;i<deps.length;++i){
//每个key删除收集的当前的effect
deps[i].delete(this)
}
this.deps.length = 0
return this.fn()
}
finally{
// 执行完当前环境,将上层指针恢复
activeEffect = this.parent
this.parent = null
}
}
这里值得注意的是,在修改对象,触发响应式set时,获取对应Key收集的effects集合 需要添加一步
let effects = depsMap.get(key)
effects = new Set(effects)
effects && effects.forEach(fn => fn())
原因是执行fn时,fn会删除依赖,然后再收集依赖,这样effects迭代没有终点
effect自己调用自己的问题
effect(()=>{
obj.count = obj.count+1
})
obj.count 初始为0,当第一次执行这个副作用函数,执行到obj.count,依赖开始收集,然后obj.count+1,触发副作用函数执行,然后依赖收集。。数据改变再执行。。。无限循环 原因就是自己调用了自己,要想解决这个问题,在重新执行副作用函数的时候,需要添加一个判断,判断当前环境的activeEffect是否指向某个effect,且这个effect就是要更新的effect,如果是同一个,则让其自然执行完就行,不需要再重新触发副作用函数执行
effects && effects.forEach(fn => {
if(activeEffect !== fn){
fn()
}
})