Vue.js设计与实现:响应式系统的作用与实现 4-2

本章目标:

  1. 计算属性的实现原理,如何实现 lazy ,缓存,解决嵌套问题?
  2. watch 是如何实现监听对象变化,并调用回调函数的?监听对象和监听getter函数的区别?
  3. 立即执行的 watch 是如何实现的?如何决定回调执行时机?
  4. ……

4.8 计算属性 computed 与 lazy

上面实现的 effect 会立即执行传入的副作用函数,有时候我们并不希望立即执行,而是需要的时候才执行。例如计算属性。可以通过在 options 中配置 lazy 属性达到目的。

effect(() => {
  console.log(obj.foo)
}, {
  lazy: true
})

在 effect 函数中就可以根据 lazy 属性确定是否执行副作用函数:

function effect(fn, options={}) {
  const effectFn = () => {
    // 调用 cleanup 完成清除工作
    cleanup(effectFn)
    // 当前激活的副作用函数
    activeEffect = effectFn
    // 再副作用函数执行前将当前副作用压入栈中
    effectStack.push(effectFn)
    const res = fn()
    // 在当前副作用函数执行后,从栈中弹出,并把 activeEffect 还原到之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    return res
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // 定义 deps 数组,用户保存所有和当前副作用相关的依赖集合
  effectFn.deps = []
  // 只用非 lazy 时才执行
  if (!options.lazy) {
    effectFn()
  }
  // 将副作用函数返回
  return effectFn
}

返回的 effectFn 该何时执行呢?手动执行拿到返回值。

const effectFn = effect(
  () => obj.foo + obj.bar, 
  {lazy: true})
const value = effectFn()

现在已经实现了懒执行副作用函数,并且拿到了副作用函数的执行结果。接下来实现计算属性:

function computed(getter) {
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
  })

  return {
    // 读取 value 时才执行 effectFn
    get value() {
      return effectFn()
    },
  }
}

实现原理:定义一个 computed 函数,接收 getter 作为参数。内部把 getter 作为副作用函数,创建了一个 lazy 的 effect。computed 函数的执行会返回一个对象,通过 value 访问器读取值。

// {foo: 1, bar: 2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log('sumRes', sumRes.value) // sumRes 3

上面只实现了计算属性的懒计算能力。多次访问会重复计算 sumRes 的值,无法做到对值的缓存。下面进行优化:

function computed(getter) {
  // value 用来缓存上一次计算值
  let value
  // dirty 标识,用来标识是否需要重新计算
  let dirty = true
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
  })

  return {
    // 读取 value 时才执行 effectFn
    get value() {
      // 只用为脏时才进行计算
      if (dirty) {
        value = effectFn()
        // 将 drity 设置为 false,下次访问直接读取 value 值
        dirty = false
      }
      return value
    },
  }
}

新增两个属性 value 和 dirty ,其中 value 用于缓存上一次计算值。dirty 用于标识是否需要重新计算。只用 dirty 为 true 才进行调用 effectFn 重新计算。现在测试是否符合:

// {foo: 1, bar: 2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log('sumRes', sumRes.value) // 3

obj.foo++
console.log('sumRes', sumRes.value) // 3

即使修改了 obj.foo 的值,也没重新计算。问题出在我们修改 obj.foo 值后 dirty 标识并没有发生改变。解决方法在 obj.foo 或 obj.bar 发生改变时,修改 dirty 值为 true 。我们该如何做呢?当然是使用上一节学习的 scheduler。

function computed(getter) {
  // value 用来缓存上一次计算值
  let value
  // dirty 标识,用来标识是否需要重新计算
  let dirty = true
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
    // 添加调度器,调度器将 dirty 重置为 true
    scheduler() {
      dirty = true
    },
  })

  const obj = {
    // 读取 value 时才执行 effectFn
    get value() {
      // 只用为脏时才进行计算
      if (dirty) {
        value = effectFn()
        // 将 drity 设置为 false,下次访问直接读取 value 值
        dirty = false
      }
      return value
    },
  }
  return obj
}

通过调度器 scheduler 函数,在 getter 函数中所依赖的响应数据发生改变时,通过调度器函数将 dirty 重置为 true。当下次访问 sumRes.value 时,会重新调用 effectFn 函数。
我们设计的计算属性已经趋于完美,但是存在一个缺陷:当另一个 effect 读取计算属性值时:

