#Next.js#React

React 性能优化实战

React 应用卡,八成是不必要的重渲染。本文讲清性能优化的正确顺序:先测量(Profiler)再优化、memo/useMemo/useCallback 的真实作用与误用、key 与列表性能、状态拆分、并发特性(useDeferredValue/useTransition),以及"过早优化是万恶之源"在 React 里的体现。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年7月7日·更新于 2026年7月23日

你的 React 应用卡了,第一反应是什么?八成是去给组件包 memo、给值包 useMemo、给函数包 useCallback。包完发现好像也没快多少,于是继续包,直到整个代码库到处都是 memo——结果不仅没快,有时候还更慢了。

这是最常见的 React 性能优化误区:把优化手段当成默认写法,把"先测量"这一步彻底跳过。 React 本身的渲染没那么贵,绝大多数性能问题不是"渲染太慢",而是"在不必要的时候渲染了不必要的组件树"。本文不讲"memo 怎么用"这种文档级内容,讲的是优化的正确顺序和真实代价——先量再改、memo 的真相、列表与 key、状态下放、并发特性,以及为什么 90% 的 memo 都不必写。

一、先把"渲染"搞清楚:重渲染 ≠ 慢,不必要的重渲染才是

要优化 React 性能,先得知道 React 在干什么。一句话:React 默认的渲染模型是"父组件渲染,所有子孙组件都跟着渲染"——除非你显式用 memo 拦截。但"渲染"和"更新 DOM"是两回事:

  • 渲染(render):组件函数执行一遍,产出 React 元素树。
  • 提交(commit):React 把新树和旧树 diff,只把真正变化的部分写到 DOM。

也就是说,哪怕一个组件重新执行了一百次,只要它产出的 UI 没变,DOM 就一个节点都不会动。绝大多数组件的渲染本身非常便宜——就是跑一个函数、对几层对象做 diff。真正贵的是两类:

  1. 渲染过程里跑了重计算(大列表 filter/sort、复杂格式化、运行时编译);
  2. 渲染触发了真实的 DOM 操作(大量节点重排重绘、强制 reflow)。

所以问题从来不是"React 渲染了",而是**"一次无关的状态变更,牵连着一棵昂贵的组件树一起重渲染"**。一个搜索框输入一个字,整页一千行的表格跟着重算了一遍——这才是卡的根源。优化的核心思路就一句:让昂贵的部分只在它真正依赖的数据变化时才重新计算或渲染。

二、第一原则:先用 Profiler 量,别瞎 memo

性能优化第一铁律:没有测量,就没有优化。 凭感觉包 memo,十有八九包错地方——你以为是 BigList 慢,实际是它外层一个无关的 Header 在每次重渲染。

React DevTools 的 Profiler 是你的第一工具。打开录制 → 触发卡顿的操作 → 停止,你会拿到一张火焰图,每个柱子代表一次渲染花了多久。重点关注:

  • ranked(排序)视图:按耗时排序,一眼看出谁是真凶。
  • why did this render:点开某个组件,看它为什么重渲染——是 props 变了?state 变了?还是父组件渲染拖累?这一步直接决定你下一步该不该 memo。
tsx
// 甚至可以埋点拿到真实耗时,只在不达标时上报
import { Profiler } from "react";

<Profiler id="search-panel" onRender={(id, phase, actualDuration) => {
  if (actualDuration > 16) {
    // 超过一帧(16.6ms),值得查
    console.warn(`${id} ${phase} 耗时 ${actualDuration.toFixed(1)}ms`);
  }
}}>
  <SearchPanel />
</Profiler>

先定位"到底是谁慢、为什么慢",再决定优化手段。 是计算贵就 memoize 计算,是树太大就拆状态,是输入卡顿就用并发特性——对症下药,而不是无脑 memo。一个粗暴但好用的判断标准:在 Profiler 里耗时不到 1ms 的组件,根本不值得花时间优化它。

三、memo / useMemo / useCallback 的真相:是优化不是默认,用错更慢

