React useMemo 与 useCallback 完全指南:原理、场景与避坑

一、先搞懂一个问题:为什么需要记忆化?

在 React 中,每次组件重新渲染时,函数组件内部的所有变量和函数都会被重新创建。对于简单逻辑这毫无影响,但在以下场景中会带来性能问题:

  • 昂贵计算重复执行:遍历、筛选、聚合大量数据,每次渲染都重新算一遍
  • 子组件无效重渲染 :父组件渲染时创建了新的函数/对象引用,即使逻辑没变,React.memo 也会认为 props 变化而触发子组件重渲染

useMemouseCallback 就是 React 提供的两个记忆化(Memoization) Hook,核心思想一致:依赖不变,就复用上次结果,跳过重复工作


二、useMemo:缓存计算结果

2.1 基本语法

jsx 复制代码
const cachedValue = useMemo(() => {
  // 耗时计算逻辑
  return result;
}, [dep1, dep2]);
  • 第一个参数:一个无参的纯函数,返回需要缓存的计算结果
  • 第二个参数:依赖数组,数组中任意值变化时重新计算
  • 返回值:首次渲染返回计算结果;依赖不变时返回上次缓存的值

2.2 底层原理

要真正理解 useMemo,必须先了解 React 的 Fiber 架构Hook 链表机制。

2.2.1 数据存储:Hook 链表

在 React 的 Fiber 架构中,每个函数组件对应一个 Fiber 节点,该节点的 memoizedState 属性指向一个单向链表,按调用顺序串联组件中所有的 Hook 节点。

js 复制代码
// Hook 节点的数据结构(简化版)
interface Hook {
  memoizedState: any,   // 存储当前 Hook 的状态值
  baseState: any,       // 基础状态
  baseQueue: any,       // 基础更新队列
  queue: any,           // 待处理更新队列
  next: Hook | null,    // 指向下一个 Hook
}

对于 useMemo,其 memoizedState 存储的是一个**数组 ****[value, deps]**,其中:

  • value:上次计算得到的结果
  • deps:上次的依赖项数组
scss 复制代码
fiber.memoizedState → hook1(useState) → hook2(useMemo) → hook3(useEffect) → null
                       [count, null]    [cachedValue, deps]  [cleanup, deps]

💡 这就是为什么 Hook 必须在顶层调用、不能放在条件语句中------React 靠调用顺序来定位链表中的节点,一旦顺序变化,就会读取到错误的状态。

2.2.2 双缓存机制:Current 与 WorkInProgress

React 采用双 Fiber 树机制:

  • Current Tree:当前屏幕上正在显示的 Fiber 树
  • WorkInProgress Tree:正在构建中的新 Fiber 树

每次组件重新渲染时,React 会基于 Current Tree 克隆出 WorkInProgress Tree,在 WorkInProgress 上执行组件函数、调用 Hook,完成后通过指针交换(root.current = finishedWork)完成更新。

useMemo 的缓存读取正是利用了这一机制:

js 复制代码
// 简化版:从 alternate(旧的 Current Fiber)读取上次的 Hook 状态
const oldHook = workInProgress.alternate?.memoizedState;
const cachedValue = oldHook?.memoizedState[0];  // 取出上次缓存的值
const cachedDeps = oldHook?.memoizedState[1];   // 取出上次的依赖

2.2.3 挂载阶段:mountMemo

组件首次渲染时,调用 mountMemo

js 复制代码
function mountMemo<T>(nextCreate: () => T, deps): T {
  // 1. 创建新的 Hook 节点,追加到 Hook 链表末尾
  const hook = mountWorkInProgressHook();

  // 2. 标准化依赖数组(undefined → null)
  const nextDeps = deps === undefined ? null : deps;

  // 3. 立即同步执行计算函数(在 Render 阶段)
  const nextValue = nextCreate();

  // 4. 将 [计算结果, 依赖数组] 存入 memoizedState
  hook.memoizedState = [nextValue, nextDeps];

  // 5. 返回计算结果供组件使用
  return nextValue;
}

关键点 :挂载阶段没有历史缓存可比较,所以无条件执行计算函数,建立初始缓存记录。

2.2.4 更新阶段:updateMemo

组件重新渲染时,调用 updateMemo,这是缓存逻辑的核心:

js 复制代码
function updateMemo<T>(nextCreate: () => T, deps): T {
  // 1. 从 Hook 链表中按顺序取出对应的 Hook 节点
  const hook = updateWorkInProgressHook();

  // 2. 标准化新依赖数组
  const nextDeps = deps === undefined ? null : deps;

  // 3. 读取上次缓存的状态 [cachedValue, cachedDeps]
  const prevState = hook.memoizedState;

  // 4. 依赖比较:如果依赖未变化,直接返回缓存值
  if (nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];  // 🎯 命中缓存,跳过计算
    }
  }

  // 5. 依赖变化了,重新执行计算
  const nextValue = nextCreate();

  // 6. 更新缓存
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

