响应式系统的设计思想

467 阅读9分钟

核心内容均来自《Vuejs设计与实现》

引入

举一个简单的例子

 const obj = { text: 'hello world' }
 function effect() {
    document.body.innerText = obj.text
 }

以上代码,副作用函数中使用到了obj的值;如何使obj.text='其他'设置时重新触发副作用函数?

实现响应式数据

前面的例子实际上就是在obj的值发生变化时,所依赖到的这个值的副作用函数,也就是上文的effect函数会依次更新。

要实现以上的需求就有两个问题:

问题1: 如何确定目标数据所依赖的副作用函数;

问题2: 如何在目标值修改的时候执行副作用函数;

  const bucket = new Set()
  const data = { text: 'hello world' }
  const obj = new Proxy(data, {
    get(target, key) {
      bucket.add(effect)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      bucket.forEach(fn => fn())
      return true
    }
  })

实际上就是在读取obj值的时候,在get中收集副作用函数,在设置obj的值时再去遍历容器中的副作用函数。

image.png

基于以上实现,引出以下使用方式:

function effect() {
   document.body.innerText = obj.text
}
effect()
obj.text = 'hello vue3'

完整的响应式方案

针对以上实现,可以发现一些问题,例如:

  1. 以上的是直接通过获取副作用函数的名称去收集的副作用集合,不够灵活

  2. 基于以上实现,如果响应式对象新增一个属性,例如obj.aa=123, 此时会如何执行;由上图得知依然会执行effect函数,而此时的effect函数,并没有与data.aa的值相关联,这就是触发了不必要的执行。

问题1:

1.定义一个全局变量存储当前的副作用函数activeEffect

2.在公共的effect函数中将传入的副作用函数赋值给activeEffect并且执行当前函数

3.在之前定义的proxy内部使用全局变量activeEffect

新增activeEffect全局变量

  let activeEffect
  function effect(fn) {
    activeEffect = fn
    fn()
  }

proxy内部处理

 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
    }
  })

问题2:

引发该问题的根本原因是,响应式数据中被操作的目标字段obj.aa没有和副作用函数建立关系,如果每个被操作字段和对应的副作用函数都有明确的关系,那就可以确保准确执行函数。因此需要优化以上存储effect的数据结构。

现状:直接将effect函数放入set集合中

 function effect() {
    document.body.innerText = obj.text
 }

思路:

  1. 确定副作用函数与哪些元素有关系,分别是代理对象obj,代理对象的字段名obj.text,以及副作用函数本身。
  2. 确定几个元素之间的关系,如下所示:
/* 图2 不同的副作用函数读取相同的key */
01 effect(function effectFn1() {
02 obj.text
03 })
04 effect(function effectFn2() {
05 obj.text
06 })
/* 图3 不同的key被相同的副作用函数读取 */
01 effect(function effectFn() {
02 obj.text1
03 obj.text2
04 })
/* 图4 不同的代理对象被不同的副作用函数读取 */

image.png 实际上是典型的树形结构,obj目标对象与他的key建立关系,而单独的key再与和他有关系的所有副作用函数建立联系。

由以上关系可以确定以下数据结构

image.png

代码如下:

  const bucket = new WeakMap()
  const obj = new Proxy(data, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      trigger(target, key)
    }
  })
  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)
  }
  function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
  }

处理遗留的副作用函数

基于以上实现引入一个场景:

  effect(() => {
    document.body.innerText = obj.ok ? obj.text : 'not'
  })
obj.ok=false
obj.text='123'

现状:当obj.ok初始为true,此时obj.text会执行,此时操作的具体key与effect函数的关系和上面的图3类似,effect注册的副作用函数在track操作时均会出现在obj.ok和obj.text的依赖列表中去;

影响:使obj.ok=false,触发set以后,副作用函数会执行一次,但是当前并不读取obj.text的值。然而此时obj.text的依赖列表中依然存在副作用函数。其实当前的副作用函数已经与obj.text没有关系了,理想情况下,执行obj.text='其他值',不触发以上的副作用函数,然而目前的现状是obj.text的set触发后,依然会使副作用函数执行

以上就产生了一个遗留的副作用函数。

