实现 watch

2024-05-14 19:30:41

上一篇实现了 computed

computedwatch 都建立在 effect 之上,但目标不一样:

  • computed 关心的是一个可以缓存的计算结果
  • watch 关心的是响应式数据变化后,执行传入的回调

使用方式

先看几个常见用法。

监听一个 getter:

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

watch(() => state.a, (newValue, oldValue) => {
  console.log(newValue, oldValue)
})

监听整个响应式对象:

watch(state, (newValue, oldValue) => {
  console.log(newValue, oldValue)
})

监听多个来源:

watch([() => state.a, () => state.b], (newValue, oldValue) => {
  console.log(newValue, oldValue)
})

参数归一化

watch 的第一个参数可以是函数、响应式对象,也可以是数组。

为了让后面的逻辑统一处理,第一步先把不同类型的 source 都转换成一个 getter

export function watch(source, cb, options = {}) {
  let getter

  if (typeof source === 'function') {
    getter = source
  }
  else if (Array.isArray(source)) {
    getter = () => source.map((item) => {
      if (typeof item === 'function') {
        return item()
      }
      return traverse(item)
    })
  }
  else {
    getter = () => traverse(source)
  }
}

如果 source 是函数,直接作为 getter。
如果 source 是数组,就依次执行数组里的每一项,最后返回一个结果数组。
如果 source 是对象,就通过 traverse 递归读取它的属性,让深层属性也能完成依赖收集。

traverse 的实现:

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) {
    return value
  }

  seen.add(value)

  for (const key in value) {
    traverse(value[key], seen)
  }

  return value
}

重点是递归读取属性的过程。

读取属性会触发代理对象的 get,从而进入 track 收集依赖。seen 用来避免循环引用导致无限递归。

使用 lazy effect

有了 getter 后,下一步就可以用 effect 收集依赖。

不过 watch 不能直接让 effect 立即执行回调。它需要先拿到旧值,等依赖变化时再拿新值,然后把一起执行回调。

所以这里需要 lazy: true

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

export function watch(source, cb, options = {}) {
  let getter

  // 参数归一化

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

effect 返回的 effectFn 是包装后的执行函数。调用它时会执行 getter,并在执行过程中收集依赖。

接着定义 oldValuenewValue

let newValue
let oldValue

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

oldValue = effectFn()

第一次手动执行 effectFn,是为了拿到初始值作为 oldValue
这次执行也会完成依赖收集,所以后续响应式数据变化时,trigger 就能找到这个 effectFn

使用 scheduler 执行回调

依赖变化后,trigger 会找到 effectFn

watch 不希望直接重新执行 getter 就结束了,它还需要:

  1. 执行 getter 拿到 newValue
  2. newValueoldValue 传给回调
  3. 回调执行后,把 newValue 保存成下一次的 oldValue

这段逻辑可以抽成一个 job

let newValue
let oldValue

function job() {
  newValue = effectFn()
  cb(newValue, oldValue)
  oldValue = newValue
}

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

oldValue = effectFn()

现在的流程是:

  • 创建 watch 时,执行一次 effectFn,拿到旧值并收集依赖
  • 依赖变化时,进入 scheduler
  • scheduler 执行 job
  • job 重新执行 effectFn 拿到新值,再执行回调

immediate 和 flush

watch 还可以通过第三个参数控制行为。

先支持两个常见选项:

  • immediate:创建 watch 后立即执行一次回调
  • flush: 'post':把回调放到微任务里执行
const effectFn = effect(() => getter(), {
  lazy: true,
  scheduler() {
    if (options.flush === 'post') {
      Promise.resolve().then(job)
    }
    else {
      job()
    }
  }
})

if (options.immediate) {
  job()
}
else {
  oldValue = effectFn()
}

如果设置了 immediate,就直接执行 job。如果没有设置 immediate,就先执行 effectFn 保存旧值,但不执行回调。

flush: 'post' 的实现目前只是把 job 放进微任务。这个版本还没有做任务队列去重,所以同一轮同步修改多次时,可能会排入多个微任务。后续如果要更接近 Vue,可以再补一个 job queue。

停止监听

watch 会返回一个函数,用来停止监听。

因为依赖关系最终都保存在 effectFn.deps 里,所以停止监听时只需要清理这个 effectFn

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

return () => {
    cleanup(effectFn)
}

处理过期回调

watch 经常会配合异步逻辑使用。

比如监听一个 id,然后根据 id 发请求:

watch(() => state.id, async (id) => {
  const data = await fetchUser(id)
  state.user = data
})

如果 id 连续变化,旧请求可能比新请求更晚返回。这样旧结果就有机会覆盖新结果。

为了解决这个问题,可以给回调提供第三个参数 onInvalidate。用户在里面注册一个清理函数,当下一次回调执行前,先执行上一次注册的清理逻辑。

let cleanupFn

function onInvalidate(fn) {
  cleanupFn = fn
}

function job() {
  newValue = effectFn()

  if (cleanupFn) {
    cleanupFn()
    cleanupFn = undefined
  }

  cb(newValue, oldValue, onInvalidate)
  oldValue = newValue
}

使用时可以这样写:

watch(() => state.id, async (id, oldId, onInvalidate) => {
  let expired = false

  onInvalidate(() => {
    expired = true
  })

  const data = await fetchUser(id)

  if (!expired) {
    state.user = data
  }
})

state.id 再次变化时,下一次 job 会先执行上一次注册的清理函数,把旧请求标记为过期。

完整代码

最后把逻辑合在一起:

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

export function watch(source, cb, options = {}) {
  let getter

  if (typeof source === 'function') {
    getter = source
  }
  else if (Array.isArray(source)) {
    getter = () => source.map((item) => {
      if (typeof item === 'function') {
        return item()
      }
      return traverse(item)
    })
  }
  else {
    getter = () => traverse(source)
  }

  let newValue
  let oldValue
  let cleanupFn

  function onInvalidate(fn) {
    cleanupFn = fn
  }

  const job = () => {
    newValue = effectFn()

    if (cleanupFn) {
      cleanupFn()
      cleanupFn = undefined
    }

    cb(newValue, oldValue, onInvalidate)
    oldValue = newValue
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      if (options.flush === 'post') {
        Promise.resolve().then(job)
      }
      else {
        job()
      }
    }
  })

  if (options.immediate) {
    job()
  }
  else {
    oldValue = effectFn()
  }

  return () => {
    cleanup(effectFn)
  }
}

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) {
    return value
  }

  seen.add(value)

  for (const key in value) {
    traverse(value[key], seen)
  }

  return value
}

到这里,一个基础版 watch 就实现了。

核心还是前面实现过的两个能力:

  • lazy:让 watch 自己决定什么时候执行 getter
  • scheduler:让 watch 接管依赖变化后的执行逻辑