vue3原理之响应式系统实现

60 阅读6分钟

本人已参与[新人创作礼]活动,一起开启掘金创作之路

响应式探究

  • 当我们副作用函数里调用了某个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()
        }
      })