这是 React 社区最大的误区之一:把 memo、useMemo、useCallback 当成"加了就更快"的免费午餐。它们不是。 每一个都带着真实代价:

  • useMemo(fn, deps):每次渲染都要执行 deps 的浅比较、还要缓存上一次的结果——占内存,且比较本身也要花时间。
  • useCallback:本质就是 useMemo(() => fn, deps),同样的代价。
  • memo(Component):被包裹的组件每次渲染前都要对所有 props 做一次浅比较。

它们只有在"省下的开销 > 引入的开销"时才划算。更关键的是 memo 的一个硬限制:只有当所有传入的 props 引用稳定时,memo 才有效。 下面这个错误极其常见:

tsx
const MemoizedList = memo(List);

function Parent() {
  // 每次渲染都是全新函数、全新对象 → memo 的浅比较必失败 → 白包
  const handleSelect = (id: string) => {
    /* ... */
  };
  const itemConfig = { active: true, editable: false };

  return <MemoizedList onSelect={handleSelect} config={itemConfig} />;
}

handleSelect 和 itemConfig 每次都是新引用,memo(List) 的浅比较 100% 返回 false,memo 形同虚设。要让它生效,你得把这两个也用 useCallback/useMemo 包起来——但这就掉进"为了 memo 而 memo"的兔子洞了,每多一层缓存都是成本和心智负担。

正确的判断顺序是:

  1. 这个组件在 Profiler 里真的慢吗?慢才往下走,不慢就此打住。
  2. 它的重渲染真的是 props 不稳定导致的吗?(Profiler 的 why 会告诉你)
  3. 是 → 把"确实会变"的 props 用 useMemo/useCallback 稳住,或更推荐:把状态下放(见第五节),从根上让它不重渲染。

绝大多数情况下,第三条的"状态下放"比堆 memo 干净得多。memo 是优化手段,不是默认写法——当你想加 memo 的原因是"听说加了快",而不是"量出来这里慢",就别加。

四、列表与 key:别用 index,大列表要虚拟化

列表是性能问题的重灾区。两个点必须吃透:key 的语义和虚拟化。

key 不是"给 React 凑数的标识",而是 React 做 diff 时判断"这一条还是不是同一条"的依据。用 index 当 key,看起来能跑,一旦列表发生插入、删除、重排,React 就会错配——把新数据套到旧组件实例上,不仅浪费 DOM 操作(本来只需改一个节点,变成改一串),还会导致 input、动画、useState 这类有内部状态的组件状态错乱。

tsx
// 反例:index 当 key,删除中间一项后所有后续项的 DOM 都要重写、状态全乱
{items.map((item, i) => <Row key={i} data={item} />)}

// 正解:用业务上的稳定唯一 id
{items.map((item) => <Row key={item.id} data={item} />)}

规则很简单:key 必须对每条数据稳定且唯一,优先用后端给的 id。 只有在"列表只追加、永不重排、项无内部状态"的极少数场景,index 才勉强能接受。

第二个是虚拟化(windowing)。列表一旦超过几百行,把所有 DOM 节点都渲染出来本身就贵——哪怕 React diff 很快,浏览器要布局/绘制几千个 DOM 节点也扛不住。虚拟化的思路是只渲染视口内 + 少量缓冲区的节点,滚动时动态替换。

tsx
import { useVirtualizer } from "@tanstack/react-virtual";

function BigList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,      // 每行预估高度
    overscan: 5,                 // 视口外多渲染几行,滚动不闪
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((vi) => (
          <Row
            key={items[vi.index].id}
            data={items[vi.index]}
            style={{ position: "absolute", top: vi.start, height: vi.size }}
          />
        ))}
      </div>
    </div>
  );
}

一行配置的差别:从"渲染一万行 DOM"变成"渲染二三十行"。列表上千就上虚拟化,别犹豫。 @tanstack/react-virtual、react-window 都是成熟方案。

