实现 computed

2024-05-14 19:30:41

上一篇已经把 effecttracktrigger 串起来了,并且给 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)

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

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

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

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

参数归一化

先创建 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 只负责读取,写入时给一个默认提示。
如果传入的是对象,就直接取对象上的 getset

接下来要返回一个带有 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:标记缓存是否已经失效

第一次读取时,dirtytrue,执行 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 对应的 schedulerschedulerdirty 标记为 true,然后再通过 trigger 通知依赖 count.value 的外层 effect 重新执行。

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

总结

实现了一个基础版 computed

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