**处理方式:**在执行副作用函数之前,将当前所有依赖与其所依赖的集合,取消关联。在副作用函数执行后,又会重新建立(track操作)新的联系

实现思路:

  1. 重新定义effect函数内部的结构,定义一个存储所有与其相关的依赖列表的集合;
  2. 在每次执行effect内部的副作用函数之前,通过上面的集合删除当前副作用函数;
  3. 在每次读取数据时「track操作」, 将当前的key对应的依赖集合添加到当前的副作用函数中;

可以模拟一下上面例子:

image.png

代码实现如下:

  let activeEffect
  function effect(fn) {
    const effectFn = () => {
      // 调用 cleanup 函数完成清除工作
      cleanup(effectFn) // 新增
      activeEffect = effectFn
      fn()
    }
    effectFn.deps = []
    effectFn()
  }
  function cleanup(effectFn) {
    // 遍历 effectFn.deps 数组
    for (let i = 0; i < effectFn.deps.length; i++) {
      // deps 是依赖集合
      const deps = effectFn.deps[i]
      // 将 effectFn 从依赖集合中移除
      deps.delete(effectFn)

    }
    // 最后需要重置 effectFn.deps 数组
    effectFn.deps.length = 0
  }
  
 /*track*/
  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)
    activeEffect.deps.push(deps);
  }

如何适配嵌套的Effect的情况

基于以上实现引入一个场景:

 let temp1, temp2
  // effectFn1 嵌套了 effectFn2
  effect(function effectFn1() {
    console.log('effectFn1 执行')
    effect(function effectFn2() {
      console.log('effectFn2 执行')
      temp2 = obj.bar
    })
    temp1 = obj.foo
  })

按照正常情况下,以上代码应该建立的关系应如下所示,并且在修改obj.foo的值时,应该触发effectFn1与effectFn2的执行;当修改obj.bar时,触发effectFn2的执行。

而事实上,当触发obj.foo的时候,只有effectFn2执行。(理想状态时1,2)

image.png

原因:归咎于effect函数的实现,存储副作用函数是通过一个全局的变量;当发生嵌套关系时,内层的activeEffect会覆盖外层的activeEffect,响应式数据依赖收集的内容无论如何都会是最内层的(内层effect函数在obj.foo读取之前)。

  let activeEffect
  function effect(fn) {
    const effectFn = () => {
      // 调用 cleanup 函数完成清除工作
      cleanup(effectFn) // 新增
      activeEffect = effectFn
      fn()
    }
    effectFn.deps = []
    effectFn()

**解决:**为了解决这个问题,我们需要引入一个栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如以下代码所示:

  
  let activeEffect
  
  const effectStack = [] 
  function effect(fn) {
    const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      effectStack.push(effectFn)
      fn()
      effectStack.pop() 
      activeEffect = effectStack[effectStack.length - 1] 
    }
    effectFn.deps = []
    effectFn()
  }

image.png

如下如所示:

image.png

调度执行

思考一个问题,在执行trigger函数时,如何去控制副作用函数的执行顺序以及执行次数,例如以下代码:

  effect(() => {
    console.log(obj.foo)
  })
  obj.foo++
  console.log('结束了')

正常情况下执行的结果为:

image.png

如果想改变结果的执行顺序,将顺序改为「1,结束了,2」,即改变trigger函数的执行顺序。

方案:

实际上该功能深层含义其实是决定effect函数的执行状态,首先需要为effect函数提供一个配置的入口,用来让使用方去自己决定effect函数的执行机制。

  1. effect函数新增配置参数的入口
  2. effect函数内部将配置参数挂载在effect函数本身上
  3. trigger函数中执行effect函数时,判断配置对象中是否包含调度器,若有则执行调度器,没有直接执行函数即可

image.png

代码实现:

/* effect函数新增options*/ 
function effect(fn, options = {}) {
    const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      effectStack.push(effectFn)
      fn()
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1]
    }
    effectFn.options = options //***
    effectFn.deps = []
    effectFn()
  }0