五、状态拆分与下放:把变化关进最小的笼子

回到那个经典场景:一个搜索框 + 一个大表格,输入框的 state 放在外层容器里。每敲一个字,容器重渲染,整张大表格跟着重渲染——哪怕过滤结果根本没变。

根因是:变化频繁的 state,和渲染昂贵的组件,住在同一个组件里。解法不是给表格包 memo,而是把那个频繁变化的 state 下放到真正需要它的最小组件。

tsx
// 反例:inputValue 在父组件,每次输入整棵树重渲染,大表格被无辜牵连
function Page({ rows }: { rows: Row[] }) {
  const [inputValue, setInputValue] = useState("");
  return (
    <>
      <SearchInput value={inputValue} onChange={setInputValue} />
      <HugeTable rows={rows} filter={inputValue} />
    </>
  );
}

// 正解:把 input 的 state 关进 SearchInput 自己肚子里
function Page({ rows }: { rows: Row[] }) {
  const [filter, setFilter] = useState("");
  return (
    <>
      <SearchInput onFilterChange={setFilter} />   {/* 输入只重渲染这个小组件 */}
      <HugeTable rows={rows} filter={filter} />
    </>
  );
}

判断原则:state 应该放在使用它的最低层组件,再高一层都嫌多。 高频变化(输入、动画、鼠标坐标、定时器)的 state 尤其要往下放——别让一个每帧都在变的值,挂在会牵连整页的根组件上。

这条原则能消灭掉一大半"想加 memo"的冲动。很多时候你以为需要 memo,其实只是 state 放错了位置。先把状态摆对,memo 的需求自然消失一大半。

六、并发特性:useDeferredValue / useTransition,让输入不被重活卡住

有些场景就是没法靠拆状态解决:一个搜索框,输入的值必须驱动一个昂贵的过滤/搜索计算。这时你想要的不是"别重渲染",而是**"别让这个昂贵的更新阻塞用户的输入"**。React 18 的并发特性就是干这个的。

useTransition:当你手里有那个 setter 时,用它把昂贵的更新标记为"可中断的低优先级"。输入立即刷新,重活在后台慢慢跑:

tsx
function SearchBox({ data }: { data: Item[] }) {
  const [input, setInput] = useState("");
  const [results, setResults] = useState(data);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input
        value={input}
        onChange={(e) => {
          const v = e.target.value;
          setInput(v);                              // 高优先级:输入立刻可见
          startTransition(() => {
            setResults(expensiveFilter(data, v));   // 低优先级:可被打断
          });
        }}
      />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </>
  );
}

useDeferredValue:当你拿不到 setter(值来自 props、context、或第三方 store),就推迟消费那个值:

tsx
function ResultList({ query, data }: { query: string; data: Item[] }) {
  // query 来自上层,我没法 startTransition 它,但可以"延迟消费"
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  const results = useMemo(
    () => expensiveFilter(data, deferredQuery),
    [data, deferredQuery],
  );

  return <ul style={{ opacity: isStale ? 0.6 : 1 }}>{results.map(/* ... */)}</ul>;
}

二者的区别一句话:你能控制那个 setter → useTransition;你只能拿到一个变化的值 → useDeferredValue。 它们解决的是同一类问题:把重活从"阻塞输入"的优先级降到"能被打断"的优先级,让界面始终保持响应。这是 React 18 之后处理"输入卡顿"的首选武器,远比无脑 memo 高效。

七、代码分割与 bundle:按路由懒加载,别一次加载全世界

前面讲的都偏运行时渲染,但性能还有另一半:首屏加载多少 JS。一个 SPA 把所有路由、所有重依赖(图表库、富文本编辑器、日期处理全家桶)打进一个 bundle,用户打开首页就要下载几 MB 的 JS,低端机上解析+执行就要好几秒——渲染再快也救不回来。

解法是代码分割:按路由懒加载,把用户暂时用不到的代码剥离出去,用到时再拉。

tsx
import { lazy, Suspense } from "react";

