响应系统的作用与实现

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

响应式数据的基本实现

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

  2. 观察以下代码

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

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

    1. 以下,obj是原始数据的代理对象,分别设置了 getset 拦截函数,用于拦截读取和设置操作
    2. 当读取属性时将effect添加到桶里,然后返回属性值,当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行
    3. 这就实现了响应式数据,在浏览器运行以下代码,能得到预期效果
    js 复制代码
    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. 基本实现的代码还需要处理很多细节

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

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

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

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

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

    3. 修改get/set拦截代码

      1. 运行以下代码,在浏览器的运行结果如图:
      2. 这样子实现了只有关联的副作用函数才会执行
      3. 为了方便描述,后文将Set数据结构存储的副作用函数集合称为key的 依赖集合
      js 复制代码
      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)

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

WeakMap和Map的区别

  • 用一段代码讲解

    js 复制代码
    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(触发)函数中

    js 复制代码
    // 进行封装操作
    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还是触发了副作用函数执行
    js 复制代码
    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集合并遍历它

      js 复制代码
      // 直接遍历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不输出' ,说明该代码优化有效
      js 复制代码
      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重新执行了,不符合预期

    • 控制台打印
    js 复制代码
    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指向栈顶

    1. 这样子,响应式数据就只会收集直接读取其值的副作用函数作为依赖
    js 复制代码
    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. 如以下例子:

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

    js 复制代码
    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,允许用户指定调度器:

    1. 对于effect函数
    js 复制代码
    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()
    }
    1. 对于trigger函数
    js 复制代码
    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次打印,期望打印最开始以及自增的最后结果

    js 复制代码
    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了
相关推荐
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员
落魄小二2 小时前
el-table 表格索引不展示问题
javascript·vue.js·elementui
neter.asia2 小时前
vue中如何关闭eslint检测?
前端·javascript·vue.js
十一吖i2 小时前
前端将后端返回的文件下载到本地
vue.js·elementplus
光影少年2 小时前
vue2与vue3的全局通信插件,如何实现自定义的插件
前端·javascript·vue.js
熊的猫3 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
mosen8684 小时前
Uniapp去除顶部导航栏-小程序、H5、APP适用
vue.js·微信小程序·小程序·uni-app·uniapp
别拿曾经看以后~5 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
Gavin_9156 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
Devil枫11 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试