Vue响应式系统核心原则

219 阅读5分钟

写在开头

希望你阅读完这篇文章后回答出以下的几个问题:

  1. 什么是响应式数据
  2. 响应式数据的实现原理
  3. 实现一个简单的响应式数据

核心知识

副作用函数:会直接或者间接影响其他函数执行的函数。

响应数据:会影响视图变化的数据。

  • 举例:我们会希望,再次修改obj.text时,对应的页面内容也发生变化
const obj = { text:'hello world' }
function effect(){
  document.body.innerText = obj.text
}
  • 解决:在修改 obj.text 值后,再次执行一遍 effect 函数,既可实现

响应式系统的工作流程:

  • 读取操作时:将副作用函数收集到「桶」中
  • 设置操作时:从「桶」中取出副作用函数并执行

简单实现

  1. 注册副作用函数的机制
//用一个全局变量存储被注册的副作用函数
let activeEffect
//effect函数用于注册副作用函数
function effect(fn){
  //当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  activeEffect = fn

  //执行副作用函数
  fn()
}
  1. 代理对象设置
//存储副作用函数的桶
const bucket = new Set()
//对数据进行代理
const obj = new Proxy(data,{
  get(target,key){
   if(activeEffect){
     bucket.add(activeEffect)
   } 
   return target[key]
  },
  set(target,key,newVal){
    target[key]=newVal
    bucket.forEach(fn=>fn())
    return true
  }
})

为什么 vue3 中使用 proxy 而不是 Object.defineProperty?Vue3 为啥用 Proxy 换掉Object.defineProperty

  1. 上述代码过程分析
effect(()=>{
  document.body.innerText = obj.text
})
  • 使用 effect,此时会匿名函数 fn 赋值给全局变量 activeEffect
  • 接着执行 fn,这会触发 obj.text 的读取操作,进而被代理对象 get 拦截
  • 在 get 拦截中,将副作用函数存储到桶中,并返回属性值
setTimeout(()=>{
  obj.text = 'ni hao'
},1000)
  • 修改 obj.text,会触发设置操作,返回新的值,并遍历执行桶中的函数,从而实现页面变化。

逐步进化

上述代码只是简单的实现,还存在以下问题

问题:被操作目标字段并没有与副作用函数直接建立联系

给响应式对象 obj 设置一个不存在的属性时,可以发现 effect 函数执行了两次。

原因: 副作用函数与对象属性之间没有明确的联系

effect(()=>{
  console.log('hello')
  document.body.innerText = obj.text
})
setTimeout(()=>{
  obj.noExist = 'ni hao'
},1000)

期待: effect 函数中并没有读取obj.noExist值,定时器语句内的执行不应该触发匿名副作用函数重新执行。

解决: 给以下三个角色建立联系

  • 被操作(读取)的代理对象 obj
  • 被操作(读取)的字段名 text
  • 使用 effect 注册的副作用函数 effectFn
obj
 |--text
    |--effectFn

const bucket = new WeekMap()

const obj = new Proxy(data,{
  get(target,key){
    if(!activeEffect) 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()))
    }
    deps.add(activeEffect)
    
    return target[key]
  },
  set(target,key,newVal){
    target[key]=newVal
    
    const depsMap = bucket.get(target)
    if(!depsMap) return 
    const effects = depsMap.get(key) 
    effects&&effects.forEach(fn=>fn)
  }
})
为什么使用 weekMap,而不使用 map?
const map = new Map()
const weekmap = new WeakMap()
(function(){
  const foo = {foo:1}
  const bar = {bar:2}
  map.set(foo,1)
  weekmap.set(bar.2)
})()

foo 对象:函数执行完毕后,依旧被 map 引用着,垃圾回收器并不会将它从内存移除

bar 对象:函数执行完毕后,由于 webpack 的 key 是弱引用,因此垃圾回收器会将它从内存中移除

在响应式系统中,可能会有许多对象被动态创建和销毁。而使用 WeakMap 可以确保当对象不再需要时,它们可以被自动回收,从而避免内存泄漏

进一步封装

track:将 get 拦截器中编写副作用收集到「桶」的这部分逻辑提取出来

function track(target,key){
  if(!activeEffect) 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()))
  }
  deps.add(activeEffect)    
}

trigger:将 set 拦截器中触发副作用重新执行的逻辑提取出来

function trigger(){
  const depsMap = bucket.get(target)
  if(!depsMap) return 
  const effects = depsMap.get(key) 
  effects&&effects.forEach(fn=>fn)
}

代理代码如下:

const obj = new Proxy(data,{
  get(target,key){
    track(target,key)//设置副作用函数存储入桶
    return target[key]
  },
  set(target,key,newVal){
    target[key]=newVal
    trigger(target,key)//副作用函数取出并执行
  }
})

问题:分支切换

// Example usage
const data = { a: 1, b: 2 };
const obj = new Proxy(data,{...})
effect(() => {
  console.log(obj.a);
  if (obj.a > 1) {
    console.log(obj.b);
  }
});

描述:

  • 当 obj.a=2 时:obj.b 会被作为依赖,而且 effect 会被 obj.a 和 obj.b 同时收集
  • 当 obj.a=1 时,并在此基础上多次修改 obj.b,虽然打印的结果始终保持不变,但是 effect 却被执行多次
  • 我们并不希望在 obj.a=1 的情况下,effect 被 obj.b 收集着

解决:每次副作用执行时,都会先清除与它所有依赖集合的关联

这样可以保证,每次执行时,副作用只与当前访问的属性建立依赖关系(动态依赖管理)

代码实现

完善副作用注册函数:

 //清除依赖关系
  function cleanup(effectFn){
    for(let i=0;i<effectFn.deps.length;i++){
      const deps = effectFn.deps[i]
      deps.delete(effectFn)
    }
    effectFn.deps.length = 0
  }

//设置副作用函数
  let activeEffect
  function effect(fn) {
    const effectFn = () => {
      cleanup(effectFn)//清除副作用函数
      activeEffect = effectFn
      fn()
    }
    effectFn.deps = []
    effectFn()
  }

完善 track

const bucket = new WeakMap()
  
  //设置副作用函数存储入桶
  function track(target, key) {
    if (!activeEffect) 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()))
    }
    //存储包含该副作用的依赖
    activeEffect.deps.push(deps)
    deps.add(activeEffect)
  }

完善 trigger

 //触发副作用函数
  function trigger() {
    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)//删除
  }
为什么会进入死循环?

比如 effectFn 函数被 obj.a 收集,且 effectFn 函数中执行了 obj.a 修改当操作。

当触发了 effectFn 函数时,obj.a 被修改,obj.a 被修改后重新出发 effectFn。

这个过程会不断重复,形成无限循环

解决:Set 数据结构会自动去重,确保每个副作用函数在一次触发过程中只被执行一次。

最后

示例代码:分支切换

以上都是实现一个完善响应式系统需要考虑的细节。阅读完这篇文章后,你是否对开篇的问题有了更明确的答案?

当然目前还存在诸多的问题比如:嵌套 effect 与 effect 栈、避免无限递归循环等,想要了解更多内容的朋友可以去阅读《vue.js 设计与实现》

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

接下来,我会推出一系列Vue 3.0的文章,欢迎关注,一起探索!