响应系统的作用与实现

343 阅读13分钟

该篇为阅读《Vue.js设计与实现》过程总结的笔记,光看只会云里雾里,不如手敲一遍,感受一下~

响应式数据的基本实现

  1. vue3采用ES6中的代理对象Proxy来实现对数据读取和设置的拦截

  2. 观察以下代码

    1. let obj= { text: 'hello' }
      function effect() {
          document.body.innerText = obj.text
      }
      
    2. 思考:当修改obj.text的值后,我们希望effect函数自动执行,但是显然以上代码做不到这一点

      • 当effect函数执行时,会触发对数据的读取操作,修改数据时,会触发数据的设置操作
      • 所以如果我们能拦截一个对象的读取和修改的操作,事情就变得简单了
  3. 粗糙地实现一个响应式数据

    1. 以下,obj是原始数据的代理对象,分别设置了 getset 拦截函数,用于拦截读取和设置操作
    2. 当读取属性时将effect添加到桶里,然后返回属性值,当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行
    3. 这就实现了响应式数据,在浏览器运行以下代码,能得到预期效果
        const bucket = new Set()   // 存储副作用函数的'桶'
    
        let data = { text: 'hello' }   // 原始数据
    
        const obj = new Proxy(data, {  // 对数据的代理
            // 拦截读取操作
            get(target, key) {
                console.log(target, key)
                bucket.add(effect)    // 将副作用函数effect添加到存储副作用函数的 桶 中
                return target[key]    // 返回属性值
            },
            // 拦截设置操作
            set(target, key, newVal) {
                target[key] = newVal  // 设置属性值
                bucket.forEach(fn => fn())   // 将副作用函数从桶里取出并执行
                return true    // 返回true表示设置成功
            }
        })
    
        function effect() {    // 副作用函数
            document.body.innerText = obj.text
        }
        effect()   // 执行副作用函数触发读取
    
        setTimeout(() => {   // 1秒后修改响应式数据
            obj.text = 'hello Vue3!'
        }, 2000)
    

设计一个完善的响应系统

  1. 基本实现的代码还需要处理很多细节

    如一旦函数名字不为effect,那么代码不能正常工作,为了能够正确地将副作用函数甚至是匿名函数收集到桶中,需要提供一个用来注册副作用函数的机制

        let activeEffect;     // 用一个全局变量存储被注册的副作用函数
        function effect(fn) {   // effect函数用于注册副作用函数
            activeEffect = fn;  // 当调用effect函数注册副作用函数时,将副作用函数fn赋值给activeEffect
            fn()    // 执行副作用函数
        }
        const obj = new Proxy(data, {
            get(target, key) {
                if (activeEffect) {
                    bucket.add(activeEffect)   // 将activeEffect中存储的副作用函数添加到桶中
                }
                return target[key]    // 返回属性值
            },
            set(target, key, newVal) {
                target[key] = newVal  // 设置属性值
                bucket.forEach(fn => fn())   // 将副作用函数从桶里取出并执行
                return true    // 返回true表示设置成功
            }
        })
    
        effect(() => {
            console.log('effect run');   // 浏览器运行代码一开始就输出 2s后再一次输出
            document.body.innerText = obj.text
        })
        setTimeout(() => {
            obj.notExist = 'hello Vue3!!!'
        }, 2000)
    

    发现一个问题:通过在响应式数据obj上设置一个不存在的属性测试时

    • effect run打印了两次
    • 但是我们的匿名副作用函数内并没有读取obj.notExist属性的值,所以理论上它们之间并没有建立响应联系,因此定时器内语句的执行不应该触发匿名副作用函数重新执行,这是不正确的
    • 导致该问题的根本原因:没有在副作用函数与被操作的目标字段之间建立明确的联系
  2. 需要重新设计'桶'

    target表示一个代理对象所代理的原始对象,key表示被操作的字段名,effectFn表示被注册的副作用函数,它们之间应该是以下的树型数据结构

    用weakMap代替Set作为桶的数据结构

    修改get/set拦截代码

