前端开发··2 阅读·预计 14 分钟

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>;
}

Vuecomputed 内部自动收集依赖,你只写计算逻辑:

// 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;    // 全等 → 跳过计算
}

这意味着两个问题:

  1. 引用类型即使内容相同也会触发重计算,除非你额外用 useMemo 包一层
  2. 依赖数组长度变化(比如条件性地在数组中添加/移除依赖)会直接触发重计算
// ❌ 反例:每次渲染都创建新对象 → 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 时,userscore 都被自动追踪。即使整个 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]; // 手动触发更新

实战决策矩阵

场景ReactVue
简单计算(数字运算)❌ 不要用 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/useCallbackVue computed
依赖追踪手动声明依赖数组运行时自动收集
失效策略依赖数组中任一项 !==依赖图中任一节点变化
计算时机渲染时遇到就执行懒求值(访问 .value 时)
主要心智负担维护依赖数组的完整性注意浅层响应式边界
过度使用代价依赖比较 + 内存占用Proxy 访问开销

核心结论:React 把控制权交给你,但要求你正确维护依赖数组;Vue 替你管理依赖,但你得理解响应式系统的边界。没有绝对的好坏——理解底层机制后,你可以在两个框架中都写出高性能的缓存策略。

选择框架是次要的,理解缓存何时真正有价值才是关键。不要为了用而用:先衡量,再缓存

0 评论

评论区

登录 后参与评论