实现 watch
约 1562 字大约 5 分钟
2026-03-29
先实现最简单的
watch到这里,响应式系统已经有了
effect、scheduler、computed和ref。接下来要补的watch,本质上并不是全新的能力,而是对effect的再封装最简单的情况里,我们只想监听一个对象上的某个属性。一旦这个属性发生变化,就执行回调函数
const proxy = reactive({ name: "张三", age: 18, }); watch(proxy, () => { console.log("---修改了obj的name属性---"); });关键点
最小版本的
watch其实就是:内部再创建一个effect,让它去读取被监听的数据;当这个effect依赖的响应式数据发生变化时,再通过scheduler调用用户传入的回调代码
watch.tsfunction watch(source, cb) { effect(() => source.name, { scheduler() { cb(); }, }); }index.tsconst proxy = reactive({ name: "张三", age: 18, }); watch(proxy, () => { console.log("---修改了obj的name属性---"); });但这个版本的问题也很明显:它现在只固定监听了
source.name封装通用的读取过程
watch真正关心的,不是“固定读取某个属性”,而是“把这个 source 在依赖收集阶段完整读一遍”所以这里就需要一个更通用的读取函数
traverse。它的作用不是返回一个新对象,而是递归读取当前值,把内部能触发依赖收集的属性都访问一遍关键点
traverse的核心不是“深拷贝”,而是“深读取”。只要某个对象、数组或者ref内部的值在递归过程中被读到了,对应的依赖就会被track收集起来。seen则是用来避免循环引用导致的死循环代码
watch.tsfunction traverse(value, seen = new Set()) { if (!isObject(value) || seen.has(value)) { return; } seen.add(value); if (isRef(value)) { traverse(value.value, seen); } else if (isArray(value)) { for (let i = 0; i < value.length; i++) { traverse(value[i], seen); } } else { for (const key in value) { traverse(value[key], seen); } } return value; } function watch(source, cb) { effect(() => traverse(source), { scheduler() { cb(); }, }); }让
source同时支持 getter 和对象仅仅有
traverse还不够,因为watch的第一个参数不一定永远是对象。它既可能是:- 一个 getter
- 一个响应式对象
- 一个
ref - 甚至也可能是数组
所以这里还需要再补一层分发:如果传入的是函数,就直接把它当成 getter;否则才走
traverse(source)关键点
watch在内部并不会直接区分“这是 reactive 还是 ref”,而是先统一收口成一个getter。后面无论是立即执行、获取新旧值,还是调度回调,本质上都是围绕这个getter在工作代码
watch.tsfunction watch(source, cb) { let getter; if (isFunction(source)) { getter = source; } else { getter = () => traverse(source); } effect(() => getter(), { scheduler() { cb(); }, }); }index.tswatch(() => proxy.name, () => { console.log("name changed"); });让回调拿到新值和旧值
watch和普通effect的一个重要差别,就是它的回调通常不仅关心“变了”,还关心“变成了什么”和“之前是什么”要做到这一点,就需要把
effect切成懒执行模式,让它先只返回包装后的副作用函数;后面在调度时,再手动执行一次拿到新值关键点
新值和旧值的关键不在
watch本身,而在effect的lazy。先通过oldValue = effectFn()得到初始化结果;等依赖变化后,在scheduler里再次调用effectFn()得到newValue,这样回调里就能同时拿到前后两次的结果代码
watch.tsfunction watch(source, cb) { let getter; if (isFunction(source)) { getter = source; } else { getter = () => traverse(source); } let oldValue, newValue; const effectFn = effect(() => getter(), { lazy: true, scheduler() { newValue = effectFn(); cb(newValue, oldValue); oldValue = newValue; }, }); oldValue = effectFn(); }index.tswatch( () => proxy.name, (newValue, oldValue) => { console.log("---oldValue---", oldValue); console.log("---newValue---", newValue); }, );支持
immediate默认情况下,
watch的回调只会在依赖变化之后触发。但在 Vue 里,还可以通过immediate选项要求它在创建时先执行一次这里其实不需要额外再造一套逻辑,因为“初始化触发”和“依赖变化后触发”本质上执行的是同一件事:重新跑一遍
getter,拿到最新值,然后调用回调关键点
这里最自然的做法,是把
scheduler里那段逻辑单独抽成一个job。初始化时如果immediate为true,就直接执行job();否则先通过effectFn()记录旧值。这样调度触发和初始化触发就统一了代码
watch.tsfunction watch(source, cb, options = {}) { let getter; if (isFunction(source)) { getter = source; } else { getter = () => traverse(source); } let oldValue, newValue; const job = () => { newValue = effectFn(); cb(newValue, oldValue); oldValue = newValue; }; const effectFn = effect(() => getter(), { lazy: true, scheduler: job, }); if (options.immediate) { job(); } else { oldValue = effectFn(); } }index.tswatch( () => proxy.name, () => { console.log(proxy.name); }, { immediate: true, }, );处理竞态问题
到这里,一个能用的
watch已经有了。但只要把回调函数换成异步逻辑,就会暴露出新的问题比如连续触发两次修改:
- 第一次触发了请求 A
- 第二次触发了请求 B
- 结果请求 B 先返回,请求 A 后返回
这时候如果直接把异步结果写回去,最终保留下来的可能反而是“更旧”的那一次结果
watch(proxy, async () => { const res = await complexOption(count); finalData.value = res; });关键点
这里真正要解决的,不是“取消 promise”,而是“让上一次副作用失效”。只要在下一次调度开始前,先执行上一次注册的过期回调,那么上一轮异步逻辑即使最后返回了结果,也能通过一个
expired标记把这次结果丢弃掉代码
watch.tsfunction watch(source, cb, options = {}) { let getter; if (isFunction(source)) { getter = source; } else { getter = () => traverse(source); } let oldValue, newValue; let cleanup; function onInvalidate(fn) { cleanup = fn; } const job = () => { newValue = effectFn(); if (cleanup) { cleanup(); } cb(newValue, oldValue, onInvalidate); oldValue = newValue; }; const effectFn = effect(() => getter(), { lazy: true, scheduler: job, }); if (options.immediate) { job(); } else { oldValue = effectFn(); } }index.tswatch(proxy, async (_newValue, _oldValue, onInvalidate) => { let expired = false; onInvalidate(() => { expired = true; }); const res = await complexOption(count); if (!expired) { finalData.value = res; } });注
watch在这里和computed的思路很像,都是借助effect的lazy和scheduler来重新组织执行时机。区别在于:computed更关心缓存和按需读取watch更关心变化后的副作用回调
所以
watch最终收口出来的关键就是三件事:- 先统一得到一个可执行的
getter - 再借助
lazy effect拿到新值和旧值 - 最后通过
onInvalidate让异步副作用具备“过期失效”的能力
