React useMemo 与 Vue computed 的缓存哲学对决:从依赖追踪到底层机制的选型决策
同一个目标,两条截然不同的路
前端框架中,「依赖变化时重新计算」是最常见的性能优化手段。React 用 useMemo/useCallback,Vue 用 computed。看起来功能相似,但它们的底层实现哲学完全不同——这种差异直接影响你的代码怎么写、性能怎么调。
本质差异:主动声明 vs 自动追踪
React 要求你手动声明依赖数组,框架不帮你追踪:
// React:开发者必须维护依赖数组
function ProductList({ items, filter }: Props) {
const filtered = useMemo(() => {
return items.filter(item => item.category === filter);
}, [items, filter]); // ⚠️ 漏写 filter → 闭包陷阱
const handleClick = useCallback((id: number) => {
console.log(filter, id);
}, [filter]); // ⚠️ 漏写 → 拿到过期值
return <div>{filtered.map(...)}</div>;
}
Vue 在 computed 内部自动收集依赖,你只写计算逻辑:
// Vue:依赖自动追踪,不会遗漏
const filtered = computed(() => {
return items.value.filter(item => item.category === filter.value);
// items.value 和 filter.value 自动成为依赖
});
这就是第一个关键差异:React 的 bug 来源之一是依赖数组遗漏;Vue 从根本上不会出现这个问题。
依赖追踪机制的源码级对比
React:浅比较依赖数组
React 的 useMemo 在内核中维护一个 fiber 上的 hook 链表。当组件重渲染时,它遍历依赖数组,用 Object.is() 逐个浅比较:
// React 内部简化逻辑
function areHookInputsEqual(nextDeps, prevDeps) {
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false; // 任何一个不等 → 重计算
}
return true; // 全等 → 跳过计算
}
这意味着两个问题:
- 引用类型即使内容相同也会触发重计算,除非你额外用
useMemo包一层 - 依赖数组长度变化(比如条件性地在数组中添加/移除依赖)会直接触发重计算
// ❌ 反例:每次渲染都创建新对象 → useMemo 永远失效
function Badge({ user }: Props) {
const config = useMemo(() => ({
level: user.score > 100 ? 'gold' : 'silver',
label: `${user.name}的徽章`
}), [user]);
// user 对象每次父组件渲染可能都是新引用 → 白优化了
return <span>{config.label}</span>;
}
// ✅ 正例:拆解到原始值级别
function Badge({ user }: Props) {
const config = useMemo(() => ({
level: user.score > 100 ? 'gold' : 'silver',
label: `${user.name}的徽章`
}), [user.score, user.name]); // 只依赖原始值
return <span>{config.label}</span>;
}
Vue:响应式系统的自动依赖图
Vue 的 computed 基于 effect 系统。每个 computed 内部有一个 ReactiveEffect 实例,当 getter 执行时,所有被访问的响应式属性都通过 Proxy 的 track() 自动注册为依赖:
// Vue 3 内部简化逻辑
class ComputedRefImpl<T> {
private _value: T;
private _dirty = true; // 脏标记
private effect: ReactiveEffect;
constructor(getter: () => T) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true; // 依赖变化 → 标记脏,不立即计算
trigger(this, 'value');
}
});
}
get value(): T {
if (this._dirty) {
this._value = this.effect.run();
this._dirty = false;
}
track(this, 'value'); // 计算属性本身也可以被其他 effect 依赖
return this._value;
}
}
核心差异在这里:
- 懒求值(Lazy):依赖变化时只设置
_dirty = true,不立即重新计算。直到.value被实际访问才执行 getter。 - 深层依赖追踪:访问
user.score时,user和score都被自动追踪。即使整个user引用变了但score没变,computed 也不会重计算。
<!-- ✅ Vue 天然能精细化追踪 -->
<script setup>
const props = defineProps<{ user: { score: number; name: string } }>();
const badge = computed(() => ({
level: props.user.score > 100 ? 'gold' : 'silver',
label: `${props.user.name}的徽章`
}));
// 只追踪 props.user.score 和 props.user.name
// user 对象引用变了但 score/name 不变 → 不会重计算!
</script>
性能代价:谁在什么时候更"贵"
React:依赖数组比较便宜,但重计算次数多
Object.is() 比较很快,但问题在于你可能比必要多计算了很多次。每个依赖数组里的引用变化都可能触发重计算,而引用变化很可能不代表值真的变了。
// ❌ 反例:过度使用 useMemo —— 缓存开销 > 计算开销
function Simple({ a, b }: { a: number; b: number }) {
const sum = useMemo(() => a + b, [a, b]);
// a + b 的代价远小于 useMemo 本身的依赖比较 + 闭包开销
// 这种场景不应该用 useMemo
return <span>{sum}</span>;
}
// ✅ 正例:只在回调引用稳定性场景用 useCallback
function ExpensiveList({ onSelect }: { onSelect: (id: number) => void }) {
const handleSelect = useCallback((id: number) => {
onSelect(id);
}, [onSelect]);
// 避免 onSelect 变化导致子列表全量重渲染
return <MemoList onSelect={handleSelect} />;
}
Vue:自动追踪"精准",但有 Proxy 开销
Vue 的依赖追踪精准,不会有多余的重计算。但代价是每个响应式属性的访问都要走 Proxy 的 getter,在极高频的访问场景下会累积开销。
// ❌ 反例:computed 中遍历大数组时频繁触发 track
const hugeSum = computed(() => {
return data.value
.map(item => item.value * 2) // 每次 .value 访问都 track
.reduce((a, b) => a + b, 0);
});
// ✅ 正例:用 shallowRef 配合手动触发,减少 track 开销
const shallowData = shallowRef<Item[]>([]);
const hugeSum = computed(() => {
return shallowData.value
.map(item => item.value * 2)
.reduce((a, b) => a + b, 0);
});
// 修改:shallowData.value = [...newData]; // 手动触发更新
实战决策矩阵
| 场景 | React | Vue |
|---|---|---|
| 简单计算(数字运算) | ❌ 不要用 useMemo | ✅ computed 无额外心智负担 |
| 回调引用稳定化 | ✅ useCallback + React.memo | 通常无需(响应式自动最小化更新) |
| 依赖深层对象的特定字段 | ⚠️ 需手动拆解到原始值 | ✅ 自动精准追踪 |
| 衍生状态的衍生状态(级联 computed) | ⚠️ 依赖数组容易出错 | ✅ 天然支持 computed 链式依赖 |
| 高频更新场景(如动画帧) | ✅ 稳定的依赖数组不会拖慢帧率 | ⚠️ 注意 Proxy overhead,考虑 shallowRef |
迁移时的坑:从 Vue 转到 React
从 Vue 转 React 最常见的错误是把 computed 的思维直接映射到 useMemo:
// ❌ Vue 思维下的错误 React 写法
function Dashboard({ data }: Props) {
// 在 Vue 里这完全没问题,但在 React 中:
const filtered = useMemo(() => data.filter(d => d.active), [data]);
const sorted = useMemo(() => [...filtered].sort((a,b) => a.time - b.time), [filtered]);
const top3 = useMemo(() => sorted.slice(0, 3), [sorted]);
// 问题:data 引用不变但内容变了 → 全部失效
// 解法:用 useMemo 把 data 的各个字段也拆开,或者接受重计算
}
// ✅ React 式正确做法:减少层级,合并计算
function Dashboard({ items }: { items: Item[] }) {
const top3 = useMemo(() => {
return items
.filter(d => d.active)
.sort((a, b) => a.time - b.time)
.slice(0, 3);
}, [items]);
// 只维护一个 useMemo,减少链条断裂风险
}
总结
| 维度 | React useMemo/useCallback | Vue computed |
|---|---|---|
| 依赖追踪 | 手动声明依赖数组 | 运行时自动收集 |
| 失效策略 | 依赖数组中任一项 !== | 依赖图中任一节点变化 |
| 计算时机 | 渲染时遇到就执行 | 懒求值(访问 .value 时) |
| 主要心智负担 | 维护依赖数组的完整性 | 注意浅层响应式边界 |
| 过度使用代价 | 依赖比较 + 内存占用 | Proxy 访问开销 |
核心结论:React 把控制权交给你,但要求你正确维护依赖数组;Vue 替你管理依赖,但你得理解响应式系统的边界。没有绝对的好坏——理解底层机制后,你可以在两个框架中都写出高性能的缓存策略。
选择框架是次要的,理解缓存何时真正有价值才是关键。不要为了用而用:先衡量,再缓存。
评论区
登录 后参与评论