/* 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()
      }
    })
  }

执行顺序

基于以上内容,如果要实现开头的内容,就可以如下所示:

  effect(
    () => {
      console.log(obj.foo)
    },
    {
      scheduler(fn) {
        setTimeout(fn)
      }
    }
  )
  obj.foo = '12'
  console.log('结束了')
1,jieshul,12

以上将trigger触发的effect函数的执行加入任务队列中,因此优先执行同步代码,就可以实现开头的效果

执行次数

引入一个场景:

 effect(() => {
 console.log(obj.foo)
 })
 obj.foo++
 obj.foo++
 obj.foo++
输出://1,2,3,4

以上例子正常执行,但是2,3只是一个过渡的值,我们的目标时只想拿到最后一次执行的值,因此期望打印的只是1,4,基于以上的调度器,可以得出以下思路:

  1. 利用调度器的特性,在每次执行时将其记录
  2. 将执行函数的操作放入微任务队列中,并在开始执行前设置标志
  3. 异步的代码执行完毕之后,初始标志

可以使用set集合(去重)存储effect函数,保证相同的effect只执行一次,代码实现如下:

  // 定义一个任务队列
  const jobQueue = new Set()
  // 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
  const p = Promise.resolve()
  // 一个标志代表是否正在刷新队列
  let isFlushing = false
  function flushJob() {
    // 如果队列正在刷新,则什么都不做
    if (isFlushing) return
    // 设置为 true,代表正在刷新
    isFlushing = true
    // 在微任务队列中刷新 jobQueue 队列
    p.then(() => {
      jobQueue.forEach(job => job())
    }).finally(() => {
      // 结束后重置 isFlushing
      isFlushing = false
    })
  }

//使用
  effect(() => {
    console.log(obj.foo)
  }, {
    scheduler(fn) {
      // 每次调度时,将副作用函数添加到 jobQueue 队列中
      jobQueue.add(fn)
      // 调用 flushJob 刷新队列
      flushJob()
    }
  })
  obj.foo++
  obj.foo++
  obj.foo++
  obj.foo++

如下图执行流程:

image.png

案例:计算属性的实现

基于以上,如果需要实现一个计算属性,需要有三个问题需要解决:

  1. 如何使effect函数懒执行
  2. 传递给effect的函数看作一个getter,并且返回计算值
  3. 如何支持缓存

懒执行&得到返回值

以上的effect函数都是立即执行的,要实现懒执行实际上十分容易,将执行结果缓存并返回即可;

实现方法:

  1. 通过option的配置对象配置lazy属性
  2. 根据lazy属性判断是立即执行的函数还是懒执行
 function effect(fn, options = {}) {
    const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      effectStack.push(effectFn)
      // 将 fn 的执行结果存储到 res 中
      const res = fn() 
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1]
      // 将 res 作为 effectFn 的返回值
      return res 
    }
    effectFn.options = options
    effectFn.deps = []
    // 只有非 lazy 的时候,才执行
    if (!options.lazy) {
      effectFn()
    }
    return effectFn
  }

实现computed

定义一个 computed 函数,接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy的 effect。computed 函数的执行会返回一个对象,该对象的value 属性是一个访问器属性,只有当读取 value 的值时,才会执行effectFn 并将其结果作为返回值返回。

 function computed(getter) {
    // 把 getter 作为副作用函数,创建一个 lazy 的 effect
    const effectFn = effect(getter, {
      lazy: true
    })
    const obj = {
      // 当读取 value 时才执行 effectFn
      get value() {
        return effectFn()
      }
    }
    return obj
  }

实现缓存

目前实现的computed函数并不能支持缓存,例如以下代码,每一次读取都会触发effectFn函数的执行。

 const sumRes = computed(() => obj.foo + obj.bar)
 console.log(sumRes.value) // 3
 console.log(sumRes.value) // 3
 console.log(sumRes.value) // 3

实现方式:

新增一个value与dirty变量,分别用来缓存effectFn的值和重新计算的标志,利用调度器在trigger中执行的特性,在调度器执行时设置重新计算的标志。

当effectFn所依赖的响应式数据更新以后,会触发trigger,说明需要重新计算。

  function computed(getter) {
    let value
    let dirty = true
    const effectFn = effect(getter, {
      lazy: true,
      // 添加调度器,在调度器中将 dirty 重置为 true
      scheduler() {
        dirty = true
      }
    })
    const obj = {
      get value() {
        if (dirty) {
          value = effectFn()
          dirty = false
        }
        return value
      }
    }
    return obj
  }