2.2.5 依赖比较算法:Object.is 浅比较

areHookInputsEqual 是决定缓存是否命中的关键函数:

js 复制代码
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false;

  // 长度不同,直接判定为变化
  if (nextDeps.length !== prevDeps.length) return false;

  // 逐项使用 Object.is 进行浅比较
  for (let i = 0; i < prevDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) {
      return false;
    }
  }
  return true;
}

React 使用 Object.is 而非 ===,主要差异在于:

  • Object.is(NaN, NaN)trueNaN === NaNfalse
  • Object.is(+0, -0)false+0 === -0true

这也解释了为什么引用类型作为依赖时必须保持引用稳定 ------即使对象内容完全相同,只要引用地址变了({} !== {}),Object.is 就会判定为"变化",触发重新计算。

2.2.6 执行时机:Render 阶段同步执行

useMemo 的计算函数在 Render 阶段同步执行,这意味着:

  • ✅ 可以读取 props、state 等响应式数据

  • 不能包含副作用(API 请求、DOM 操作、定时器等),因为 Render 阶段可能被 React 中断并重试

  • 📌 副作用应该用 useEffect,它在 Commit 阶段异步执行,且保证只执行一次。

2.3 典型使用场景

✅ 场景一:昂贵计算缓存

js 复制代码
function ProductList({ products, searchTerm }) {
  // 每次渲染都 filter 上千条数据 → 卡顿
  const filtered = useMemo(() => {
    return products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [products, searchTerm]);  // 仅当这两个值变化时才重新筛选
  return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

✅ 场景二:稳定对象/数组引用,配合 React.memo

js 复制代码
function Parent({ user }) {
  // 每次渲染都创建新对象 → 子组件 memo 失效
  const config = useMemo(() => ({
    theme: 'dark',
    locale: user.locale,
    permissions: calcPermissions(user.role),
  }), [user.locale, user.role]);
  return <ChildComponent config={config} />;
}
const ChildComponent = React.memo(({ config }) => {
  // 只有 config 引用真正变化时才重渲染
  return <div>{config.theme}</div>;
});

✅ 场景三:避免 Effect 频繁触发

js 复制代码
const options = useMemo(() => ({ method: 'GET', headers }), [headers]);
useEffect(() => {
  fetch(url, options);  // options 引用稳定,Effect 不会重复执行
}, [options]);

2.4 ❌ 不该用 useMemo 的场景

js 复制代码
// ❌ 简单运算,缓存开销 > 计算开销
const value = useMemo(() => count + 1, [count]);
// ✅ 直接计算即可
const value = count + 1;
// ❌ 在 useMemo 中执行副作用
const data = useMemo(() => {
  fetchData();  // 副作用!
  return processedData;
}, [deps]);
// ✅ 副作用用 useEffect

三、useCallback:缓存函数引用

3.1 基本语法

jsx 复制代码
const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a, b]);
  • 第一个参数:需要缓存的回调函数
  • 第二个参数:依赖数组
  • 返回值 :依赖不变时,返回同一个函数引用

3.2 底层原理

useCallback 的底层实现与 useMemo 几乎完全一致 ,甚至可以认为 useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。 两者的核心差异仅在于存储的内容不同

useMemo useCallback
memoizedState[0] 计算函数的返回值 回调函数本身(函数引用)
缓存目的 避免重复计算 保持引用稳定

3.2.1 挂载阶段:mountCallback

js 复制代码
function mountCallback<T>(callback: T, deps): T {
  // 1. 创建新的 Hook 节点
  const hook = mountWorkInProgressHook();

  // 2. 标准化依赖数组
  const nextDeps = deps === undefined ? null : deps;

  // 3. 将 [函数引用, 依赖数组] 存入 memoizedState
  //    注意:这里存的是函数本身,不是执行结果
  hook.memoizedState = [callback, nextDeps];

  // 4. 返回原始函数
  return callback;
}

mountMemo 的关键区别:mountMemo执行 nextCreate() 获取返回值再存储,而 mountCallback 不执行函数,直接存储函数引用。

3.2.2 更新阶段:updateCallback

