从 Effect 到 Computed:一次自然的延伸
Q: 有了上一篇手写的 effect、track、trigger 之后,computed 和 watch 是不是就很容易实现了?
A: 是的。你会发现,只要前面的响应式系统骨架搭建好了,computed 和 watch 的实现就是这些基础设施的自然延伸——本质上都是对 effect 的不同封装方式。computed 的关键在于缓存和脏值检测,watch 的关键在于旧值保存和调度时机控制。
回顾 computed 的两种用法
在动手实现之前,先回顾 computed 的两种调用方式:
// 方式一:传入 getter 函数
const state = reactive({ a: 1, b: 2 });
const sum = computed(() => {
return state.a + state.b;
});
// 方式二:传入包含 get/set 的对象
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed({
get() {
return firstName.value + ' ' + lastName.value;
},
set(newValue) {
[firstName.value, lastName.value] = newValue.split(' ');
},
});
手写 computed:逐步实现
第一步:参数归一化
由于 computed 支持函数和对象两种参数形式,第一步是将它们统一处理:
function normalizeParameter(getterOrOptions) {
let getter, setter;
if (typeof getterOrOptions === 'function') {
getter = getterOrOptions;
setter = () => {
console.warn(`Computed property was assigned to but it has no setter.`);
};
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
return { getter, setter };
}
无论用户传入的是函数还是对象,统一转换为 { getter, setter } 格式。如果只传入 getter 函数,setter 默认发出警告,这和 Vue3 原生行为一致。
第二步:建立依赖关系 + 缓存
computed 的核心特性是缓存计算结果——只有当依赖的响应式数据变化时,才重新计算。我们用 value 变量存储缓存值,用 dirty 标记标识数据是否「过期」:
let value,
dirty = true; // 初始为 true,确保第一次访问时执行计算
// 将 getter 传入 effect,getter 内部的响应式数据与 getter 建立依赖
const effetcFn = effect(getter, {
lazy: true, // 不立即执行
});
这里 lazy: true 至关重要——计算属性只有在被访问时才进行计算,而非在创建时立即执行。
第三步:返回带 value 访问器的对象
const obj = {
get value() {
if (dirty) {
value = effetcFn(); // 执行 getter,重新计算
dirty = false; // 标记为非脏,下次直接返回缓存
}
return value;
},
set value(newValue) {
setter(newValue); // 调用用户的 setter
},
};
return obj;
此时基本功能已经可用,但还有一个关键问题需要解决。
第四步:让外部函数感知计算属性的变化
考虑这个场景——一个渲染函数(或其他 effect)依赖了计算属性的值:
const state = reactive({ a: 1, b: 2 });
const sum = computed(() => {
console.log('computed');
return state.a + state.b;
});
effect(() => {
// 模拟渲染函数,依赖 sum
console.log('render', sum.value);
});
state.a++; // 期望:computed 重新计算 + render 重新执行
实际执行结果:
computed
render 3
computed // computed 重新执行了
// 但 render 没有重新执行!
虽然 state.a++ 导致 computed 内部的 getter 重新执行了(dirty 重新标记为 true),但渲染函数并不知道计算属性的值变了。原因是渲染函数和计算属性之间没有建立依赖关系。
解决方案分两步:
Step 1 — 在获取 computed.value 时,手动建立依赖:
const obj = {
get value() {
track(obj, TrackOpTypes.GET, 'value'); // ← 手动收集依赖
if (dirty) {
value = effetcFn();
dirty = false;
}
return value;
},
// ...
};
这样,当渲染函数(effect)中访问 sum.value 时,会触发 track(obj, 'value'),从而建立**「计算属性 value ↔ 渲染函数」**的依赖关系。
Step 2 — 当依赖数据变化时,触发计算属性 value 的更新:
const effetcFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true; // 标记为脏
trigger(obj, TriggerOpTypes.SET, 'value'); // 通知依赖 value 的函数(渲染函数)
},
});
scheduler 在这里起到了中间桥梁的作用:
state.a变化 → 触发 computed 内部 getter 对应的 effect- 由于配置了 scheduler,不会直接执行 getter,而是执行 scheduler
- scheduler 中:标记 dirty = true + 派发更新通知渲染函数
- 渲染函数重新执行 → 再次访问
sum.value→ 由于 dirty 为 true → 重新计算
整个数据流如下:
state.a 变化
→ scheduler 执行
→ dirty = true
→ trigger(obj, SET, 'value')
→ 渲染函数重新执行
→ 访问 sum.value
→ track(obj, GET, 'value')
→ dirty 为 true,执行 getter 重新计算
→ 返回新值
完整实现
import { effect } from './effect/effect.js';
import track from './effect/track.js';
import trigger from './effect/trigger.js';
import { TriggerOpTypes, TrackOpTypes } from './utils.js';
function normalizeParameter(getterOrOptions) {
let getter, setter;
if (typeof getterOrOptions === 'function') {
getter = getterOrOptions;
setter = () => {
console.warn(`Computed property was assigned to but it has no setter.`);
};
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
return { getter, setter };
}
export function computed(getterOrOptions) {
// 1. 参数归一化
const { getter, setter } = normalizeParameter(getterOrOptions);
// 2. 缓存值和脏标记
let value,
dirty = true;
// 3. 将 getter 包装为 effect,建立响应式依赖
const effetcFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true;
trigger(obj, TriggerOpTypes.SET, 'value');
},
});
// 4. 返回带 getter/setter 的对象
const obj = {
get value() {
track(obj, TrackOpTypes.GET, 'value');
if (dirty) {
value = effetcFn();
dirty = false;
}
return value;
},
set value(newValue) {
setter(newValue);
},
};
return obj;
}
手写 watch
Q: watch 和 watchEffect 的实现有什么不同?watch 是怎么做到获取旧值的?
A: watch 的核心区别在于它可以指定侦听的数据源、获取新旧值对比、以及控制回调的执行时机。
回顾 watch 的用法
const x = reactive({ a: 1, b: 2 });
// 侦听单个响应式对象
watch(x, (newX) => {
console.log(`x is ${newX}`);
});
// 侦听 getter 函数
watch(
() => x.a + x.b,
(sum) => {
console.log(`sum is: ${sum}`);
}
);
watch 接收的参数:
- 第一个参数(source):响应式数据或 getter 函数
- 第二个参数(cb):回调函数,接收
(newValue, oldValue) - 第三个参数(options):配置项
immediate:立即执行一次回调once:只执行一次flush:控制回调时机(post/sync)
watch 返回一个函数,调用它可以停止侦听:
const unwatch = watch(() => {});
unwatch(); // 停止侦听
第一步:参数归一化 + 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;
}
假设有一个深层嵌套的对象:
const obj = {
a: 1,
b: {
c: 2,
d: { e: 3 },
},
};
traverse 的遍历过程如下:
- 访问
obj→ 加入 seen 集合 - 遍历属性:
- 访问
obj.a(数字,直接返回) - 访问
obj.b(对象)→ 递归进入- 访问
obj.b.c(数字,直接返回) - 访问
obj.b.d(对象)→ 递归进入- 访问
obj.b.d.e(数字,直接返回)
- 访问
- 访问
- 访问
在这个过程中,每次访问一个属性都会触发其 getter 拦截,从而将当前活动的 effect 函数收集为该属性的依赖。这样就能确保:无论多深层的属性变化,watch 都能感知到。
接下来进行参数归一化:
export function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
// ...
}
无论 source 是什么类型:
- 如果是函数:直接作为 getter 使用,函数内部的响应式数据会被收集
- 如果是响应式对象:包装为
() => traverse(source),深度访问所有属性来收集依赖
第二步:使用 effect + scheduler 实现核心逻辑
let oldValue, newValue;
const job = () => {
newValue = effectFn(); // 重新执行 getter,获取新值
cb(newValue, oldValue); // 调用用户回调,传入新旧值
oldValue = newValue; // 更新旧值,为下次变化做准备
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job,
});
核心流程分析:
- getter 内部的响应式数据和 effectFn 建立依赖关系
- 响应式数据变化 → 进入 scheduler(即 job 函数)
- job 函数:
- 重新执行
getter()获取新值 - 调用用户回调
cb(newValue, oldValue) - 将
oldValue更新为当前值
- 重新执行
这里巧妙地利用 scheduler 拦截了默认的函数重新执行,改为由我们自己控制何时获取新值以及如何处理新旧对比。
第三步:实现 immediate
if (options.immediate) {
job(); // 立即执行一次回调
} else {
oldValue = effectFn(); // 首次执行 getter,结果作为 oldValue
}
- 有
immediate:立即调用job(),此时newValue是首次计算结果,oldValue是undefined - 无
immediate:执行effectFn()获取初始值,赋给oldValue,等待数据变化
第四步:实现取消侦听
return () => {
cleanup(effectFn);
};
返回一个函数,调用时执行 cleanup,将 effectFn 从所有依赖集合中移除。此后数据变化就不会再触发回调了。
第五步:flush 配置——控制回调的执行时机
flush 的本质是指定调度函数的执行时机。当 flush 为 post 时,需要将回调延迟到 DOM 更新之后再执行:
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
Promise.resolve().then(job); // 放入微任务队列
} else {
job(); // 同步执行
}
},
});
使用 Promise.resolve().then(job) 将 job 放入微任务队列,确保它在当前同步代码和 DOM 更新周期完成之后才执行。这样回调函数中就能安全地访问到更新后的 DOM。
完整实现
import { effect, cleanup } from './effect/effect.js';
export function watch(source, cb, options = {}) {
// 1. 参数归一化
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
// 2. 保存新旧值
let oldValue, newValue;
// 3. 要执行的更新函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
// 4. 使用 effect 建立依赖,scheduler 控制执行
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
Promise.resolve().then(job);
} else {
job();
}
},
});
// 5. immediate 逻辑
if (options.immediate) {
job();
} else {
oldValue = effectFn();
}
// 6. 返回取消侦听函数
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;
}
computed vs watch:设计思路对比
将 computed 和 watch 放在一起对比,能更清晰地看到它们的设计差异:
| 维度 | computed | watch |
|---|---|---|
| 用途 | 派生一个计算值,依赖变化时自动更新 | 观察数据变化,执行副作用 |
| 返回值 | 一个带 .value 的引用对象(Ref) |
一个取消侦听的函数 |
| 缓存 | 有,通过 dirty 标记实现 | 无缓存,每次都执行回调 |
| 执行时机 | 访问时计算(lazy) | 依赖变化时立即执行(或按 flush 配置) |
| 与 effect 的关系 | effect(getter, { lazy, scheduler }) | effect(getter, { lazy, scheduler }) |
| scheduler 的作用 | 标记脏 + 触发外部依赖 | 获取新值 + 调用回调 |
有意思的是,两者的底层签名一模一样:effect(getter, { lazy: true, scheduler })。区别仅在于 scheduler 中做了什么:
- computed 的 scheduler:
dirty = true; trigger(obj, SET, 'value') - watch 的 scheduler:
newValue = effectFn(); cb(newValue, oldValue); oldValue = newValue
这就是响应式基础设施的强大之处——核心机制统一,上层用法多样。
总结
通过上下两篇文章,我们从零手写了 Vue3 响应式系统的四个核心部分:
- Proxy 拦截层 —— 区分读取与写入,连接 track 与 trigger
- Effect 依赖管理 —— activeEffect、环境函数、调用栈、cleanup
- Computed 计算属性 —— 参数归一化、缓存/dirty 标记、scheduler 串联外部依赖
- Watch 侦听器 —— traverse 深度追踪、新旧值对比、flush 调度控制、取消侦听
你会发现,computed 和 watch 本质上都是 effect + scheduler 的不同封装。这种「统一核心、差异化上层」的设计,正是 Vue3 响应式系统优雅之处的集中体现。掌握了这些原理,你就真正拥有了「看穿」Vue3 响应式魔法本质的能力。