const bucket = new WeakMap()
const obj = new Proxy(data, {
    get(target, key) {
        if (!activeEffect) return target[key]        // 没有activeEffect 直接return
        let depsMap = bucket.get(target);            // 根据target从bucket中获取depsMap 它是一个Map类型,key--effect
        if (!depsMap) {                              // 如果不存在depsMap 那么新建一个Map并于target关联
            bucket.set(target, (depsMap = new Map()))
        }
        let deps = depsMap.get(key)                  // 再根据key从depsMap中取出deps 它是一个Set类型 里面存储着所有与当前key相关的副作用函数:effects
        if (!deps) {                                 // 如果不存在deps 同样新建一个Set与key关联
            depsMap.set(key, (deps = new Set()))
        }
        deps.add(activeEffect)                       // 最后将当前激活的副作用函数添加到bucket中
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        const depsMap = bucket.get(target)          // 根据target从桶中取得depsMap key--effects
        if (!depsMap) return
        const effects = depsMap.get(key)            // 根据key取得所有副作用函数 effects
        effects && effects.forEach(fn => fn())      // 执行副作用函数
    }
})

effect(() => {
    console.log('effect run');
    document.body.innerText = obj.text
})
effect(() => {
    console.log('有关联的才log');
    document.body.innerText = obj.age
})
setTimeout(() => {
    obj.notExist = 'hello Vue3!!!'
    obj.age = 20
}, 2000)

运行代码,在浏览器的运行结果如图: 这样子实现了只有关联的副作用函数才会执行 为了方便描述,后文将Set数据结构存储的副作用函数集合称为key的 依赖集合

那么为什么要使用WeakMap作为桶的数据结构

WeakMap和Map的区别

  • 用一段代码讲解

    • const map = new Map();
      const weakmap = new WeakMap();
      (function () {
          const foo = { foo: 1 };
          const bar = { bar: 2 };
          map.set(foo, 1);
          weakmap.set(bar, 2);
      })()
      console.log(map.keys());
      
  • 函数表达式执行完后

  1. 对于foo对象来说,仍然作为map的key引用着,因此垃圾回收 不会把它从内存中移除,仍然可以通过map.kyes打印出对象foo

  2. 对于bar对象来说,由于WeakMap的key是弱引用,不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器会把对象foo从内存移除,并且WeakMap不提供keys方法,无法获取weakmap的key值,也就无法通过weakmap取得对象bar

    1. 基于这个特性,WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息
    2. 例如上诉桶的结构,如果target对象没有任何引用了,说明用户侧不再需要它,这是垃圾回收器会完成回收任务。如果使用Map作为桶结构,用户测的代码对target没有任何引用作用,这个target也不会被回收,最终可能导致内存溢出

最后,可以对上文的代码作封装处理

  1. 在get拦截函数里,把副作用函数收集到桶的逻辑,更好的做法是封装到一个 track(追踪)函数中

  2. 在set拦截函数里,把触发副作用函数重新执行的逻辑,封装到一个 trigger(触发)函数中

        // 进行封装操作
        const obj = new Proxy(data, {
            get(target, key) {
                track(target, key)   // 将副作用函数activeEffect添加到存储副作用函数的桶中
                return target[key]
            },
            set(target, key, newVal) {
                target[key] = newVal
                trigger(target, key) // 把副作用函数从桶中取出来执行
            }
        })
        function track(target, key) { // 在get拦截函数内调用track函数追踪变化
            if (!activeEffect) return
            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)
        }
        function trigger(target, key) { // 在set拦截函数内调用trigger函数触发变化
            const depsMap = bucket.get(target)
            if (!depsMap) return
            let effects = depsMap.get(key)
            effects && effects.forEach(fn => fn())
        }
    

