实现 computed

2024-05-14 19:30:41

上一篇已经把 effect、track 和 trigger 串起来了,并且给 effect 增加了两个重要能力:

  • lazy:先不执行副作用函数,等需要时再手动执行
  • scheduler:响应式数据变化后,把重新执行的时机交给调用方控制

这两个能力刚好可以用来实现 computed。

computed 的核心特点有三个:

  • 第一次读取 .value 时才执行 getter
  • 依赖没有变化时,多次读取会复用缓存值
  • 依赖变化后,不会立刻重新计算,而是等下一次读取时再计算

从这三个特点出发,实现一个基础版 computed。

使用方式

computed 可以接收一个函数:

const state = reactive({
  a: 1,
  b: 2
})

const count = computed(() => state.a * 2)

console.log(count.value)

也可以接收一个对象,用 get 和 set 分别处理读取和写入:

const count = computed({
  get() {
    return state.a + state.b
  },
  set(newValue) {
    state.a = newValue
  }
})

console.log(count.value)
count.value = 100

所以实现时第一步要先处理参数归一化:不管传进来的是函数还是对象,最终都统一成 getter 和 setter。

参数归一化

先创建 computed.js:

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)
}

function normalizeOptions(getterOrOptions) {
  let getter
  let setter

  if (typeof getterOrOptions === 'function') {
    getter = getterOrOptions
    setter = () => {
      console.warn('computed value is readonly')
    }
  }
  else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  return {
    getter,
    setter
  }
}

如果传入的是函数,说明这个 computed 只负责读取,写入时给一个默认提示。
如果传入的是对象,就直接取对象上的 get 和 set。

接下来要返回一个带有 value 访问器的对象。模板里可以直接使用计算属性,但在逻辑层里还是通过 .value 读取和设置。

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)

  const obj = {
    get value() {
      // 返回 getter 的执行结果
    },
    set value(value) {
      setter(value)
    }
  }

  return obj
}

现在的问题是:get value 里应该怎么拿到结果?

计算属性的值来自 getter 的执行结果,而 getter 内部又可能读取响应式数据。也就是: computed 本质上还是需要 effect 完成依赖收集。

使用 lazy effect

如果直接执行 getter,它确实可以拿到值,但依赖变化时就没办法和响应式系统关联起来。

所以这里用上一篇实现的 effect 包装 getter:

import { effect } from './effect/effect.js'

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)

  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      return effectFn()
    },
    set value(value) {
      setter(value)
    }
  }

  return obj
}

一定需要 lazy: true。

因为 computed 不应该在创建时就执行 getter,而应该等第一次读取 .value 时再执行。effect 返回的 effectFn 就是包装后的执行函数,调用它时会执行 getter,并在执行过程中完成依赖收集。

但这样还有一个问题:每次读取 .value 都会重新执行 getter。

const count = computed(() => {
  console.log('computed')
  return state.a * 2
})

console.log(count.value)
console.log(count.value)
console.log(count.value)

这段代码会打印三次 computed。

计算属性应该有缓存。只要依赖没有变化,多次读取都应该返回上一次的结果。

增加缓存

增加两个变量:

  • value:保存上一次计算结果
  • dirty:标记缓存是否已经失效

第一次读取时,dirty 是 true,执行 effectFn 得到最新值,然后把 dirty 改成 false。
后续再次读取时,如果 dirty 还是 false,直接返回缓存的 value。

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)

  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }

      return value
    },
    set value(value) {
      setter(value)
    }
  }

  return obj
}

这样多次读取 .value 时,getter 只会执行一次。

不过新的问题也出现了:如果依赖的响应式数据变化了,缓存并不会自动失效。

const count = computed(() => {
  console.log('computed')
  return state.a * 2
})

console.log(count.value)
console.log(count.value)

state.a = 100

console.log(count.value)
console.log(count.value)

state.a 修改后,下一次读取应该重新计算。
所以响应式数据触发更新时,需要把 dirty 改回 true。

使用 scheduler 标记失效

依赖变化时,会通过 trigger 找到 getter 对应的 effectFn。

但是 computed 不希望依赖一变就立刻重新计算。它只需要把缓存标记为失效,等下一次读取 .value 时再重新计算。

使用 scheduler:

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)

  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }

      return value
    },
    set value(value) {
      setter(value)
    }
  }

  return obj
}

现在

  1. 创建 computed 时不会执行 getter
  2. 第一次读取 .value 时执行 getter,并缓存结果
  3. 后续读取直接返回缓存
  4. 依赖变化时只把 dirty 改成 true
  5. 下一次读取 .value 时再重新计算

computed 被 effect 使用

const count = computed(() => {
  console.log('computed')
  return state.a * 2
})

effect(() => {
  console.log(count.value)
})

state.a = 100

外层 effect 读取了 count.value,所以它应该依赖这个计算属性。
当 state.a 修改后,count 的值会变化,外层 effect 也应该重新执行。

但是按照当前实现,count.value 只是一个普通对象上的 getter。读取它时,并没有调用 track 收集依赖。依赖变化时,scheduler 也只是把 dirty 改成了 true,并没有通知使用 count.value 的副作用函数。

所以要把 computed 自己也接入依赖收集:

  • 读取 value 时,手动调用 track(obj, TrackOpTypes.GET, 'value')
  • 依赖变化时,手动调用 trigger(obj, TriggerOpTypes.SET, 'value')
import { TrackOpTypes, TriggerOpTypes } from './constant/enum.js'
import { effect } from './effect/effect.js'
import track from './effect/track.js'
import trigger from './effect/trigger.js'

export function computed(getterOrOptions) {
  const { getter, setter } = normalizeOptions(getterOrOptions)

  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(obj, TriggerOpTypes.SET, 'value')
    }
  })

  const obj = {
    get value() {
      track(obj, TrackOpTypes.GET, 'value')

      if (dirty) {
        value = effectFn()
        dirty = false
      }

      return value
    },
    set value(value) {
      setter(value)
    }
  }

  return obj
}

这样外层 effect 执行时,会读取 count.value。读取过程中 track 会把外层 effect 收集到 count.value 的依赖集合里。

当 state.a 变化时,会先触发 computed 内部 getter 对应的 scheduler。scheduler 把 dirty 标记为 true,然后再通过 trigger 通知依赖 count.value 的外层 effect 重新执行。

外层 effect 再次读取 count.value 时,因为 dirty 已经是 true,所以会重新执行 getter 并得到最新值。

总结

实现了一个基础版 computed:

  • 用参数归一化同时支持函数写法和对象写法
  • 用 effect(getter, { lazy: true }) 实现懒执行
  • 用 value 和 dirty 实现缓存
  • 用 scheduler 在依赖变化时标记缓存失效
  • 在读取和失效时手动调用 track、trigger,让 computed 可以被其他 effect 依赖