js 复制代码
function updateCallback<T>(callback: T, deps): T {
  // 1. 从 Hook 链表中按顺序取出对应的 Hook 节点
  const hook = updateWorkInProgressHook();

  // 2. 标准化新依赖数组
  const nextDeps = deps === undefined ? null : deps;

  // 3. 读取上次缓存的 [函数引用, 依赖数组]
  const prevState = hook.memoizedState;

  // 4. 依赖比较:使用与 useMemo 完全相同的算法
  if (nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];  // 🎯 返回缓存的函数引用
    }
  }

  // 5. 依赖变化,更新缓存为新函数
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

3.2.3 为什么 useCallback 是 useMemo 的特例

从源码层面看,两者的核心逻辑完全一致,唯一的区别在于:

js 复制代码
// useMemo:存储的是执行结果
hook.memoizedState = [nextCreate(), nextDeps];  // 先执行,再存结果

// useCallback:存储的是函数本身
hook.memoizedState = [callback, nextDeps];       // 不执行,直接存引用

这也是为什么官方提供两个独立 API 的原因:

  • 语义化useMemo 明确表示"缓存一个值",useCallback 明确表示"缓存一个函数"
  • 减少误用 :如果用 useMemo 缓存函数,需要写成 useMemo(() => fn, deps),容易忘记包裹导致直接缓存执行结果
  • 类型约束 :TypeScript 中 useCallback 的返回值被强制约束为函数类型

3.2.4 只缓存引用,不缓存执行

这是 useCallback 最容易被误解的一点:

js 复制代码
const handleClick = useCallback(() => {
  console.log('clicked', count);  // count 是闭包捕获的值
}, [count]);
  • useCallback 不会执行 handleClick,它只是返回一个稳定的函数引用
  • 只有当你手动调用 handleClick() 时,函数体才会执行
  • 如果依赖数组写漏了 count,函数引用虽然稳定了,但内部捕获的 count 永远是旧值(闭包陷阱

3.2.5 为什么必须配合 React.memo

useCallback 单独使用没有任何性能收益 ,它只是让函数引用不变,但函数引用不变本身不会减少任何计算。只有当这个稳定的引用传递给用 React.memo 包裹的子组件时,才能避免子组件因 props 引用变化而无效重渲染。

markdown 复制代码
父组件渲染
    ↓
useCallback 返回同一个函数引用
    ↓
React.memo 对子组件 props 做浅比较
    ↓
引用没变 → 跳过子组件渲染 ✅
引用变了 → 子组件重渲染 ❌

💡 关键区别useCallback 只缓存函数引用,不缓存执行结果。函数体内部逻辑每次调用时仍然会执行,只是引用地址不变。

3.3 典型使用场景

✅ 场景一:传递给 React.memo 子组件的回调

jsx 复制代码
// 父组件
function Parent() {
  const [count, setCount] = useState(0);

  // 不用 useCallback → 每次渲染创建新函数 → Child 重渲染
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);  // 空依赖,整个生命周期引用不变

  return (
    <>
      <Child onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
    </>
  );
}

// 子组件
const Child = React.memo(({ onClick }) => {
  console.log('Child rendered');  // 只有 onClick 引用变化时才打印
  return <button onClick={onClick}>Click</button>;
});

✅ 场景二:函数作为其他 Hook 的依赖

jsx 复制代码
const fetchUser = useCallback((id) => {
  return api.get(`/users/${id}`);
}, []);

useEffect(() => {
  fetchUser(userId);  // fetchUser 引用稳定,Effect 只依赖 userId
}, [fetchUser, userId]);

✅ 场景三:高频事件处理

jsx 复制代码
const handleScroll = useCallback((e) => {
  setScrollY(e.target.scrollTop);
}, []);

// 配合节流/防抖时,稳定的引用很重要
useEffect(() => {
  const throttled = throttle(handleScroll, 100);
  window.addEventListener('scroll', throttled);
  return () => window.removeEventListener('scroll', throttled);
}, [handleScroll]);

3.4 ❌ 不该用 useCallback 的场景

jsx 复制代码
// ❌ 仅组件内部使用、不传递给子组件的函数
const handleClick = useCallback(() => { setCount(c => c + 1); }, []);
// ✅ 直接写即可,没有优化收益
const handleClick = () => { setCount(c => c + 1); };

// ❌ 子组件没有用 React.memo 包裹
// useCallback 单独使用毫无意义,必须配合 memo 才能拦截重渲染

四、useMemo vs useCallback 核心对比

维度 useMemo useCallback
缓存对象 计算结果(值) 函数引用(方法)
执行时机 依赖变化时自动执行计算 仅缓存引用,手动调用才执行
解决的问题 避免重复 CPU 计算 避免函数引用变更导致子组件无效重渲染
能否单独使用 ✅ 可以,直接减少计算量 ❌ 单独使用无意义,需配合 React.memo
等价关系 useMemo(() => fn, deps)useCallback(fn, deps) ---