切换分支与 cleanup

  1. 分支切换定义,如下代码

    1. 当我们把ok的值改为false,并触发副作用函数重新执行的时候,age的依赖集合遗留了该副作用函数
    2. 遗留的副作用函数会导致不必要的更新,就比如再修改age的值,最好的结果是不再执行该副作用函数,但是事实上是会重新执行的
    3. 控制台打印了3次 '当ok为false后 再修改age 这里不应该输出 但是理论上是会被输出的' 说明最后修改age还是触发了副作用函数执行
        const data = { text: 'hello', age: 18, ok: true }
        const obj = new Proxy(...)
        effect(() => {
            console.log('当ok为false后 再修改age 这里不应该输出 但是理论上是会被输出的');
            document.body.innerText = obj.ok ? obj.age : 'not'
        })
        setTimeout(() => {
            obj.ok = false
        }, 2000)
    
        setTimeout(() => {
            obj.age = 21
        }, 3000)
    
  2. 解决思路:每次副作用函数执行前,将其从相关联的依赖集合中移除

    1. 重新设计副作用函数,,并设置一个属性deps,该属性是数组,用来存储所有包含当前副作用函数的依赖集合

    2. 有了集合和副作用函数间的联系后,可以在每次执行副作用函数时,根据deps获取所有相关的依赖集合,进而将副作用函数从依赖集合中移除

    3. cleanup函数接收副作用函数作为参数,遍历effectFn.deps数组,每一项都是一个依赖集合,将副作用函数从依赖集合中移除,可以避免副作用函数产生遗留了

    4. 此时有新的问题,执行代码会导致无限循环执行,问题出在trigger函数的effects && effects.forEach(fn => fn()) 这句代码中

    5. forEach 遍历set集合时,如果一个值已经被访问过,但该值被删除并重新添加到集合,此时遍历还没有结束,该值会被重新访问,解决方法,构造另外一个Set集合并遍历它

          // 直接遍历set进行delete和set操作会无限执行 在set外构造另外一个set可以防止无限执行
          const set = new Set([1, 2, 3])
          const newSet = new Set(set)
          newSet.forEach(item => {   // 1 2 3 
              console.log(item);
              set.delete(1)
              set.add(1)
          })
      
    6. 重新设计trigger函数,最后得到

      控制台只打印2次 '当ok为false后这里输出一次 之后再修改age不输出' ,说明该代码优化有效

          const data = { text: 'hello', age: 18, ok: true }
          let activeEffect;
      
          function cleanup(effectFn) {
              for (let i = 0; i < effectFn.deps.length; i++) {
                  const deps = effectFn.deps[i]   // deps是依赖集合
                  deps.delete(effectFn)           // 将effectFn从依赖集合中移除
              }
              effectFn.deps.length = 0            // 最后重置effectFn.deps数组
          }
      
          function effect(fn) {
              const effectFn = () => {
                  cleanup(effectFn)           // 调用cleanup函数完成清除工作
                  activeEffect = effectFn;    // 当effectFn执行时,将其设置为当前激活的副作用函数
                  fn()
              }
              effectFn.deps = []
              effectFn()
          }
          const bucket = new WeakMap()
      
          function track(target, key) {
              if (!activeEffect) return
              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)         // deps就是一个与当前副作用函数存在联系的依赖集合
              activeEffect.deps.push(deps)   // 将其添加到activeEffect.deps数组中
          }
          function trigger(target, key) {
              const depsMap = bucket.get(target)
              if (!depsMap) return
              let effects = depsMap.get(key)
              const effectsToRun = new Set(effects)  // 用来避免无限执行
              effectsToRun.forEach(effectFn => effectFn())
          }
      
          const obj = new Proxy(data, {
              get(target, key) {
                  track(target, key)
                  return target[key]
              },
              set(target, key, newVal) {
                  target[key] = newVal
                  trigger(target, key)
              }
          })
      
          effect(() => {
              console.log('有关联的才log');
              document.body.innerText = obj.age
          })
          setTimeout(() => {
              obj.ok = false
          }, 2000)
      
          setTimeout(() => {
              obj.age = 21
          }, 3000)
      
          effect(() => {
              console.log('当ok为false后这里输出一次 之后再修改age不输出');
              document.body.innerText = obj.ok ? obj.age : 'not'
          })
      

嵌套的 effect 与 effect栈

  • effect是可以嵌套的,但是以上的代码实现的响应系统并不支持嵌套

  • 如下代码,我们修改的是foo的值,发现fn1并没有执行,反而使得fn2重新执行了,不符合预期

    • 控制台打印
        let temp1, temp2
    
        effect(function effectFn1() {  // f1嵌套了f2
            console.log('effectFn1执行');
            effect(function effectFn2() {
                console.log('effectFn2执行');
                temp2 = obj.bar
            })
            temp1 = obj.foo
        })
        setTimeout(() => {
            obj.foo = false;
        }, 2000)
    
  1. 观察到我们的副作用函数直接将effectFn赋给activeEffect,意味着同一刻activeEffect所存储的副作用函数只有一个,并且是内层的副作用函数

  2. 需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前的副作用函数压入栈中,执行完毕从栈中弹出,始终让activeEffect指向栈顶

    这样子,响应式数据就只会收集直接读取其值的副作用函数作为依赖

        let effectStack = [];         // 定义一个effectStack数组模拟栈
        function effect(fn) {
            const effectFn = () => {
                cleanup(effectFn)
                activeEffect = effectFn;    // 当调用effect函数注册副作用函数时,将副作用函数复制给activeEffect
                effectStack.push(effectFn)  // 在调用副作用函数前将当前副作用函数压入栈
                fn()
                effectStack.pop()   // 当副作用函数执行完 将当前副作用函数弹出栈,并把activeEffect还原为之前的值
                activeEffect = effectStack[effectStack.length - 1]
            }
            effectFn.deps = []
            effectFn()
        }
    

