实现 watch
上一篇实现了 computed。
computed 和 watch 都建立在 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,并在执行过程中收集依赖。
接着定义 oldValue 和 newValue:
let newValue
let oldValue
const effectFn = effect(() => getter(), {
lazy: true
})
oldValue = effectFn()
第一次手动执行 effectFn,是为了拿到初始值作为 oldValue。
这次执行也会完成依赖收集,所以后续响应式数据变化时,trigger 就能找到这个 effectFn。
使用 scheduler 执行回调
依赖变化后,trigger 会找到 effectFn。
但 watch 不希望直接重新执行 getter 就结束了,它还需要:
- 执行 getter 拿到
newValue - 把
newValue和oldValue传给回调 - 回调执行后,把
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执行jobjob重新执行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自己决定什么时候执行 getterscheduler:让watch接管依赖变化后的执行逻辑