五、黄金搭档:React.memo + useMemo + useCallback

三者形成完整的缓存链路

plain 复制代码
React.memo    → 拦截组件重渲染(组件级缓存)
useMemo       → 稳定数据引用(值级缓存)
useCallback   → 稳定函数引用(函数级缓存)
jsx 复制代码
// 完整示例:带搜索和选择的商品列表
const ProductItem = React.memo(({ product, onSelect }) => {
  return (
    <li onClick={() => onSelect(product.id)}>
      {product.name} - ¥{product.price}
    </li>
  );
});

function ProductPage({ products, keyword }) {
  const [selected, setSelected] = useState(null);

  // 1. useMemo 缓存筛选结果,避免每次渲染都遍历
  const filtered = useMemo(() => {
    const lower = keyword.toLowerCase();
    return products.filter(p => p.name.toLowerCase().includes(lower));
  }, [products, keyword]);

  // 2. useCallback 稳定回调引用,配合 React.memo 生效
  const handleSelect = useCallback((id) => {
    setSelected(id);
  }, []);

  return (
    <ul>
      {filtered.map(p => (
        <ProductItem key={p.id} product={p} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

六、避坑指南

坑 1:依赖数组写漏 → 拿到过期数据

jsx 复制代码
// ❌ 忘记把 filter 加入依赖
const filtered = useMemo(() =>
  items.filter(i => i.category === filter), [items]);

// ✅ 所有响应式变量都要加入依赖
const filtered = useMemo(() =>
  items.filter(i => i.category === filter), [items, filter]);

坑 2:依赖数组写死 [] → 缓存永不更新

jsx 复制代码
// ❌ 依赖写空数组,total 永远是初始值
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), []);

坑 3:闭包陷阱 → 函数捕获过期状态

jsx 复制代码
// ❌ 依赖为空,count 永远是初始值 0
const increment = useCallback(() => {
  setCount(count + 1);
}, []);

// ✅ 使用函数式更新
const increment = useCallback(() => {
  setCount(prev => prev + 1);
}, []);

坑 4:过度使用 → 反而更慢

useMemo / useCallback 本身有成本:维护缓存占用内存 + 每次渲染做依赖比较。如果计算本身很快(1ms 内),缓存开销反而大于计算开销

📌 黄金法则:先用 React DevTools Profiler 定位瓶颈,确认存在性能问题后再加记忆化,不要"以防万一"到处套。


七、前瞻:React 19 编译器自动记忆化

React 19 引入了React Compiler (原 React Forget),能够自动对组件中的值和函数进行记忆化处理,大幅减少手动调用 useMemo / useCallback 的需求。

jsx 复制代码
// React 19 + Compiler 开启后,以下代码会被自动优化
function Component({ items }) {
  const total = items.reduce((s, i) => s + i.price, 0);  // 自动 memo
  const handleClick = () => { console.log('click'); };    // 自动 callback
  return <div>{total}</div>;
}

但理解 useMemouseCallback 的底层原理仍然非常重要------编译器并非万能,在复杂场景下你仍需手动干预,且面试和代码审查中这些概念依然是高频考点。


八、总结速查表

场景 用哪个
大数据筛选/排序/聚合 useMemo
复杂数学计算、递归 useMemo
稳定对象/数组引用传给子组件 useMemo
函数传给 React.memo 子组件 useCallback
函数作为 useEffect 等 Hook 的依赖 useCallback
简单运算、组件内部自用函数 都不用
不确定是否有性能问题 先 Profiler,再决定

如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区分享你的使用心得~

相关推荐
神秘的猪头43 分钟前
React 脱围机制(二):重新理解 useEffect——它不是生命周期,而是同步外部系统
react.js
碳基修炼1 小时前
排查记:本地 devServer 是 http,浏览器却把 302 重定向升级成了 https
前端·http
步行cgn1 小时前
Spring p 命名空间注入详解
java·前端·spring
何何____1 小时前
Vue 生命周期详解
前端·javascript
江米小枣tonylua1 小时前
TypeScript 全面の拥抱 !Prisma 8 + Electron 升级实战
前端
闪耀之光M782 小时前
npm命令解析
前端
咸鱼老弟2 小时前
AI Agent 的"自主性悖论"——为什么给它的自由度越大,越要配一套更硬的护栏
前端·人工智能
默_笙3 小时前
💫 闭包是个背包:拆解小米前端面试题里的三道"闭包陷阱"
前端·javascript·面试
天若有情6733 小时前
粒子星空背景单页HTML模板|炫酷动态星空特效网页(纯前端)
前端·html·css动画·网页特效·粒子星空·静态网页模版