避免无限递归循环

  1. 如以下例子:

    读取obj.foo的值,会触发track操作,将副作用函数添加到桶中,接着将其加1再赋值给foo,会触发trigger操作,从桶中取出副作用函数并执行,问题是该副作用函数正在执行,还没执行完毕就要开始下一次执行,无限递归调用自己,尝试栈溢出

  2. 解决思路:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

        function trigger(target, key) {
            const depsMap = bucket.get(target)
            if (!depsMap) return
            const effects = depsMap.get(key)
            // const effectsToRun = new Set(effects)  // 用来避免无限执行
            const effectsToRun = new Set();
            effects && effects.forEach(effectFn => {
                if (effectFn !== activeEffect) {   // 在这里进行判断
                    effectsToRun.add(effectFn)
                }
            })
            effectsToRun.forEach(effectFn => effectFn())
        }
    

调度执行

  1. 可调度:当trigger动作触发副作用函数重新执行的时,有能力绝对副作用函数执行的时机、次数以及方式

  2. 需要响应系统支持调度,可以为effect函数设计一个选项参数options,允许用户指定调度器:

    对于effect函数

        function effect(fn, options = {}) {
            const effectFn = () => {
                cleanup(effectFn)
                activeEffect = effectFn;    
                effectStack.push(effectFn)  
                fn()
                effectStack.pop()  
                activeEffect = effectStack[effectStack.length - 1]
            }
            effectFn.options = options   // 将options挂载到effectFn上班
            effectFn.deps = []
            effectFn()
        }
    

    对于trigger函数

        function trigger(target, key) {
            const depsMap = bucket.get(target)
            if (!depsMap) return
            const effects = depsMap.get(key)
            const effectsToRun = new Set();
            effects && effects.forEach(effectFn => {
                if (effectFn !== activeEffect) { 
                    effectsToRun.add(effectFn)
                }
            })
            effectsToRun.forEach(effectFn => {
                if (effectFn.options.scheduler) {
                    effectFn.options.scheduler(effectFn)  // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
                } else {
                    effectFn()   // 否则直接执行副作用函数(之前的默认行为)
                }
            })
        }
    
  3. 基于调度器,我们可以实现该功能,当num自增到5时,不需要执行5次打印,期望打印最开始以及自增的最后结果

        const jobQueue = new Set()   // 定义一个任务队列
        const p = Promise.resolve()  // 使用Promise.resolve()创建一个promise实例,我们用它将一个任务添加到微任务队列
        let isFlushing = false
        function flushJob() {        // 一个标志代表是否正在刷新队列
            if (isFlushing) return   // 如果队列正在刷新 则什么都不做
            isFlushing = true        // 设置为true 代表正在刷新
            p.then(() => {           // 在微任务队列中刷新jobQueue队列
                jobQueue.forEach(job => job())
            }).finally(() => {
                isFlushing = false   // 结束后重置isFlushing
            })
        }
    
        effect(() => {
            console.log(obj.num);
        }, {
            scheduler(fn) {         // 每次调度时,将副作用函数添加到jobQueue队列中
                jobQueue.add(fn)
                flushJob()          // 调用flushJob刷新队列
            }
        })
        obj.num++
        obj.num++
        obj.num++
        obj.num++
        // 控制台打印
        // 1
        // 5
    
    1. 定义了一个任务队列jobQueue,为 Set数据结构,目的是利用Set数据结构的自动去重能力
    2. 调度器scheduler的实现,在每次调度执行时,先将当前副作用函数添加到jobQueue队列中,再调用flushJob函数刷次队列
    3. flushJob函数通过isFlushing标志判断是否需要执行,只有当其为false时才需要执行,而一旦flushJob函数开始执行,isFlushing就会设置为true,意思是无论调用多少次flushJob函数,在一个周期内都只会执行一次
    4. 注意,在flushJob内通过p.then将一个函数添加到微任务队列,在微任务队列内完成对jobQueue的遍历执行
  • 整体代码效果

    • 连续对num执行四次自增操作,会同步且连续地执行四次scheduler调度函数,这意味着同一个副作用函数会被jobQueue.add(fn)语句执行四次,但是由于Set的数据结构的去重能力,最终jobQueue中只会有一项,即当前副作用函数
    • flushJob也会同步且连续地执行四次,但由于isFlushing标志的存在,实际上flushJob函数在一个事件循环内只会执行一次,即在微任务队列内执行一次
    • 当微任务队列开始执行时,会遍历jobQueue并执行里面存储的副作用函数
    • 由于此时jobQueue队列内只有一个副作用函数,所以只会执行一次,并且当它指向时,num已经是5了