const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
  console.log(sumRes.value)
})
obj.foo++

在另一个 effect 读取 sumRes.value 时,即使修改 obj.foo 值,无法触发副作用重新执行。
经过分析发现,上述问题本质上就是 effect 嵌套问题。外层的 effect 不会被计算属性的内部的 effect 中的响应数据收集。解决办法:当读取计算属性的值时,手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应:

function computed(getter) {
  // value 用来缓存上一次计算值
  let value
  // dirty 标识,用来标识是否需要重新计算
  let dirty = true
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
    // 添加调度器,调度器将 dirty 重置为 true
    scheduler() {
      if (!dirty) {
        dirty = true
        // 当计算属性依赖的响应式数据变化时,触发 trigger
        trigger(obj, 'value')
      }
    },
  })

  const obj = {
    // 读取 value 时才执行 effectFn
    get value() {
      // 只用为脏时才进行计算
      if (dirty) {
        value = effectFn()
        // 将 drity 设置为 false,下次访问直接读取 value 值
        dirty = false
      }
      // 当读取 value 时,手动触发 track 函数追踪函数
      track(obj, 'value')
      return value
    },
  }
  return obj
}

读取计算属性的 value 时,手动调用 track 函数,计算属性返回的对象 obj 作为 target 传递给 track 函数。当依赖数据变化时,手动调用 trigger 函数触发响应。

const sumRes = computed(() => obj.foo + obj.bar)
effect(function effectFn() {
  console.log(sumRes.value)
})
obj.foo++

他们建立的联系:

01 computed(obj)
02     └── value
03         └── effectFn

以上,我们实现了 computed 的功能:

  1. 实现 effect 懒计算,通过 lazy 标识控制 effect 是否执行,将 effectFn 返回给用户决定执行时机。
  2. 缓存上一次计算值,在 computed 内部定义一个 value 和 dirty 标识,在数据为脏时才重新计算,否则直接读取缓存值。关联的响应式数据变化时,通过 scheduler 修改 dirty 的值为 true 触发重新执行。
  3. 最后我们还解决了嵌套导致外层 effect 无法和内层的响应数据关键的问题,通过手动调用 track 函数和 trigger 解决问题。

4.9 watch 实现原理

  • watch:所谓 watch 本质就是观测一个响应式数据,当数据发生变化时通知并执行响应的回调函数。

举个🌰,如何实现 watch 函数实现下面监听呢?

watch(obj, () => {
  console.log('数据变了')
})
// 修改数据值
obj.foo++

聪明的你已经发现,通过 effect 配合 scheduler 可是轻松实现:

function watch(source, cb) {
  effect(
    // 触发读取操作,建立联系
    () => source.foo,
    {
      scheduler() {
        // 数据变化触发 cb 回调函数
        cb()
      },
    }
  )
}

副作用的中通过 scheduler 调度回调函数,watch 实现利用这个点。但上面的实现存在一个问题,只能监听 foo 变化执行回调函数。所以我们需要一个通用的的读取操作:

function watch(source, cb) {
  effect(
    // 触发读取操作,建立联系
    // 调用 traverse 递归读取
    () => traverse(source),
    {
      scheduler() {
        // 数据变化触发 cb 回调函数
        cb()
      },
    }
  )
}
function traverse(value, seen = new Set()) {
  // 如果 value 是原始值或者已经被读取过,直接返回
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将数据添加到 seen ,代表已读取过了,避免循环引用。
  seen.add(value)
  // ! 暂时不考虑数据
  // 假设 value 是一个对象,通过 for...in 读取每一个属性,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}

通过递归 traverse 读取对象的任意属性,从而任意属性发生变化时都能触发回调函数执行。在 Vue 中,watch 第一个参数不仅接收响应式数据,也可以接收一个 getter 函数。在 gettter 内部用户执行 watch 依赖哪些响应式数据,只用当前数据变化时,触发回调函数,例如:

watch(
  () => obj.foo,
  () => {
    console.log('数据变化了')
  }
)
obj.foo++

需要对 watch 进行修改,处理 source 为函数的情况:

function watch(source, cb) {
  let getter
  // 如果传入的是函数
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  effect(
    // 触发读取操作,建立联系
    // 调用 traverse 递归读取
    () => getter(),
    {
      scheduler() {
        // 数据变化触发 cb 回调函数
        cb()
      },
    }
  )
}
function traverse(value, seen = new Set()) {
  // ...
}

对传入的 source 做一层判断,如果是函数,说明传递了 getter 函数,直接使用用户传入的 getter 函数即可。否则调用 traverse 函数递归读取。
上面还缺少一个功能,在回调时无法获取新旧值。如何解决这个问题呢?

// 回调函数携带新旧值
function watch(source, cb) {
  let getter
  // 如果传入的是函数
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  const effectFn = effect(
    // 触发读取操作,建立联系
    // 调用 traverse 递归读取
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 数据变化触发 cb 回调函数,返回新值和旧值
        cb(newValue, oldValue)
        // 更新旧值,不然下一次得到错误的旧值
        oldValue = newValue
      },
    }
  )
  // 手动调用副作用函数,拿到的值是旧值
  oldValue = effectFn()
}
function traverse(value, seen = new Set()) {
  // ...
}

核心修改是使用 lazy 选项创建一个懒执行的 effect。在最后手动调用 effectFn 函数得到的返回值是旧值,第一次执行得到的值。数据变化后触发 scheduler 调度函数执行时,会重新调用 effectFn 函数得到新值。这样就拿到了新值和旧值,将他们作为 cb 的参数传递出去。最后需要刷新旧值,将新旧赋给旧值即可。

4.10 立即执行的watch与回调执行时机

通过上一节学习,我们知道 watch 的本质是对 effect 的二次封装。本节我们继续学习 watch 的两个特性:

  1. 立即执行的回调函数,在 Vue.js 通过选项参数 immediate指定是否需要立即执行。
  2. 回调函数执行时机,在 Vue.js 通过 flush指定回调函数的执行时机。

首先来看立即执行的回调函数,默认情况下,一个 watch 的回调函数只会在响应式数据发生改变时才执行。回调函数的立即执行和后续执行本质上没有区别,只是 scheduler 调度函数执行时机不同,分别在初始化执行和变化时执行。那么我们对 watch 进行修改:

function watch(source, cb, options={}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  // 提取 scheduler 调度函数为独立的 job 函数 <---------- 新增
  const job = () => {
    // 当数据变化时,调用回调函数
    newValue = effectFn()
    // 数据变化触发 cb 回调,返回新旧值
    cb(newValue, oldValue)
    // 刷新旧值
    oldValue = newValue
  }
  const effectFn = effect(
    // 触发读操作,建立联系
    () => getter(),
    {
      lazy: true,
      // 使用 job 函数作为调度器函数 <------------ 修改
      scheduler: job 
    }
  )
    // <------------------ 新增
  if (options.immediate) {
    // 当 immediate 为 true 时,立即执行 job 从而触发回调执行
    job()
  } else {
    // 手动调用副作用函数,拿到旧值(第一调用)
    oldValue = effectFn()
  }
}

实现思路很简单:将 scheduler 调度函数独立为 job 函数,在 scheduler 使用 job 函数作为调度器函数。另外再添加选项配置项 immediate 属性,在属性为 true 的时候,立即执行 job 函数,从而触发回调执行。

另外,通过 flush 选项参数来指定函数的执行时机。在 Vue.js 3 中:

watch(obj, () => {
  console.log('---', obj.foo)
}, {
  flush: 'pre', // flush?: 'pre' | 'post' | 'sync' default: 'pre'
})
  • flush 的值为 pre 表示组件更新前执行,暂时无法模拟,因为设计组件更新时机。
  • flush 的值为 post 时,调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后执行。
  • flush 的值为 sync 时,同步执行,相当于直接调用 job 函数执行。

下面模拟实现 post 异步执行副作用函数:

function watch(source, cb, options={}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  // 提取 scheduler 调度函数为独立的 job 函数
  const job = () => {
    // 当数据变化时,调用回调函数
    newValue = effectFn()
    // 数据变化触发 cb 回调,返回新旧值
    cb(newValue, oldValue)
    // 刷新旧值
    oldValue = newValue
  }
  const effectFn = effect(
    // 触发读操作,建立联系
    () => getter(),
    {
      lazy: true,
      // 使用 job 函数作为调度器函数
      scheduler: () => {
      // 在调度函数中判断 flush 是否为 post,将其加入到微任务队列中。
      if (options.flush == 'post') {
        const p = Promise.resolve()
        p.then(job)
      } else {
        job()
      }
    },
    }
  )
  if (options.immediate) {
    // 当 immediate 为 true 时,立即执行 job 从而触发回调执行
    job()
  } else {
    // 手动调用副作用函数,拿到旧值(第一调用)
    oldValue = effectFn()
  }
}

4.11 过期的副作用

竞态问题通常是多线程或多进程编程被提及的问题。但是在一些情况下前端也会遇到竞态问题,例如下面的例子:

let finalData

watch(obj, async () => {
  // 发生网络请求,
  const res = await fetch('xxxx')
  // 将请求结果赋值给 data
  finalData = res
})

这段代码,监听 obj 变化时发送网络请求,将请求结果赋值给 finalData。看起来没有问题,仔细思考会发现这段代码会发生竞态问题。
第一次请求 A 发送后,又重新发生请求B,因为网络原因,请求 B 先返回数据,请求 A 才返回数据,此时 finalData 是请求 A 产生的数据。理论上,请求 B 返回的数据是“最新”的,请求A 返回的数据就是过期数据,我们希望变量 finalData 存储的值是请求 B 返回的结果。
我们对上面问题做进一步总结,请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是第二次执行产生的副作用。请求 B 发生后,请求 A 已经过期,应该把请求 A 产生的结果视为无效,这样就避免竞态问题产生。
总之,我们需要一种手段让副作用过期,在 Vue.js 中, watch 函数的回调函数接受第三个参数 onInvalidate 是一个函数,类似于事件监听器,可以使用它注册一个回调,这个回调函数会在当前副作用函数过期时执行:

watch(obj, async (newValue, oldValue, onInvalidate) => {
  // 定义一个表示,代表当前副作用是否过期,false 没有过期
  let expired = false
  // 调用 onInvalidate() 函数注册一个过期回调
  onInvalidate(() => {
    // 当过期时,将 expired 设置为 true
    expired = true
  })
  // 发送网络请求
  const res = await fetch('path/to/request')
  // 只有当该副作用函数的执行没有过期时,才执行赋值操作
  if (!expired) {
    finalData = res
  }
})

Vue.js 如何实现 onInvalidate 的呢?在 watch 每次检测到变化后,在副作用函数重新执行前,会先调用 onInvalidate 函数注册的过期回调。那么我们对 watch 实现进行修改:

function watch(source, cb, options={}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  // 用于存储用户注册的过期回调
  let cleanup
  function onInvalidate(fn) {
    // 将过期回调存储到 cleanup 中
    cleanup = fn
  }
  let oldValue, newValue
  // 提取 scheduler 调度函数为独立的 job 函数
  const job = () => {
    // 当数据变化时,调用回调函数
    newValue = effectFn()
    // 在调用回调函数 cb 之前,先调用过期回调
    if (cleanup) {
      cleanup()
    }
    // 将 onInvalidate 作为回调函数的第三个参数
    cb(newValue, oldValue, onInvalidate)
    // 刷新旧值
    oldValue = newValue
  }
  const effectFn = effect(
    // 触发读操作,建立联系
    () => getter(),
    {
      lazy: true,
      // 使用 job 函数作为调度器函数
      scheduler: () => {
      // 在调度函数中判断 flush 是否为 post,将其加入到微任务队列中。
      if (options.flush == 'post') {
        const p = Promise.resolve()
        p.then(job)
      } else {
        job()
      }
    },
    }
  )
  if (options.immediate) {
    // 当 immediate 为 true 时,立即执行 job 从而触发回调执行
    job()
  } else {
    // 手动调用副作用函数,拿到旧值(第一调用)
    oldValue = effectFn()
  }
}

使用 cleanup 变量将用户定义的过期回调存储,在 job 函数中,每次回调 cb 之前先检查是否存在过期回调,存在执行回调函数 cleanup,最后把 onInvalidate 作为第三个 cb 参数返回给用户。

参考