// 重依赖按需加载,不进主 bundle
const RichEditor = lazy(() => import("./RichEditor"));
const ChartPanel = lazy(() => import("./ChartPanel"));

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/editor" element={<RichEditor />} />
        <Route path="/chart" element={<ChartPanel />} />
      </Routes>
    </Suspense>
  );
}

Next.js 的 App Router 在这点上更省心——路由级和组件级默认就是代码分割的,next/dynamic 还能在 SSR 之外做更细的按需加载。无论哪个框架,定期跑一次 bundle 分析(webpack-bundle-analyzer / Next 的 @next/bundle-analyzer),看看有没有意外塞进来的巨无霸依赖,是性价比极高的优化。

几条经验:

  • 路由级分割几乎总是值得的——不同页面的代码没必要一起加载。
  • 重依赖按需加载:只在某个 tab/弹窗里用的编辑器、图表库,lazy 掉。
  • 警惕"全家桶"依赖:用到了某个库的 5% 功能却 import 了整个包,优先找 tree-shaking 友好或按需引入的替代品。
  • 关注首屏 JS 体积,而不是只看运行时——很多时候砍 bundle 比优化渲染对体感的提升更直接。

八、过早优化是万恶之源:90% 的 memo 都不必写

最后回到一个朴素的工程原则。Knuth 那句"过早优化是万恶之源"在 React 里尤其成立,因为 React 的默认渲染本来就不慢。社区里大量 useMemo/useCallback 的真实动机是"听说加了性能好""怕以后慢先包上"——没有任何测量依据。结果代码可读性下降一层(每个值都要想 deps),还引入了缓存成本和 deps 写错导致的 stale 闭包 bug,换来一个根本不存在的性能提升。

正确的姿势是:

  1. 先把代码写对、写清楚。state 放对位置、列表用稳定 key、重活放对时机——这些"结构性"正确比任何 memo 都值钱。
  2. 在真实场景里测量(Profiler + 真机/限速)。找出真正卡的那 5%。
  3. 只针对那 5% 下手。该上 memo 上 memo、该拆状态拆状态、该虚拟化虚拟化、该用并发特性就用。

这是一个**"先写对 → 再测量 → 最后针对性优化"**的闭环,而不是"上来就全局 memo"。一个写得干净的 React 组件树,在没加任何 memo 的情况下,绝大多数场景的渲染性能已经够用。memo 是你量出问题后的精确武器,不是预防性的维生素。 把它当成维生素天天吃,只会让代码越来越难维护,却换不来可感知的快。

小结

  1. React 性能问题八成是"不必要的重渲染"。重渲染本身不慢,慢的是"一次无关状态变更牵连一整棵昂贵的子树一起算、一起渲染"。优化的核心是让昂贵的部分只在它真正依赖的数据变化时才动。
  2. 第一原则是测量,不是 memo。用 React Profiler 的 ranked 视图和 "why did this render" 先定位"谁慢、为什么慢",再对症下药。耗时不到 1ms 的组件不值得优化。
  3. memo / useMemo / useCallback 是优化手段,不是默认写法。它们有比较和内存代价,且 memo 只有在所有 props 引用稳定时才有效——传个新函数或新对象进去,memo 直接失效。多数时候"状态下放"比堆 memo 更干净。
  4. 列表用稳定唯一的 key(别用 index),上千行就上虚拟化。@tanstack/react-virtual / react-window 能把"渲染万行 DOM"降成"渲染二三十行"。
  5. 并发特性处理"输入被重活卡住":useTransition(你持有 setter)和 useDeferredValue(你只拿到变化的值)把昂贵更新降到可中断的低优先级;配合路由级代码分割控制首屏 JS 体积。

金句:React 默认就不慢,慢的是你把变化关错了笼子。 先把代码写对、把 state 放到最小必要的位置,再用 Profiler 量出真正的瓶颈——memo 是量完之后的精确手术刀,不是张口就来的维生素。

Related

相关推荐