西瓜博客
返回所有文章

React 性能优化:别急着给所有组件加 memo

从性能测量出发,理解 memo、useMemo 和 useCallback 的真实作用,优先通过状态下沉与合理组件边界减少无效工作。

React 性能优化最容易走偏的一步,就是在还没有发现瓶颈时,先给所有组件加上 memo,再把所有函数包进 useCallback。

缓存有维护成本,也会增加代码的理解难度。更可靠的顺序是:复现问题、测量瓶颈、调整结构,最后才考虑缓存。

1. 渲染不等于修改整个 DOM

组件函数再次执行,意味着 React 要重新计算这部分界面。React 随后比较结果,再提交必要的 DOM 更新;不是每次渲染都会重建整个页面。

不过,组件执行中的昂贵计算仍然会消耗时间。如果列表很大或交互频繁,就需要关注这些计算是否出现在关键路径上。

使用 React DevTools Profiler 观察具体交互,重点看哪些组件耗时最多、为什么发生更新。开发模式、Strict Mode 和调试工具都会影响结果,必要时用接近生产的构建复核。

2. 先把状态放到真正需要它的位置

假设整个页面保存了一个输入框的文字,每输入一个字符都让包含大型报表的父组件重新渲染。如果报表根本不需要这个文字,最简单的优化通常是把输入状态移到输入组件内部。

这比把每个报表组件逐个包上 memo 更直接。状态提升用于共享数据,状态下沉用于限制更新范围;二者都应该服从数据真正的使用位置。

3. 三个 API 分别缓存什么

API 缓存对象 主要用途
memo 组件上一次渲染结果的复用机会 props 未变化时跳过部分父级引起的渲染
useMemo 计算结果 避免重复的昂贵计算或稳定引用
useCallback 函数引用 为需要稳定引用的消费者提供函数

memo 默认用 Object.is 逐项比较 props。新建的对象、数组和函数,即使内容相同,引用也不同。

memo 不会阻止组件因自身 state 或消费的 Context 变化而重新渲染,也不是绝对的永不执行保证。

4. 一个有边界的缓存示例

下面把输入筛选、主题切换和列表展示放在一起,便于观察引用变化。在 Next.js App Router 中,应将组件放在客户端组件边界内。

import { memo, useCallback, useMemo, useState } from "react";

type Article = { id: string; title: string };

const initialArticles: Article[] = [
  { id: "state", title: "React 状态更新" },
  { id: "hooks", title: "React Hooks 实践" },
  { id: "testing", title: "组件测试入门" },
];

const ArticleList = memo(function ArticleList({
  articles,
  onSelect,
}: {
  articles: Article[];
  onSelect: (articleId: string) => void;
}) {
  return (
    <ul>
      {articles.map(article => (
        <li key={article.id}>
          <button onClick={() => onSelect(article.id)}>{article.title}</button>
        </li>
      ))}
    </ul>
  );
});

export function ArticleBrowser() {
  const [keyword, setKeyword] = useState("");
  const [dark, setDark] = useState(false);
  const [selectedId, setSelectedId] = useState<string | null>(null);

  const visibleArticles = useMemo(() => {
    const normalizedKeyword = keyword.trim().toLowerCase();
    return initialArticles.filter(article =>
      article.title.toLowerCase().includes(normalizedKeyword)
    );
  }, [keyword]);

  const handleSelect = useCallback((articleId: string) => {
    setSelectedId(articleId);
  }, []);

  return (
    <section data-theme={dark ? "dark" : "light"}>
      <label>
        关键词
        <input value={keyword} onChange={event => setKeyword(event.target.value)} />
      </label>
      <button onClick={() => setDark(previousDark => !previousDark)}>切换主题</button>
      <p>当前选择:{selectedId ?? "暂无"}</p>
      <ArticleList articles={visibleArticles} onSelect={handleSelect} />
    </section>
  );
}

主题变化时,keyword 没变,所以 useMemo 可以复用数组引用;handleSelect 引用也稳定,因此列表具备跳过此次父级更新的条件。

空依赖数组在这里成立,是因为回调只使用参数和稳定的 state setter。如果回调读取了关键词等响应式值,就必须列入依赖,不能为了稳定引用牺牲正确性。

这只是演示缓存如何配合工作。三条文章的列表不值得专门优化;data-theme 也只是主题标记,实际颜色仍需 CSS 实现。

5. 什么时候不值得缓存

  • 普通字符串拼接、简单加减法等低成本计算。
  • 没有稳定引用需求的普通事件函数。
  • props 每次都必然变化的组件。
  • 为了解决状态设计问题而勉强叠加的缓存。

缓存不是业务语义保证。代码正确性不应该依赖 useMemo 永远保留某个结果;需要持久的组件状态时,应使用 state 或 ref 等合适机制。

如果项目启用了 React Compiler,编译器可能自动完成部分记忆化优化。先确认项目配置和测量结果,再决定是否添加手动缓存。

小结

优先级可以概括为:先测量,再减少更新范围,再降低计算成本,最后缓存确实昂贵且可复用的工作。 对超大列表,虚拟化可能比缓存更有效;对卡顿输入,延后非紧急渲染可能更合适。