React 性能优化实战
React 应用卡,八成是不必要的重渲染。本文讲清性能优化的正确顺序:先测量(Profiler)再优化、memo/useMemo/useCallback 的真实作用与误用、key 与列表性能、状态拆分、并发特性(useDeferredValue/useTransition),以及"过早优化是万恶之源"在 React 里的体现。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
你的 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。真正贵的是两类:
- 渲染过程里跑了重计算(大列表 filter/sort、复杂格式化、运行时编译);
- 渲染触发了真实的 DOM 操作(大量节点重排重绘、强制 reflow)。
所以问题从来不是"React 渲染了",而是**"一次无关的状态变更,牵连着一棵昂贵的组件树一起重渲染"**。一个搜索框输入一个字,整页一千行的表格跟着重算了一遍——这才是卡的根源。优化的核心思路就一句:让昂贵的部分只在它真正依赖的数据变化时才重新计算或渲染。
二、第一原则:先用 Profiler 量,别瞎 memo
性能优化第一铁律:没有测量,就没有优化。 凭感觉包 memo,十有八九包错地方——你以为是 BigList 慢,实际是它外层一个无关的 Header 在每次重渲染。
React DevTools 的 Profiler 是你的第一工具。打开录制 → 触发卡顿的操作 → 停止,你会拿到一张火焰图,每个柱子代表一次渲染花了多久。重点关注:
- ranked(排序)视图:按耗时排序,一眼看出谁是真凶。
- why did this render:点开某个组件,看它为什么重渲染——是 props 变了?state 变了?还是父组件渲染拖累?这一步直接决定你下一步该不该 memo。
// 甚至可以埋点拿到真实耗时,只在不达标时上报
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 才有效。 下面这个错误极其常见:
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"的兔子洞了,每多一层缓存都是成本和心智负担。
正确的判断顺序是:
- 这个组件在 Profiler 里真的慢吗?慢才往下走,不慢就此打住。
- 它的重渲染真的是 props 不稳定导致的吗?(Profiler 的 why 会告诉你)
- 是 → 把"确实会变"的 props 用
useMemo/useCallback稳住,或更推荐:把状态下放(见第五节),从根上让它不重渲染。
绝大多数情况下,第三条的"状态下放"比堆 memo 干净得多。memo 是优化手段,不是默认写法——当你想加 memo 的原因是"听说加了快",而不是"量出来这里慢",就别加。
四、列表与 key:别用 index,大列表要虚拟化
列表是性能问题的重灾区。两个点必须吃透:key 的语义和虚拟化。
key 不是"给 React 凑数的标识",而是 React 做 diff 时判断"这一条还是不是同一条"的依据。用 index 当 key,看起来能跑,一旦列表发生插入、删除、重排,React 就会错配——把新数据套到旧组件实例上,不仅浪费 DOM 操作(本来只需改一个节点,变成改一串),还会导致 input、动画、useState 这类有内部状态的组件状态错乱。
// 反例: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 节点也扛不住。虚拟化的思路是只渲染视口内 + 少量缓冲区的节点,滚动时动态替换。
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 下放到真正需要它的最小组件。
// 反例: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 时,用它把昂贵的更新标记为"可中断的低优先级"。输入立即刷新,重活在后台慢慢跑:
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),就推迟消费那个值:
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,低端机上解析+执行就要好几秒——渲染再快也救不回来。
解法是代码分割:按路由懒加载,把用户暂时用不到的代码剥离出去,用到时再拉。
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,换来一个根本不存在的性能提升。
正确的姿势是:
- 先把代码写对、写清楚。state 放对位置、列表用稳定 key、重活放对时机——这些"结构性"正确比任何 memo 都值钱。
- 在真实场景里测量(Profiler + 真机/限速)。找出真正卡的那 5%。
- 只针对那 5% 下手。该上 memo 上 memo、该拆状态拆状态、该虚拟化虚拟化、该用并发特性就用。
这是一个**"先写对 → 再测量 → 最后针对性优化"**的闭环,而不是"上来就全局 memo"。一个写得干净的 React 组件树,在没加任何 memo 的情况下,绝大多数场景的渲染性能已经够用。memo 是你量出问题后的精确武器,不是预防性的维生素。 把它当成维生素天天吃,只会让代码越来越难维护,却换不来可感知的快。
小结
- React 性能问题八成是"不必要的重渲染"。重渲染本身不慢,慢的是"一次无关状态变更牵连一整棵昂贵的子树一起算、一起渲染"。优化的核心是让昂贵的部分只在它真正依赖的数据变化时才动。
- 第一原则是测量,不是 memo。用 React Profiler 的 ranked 视图和 "why did this render" 先定位"谁慢、为什么慢",再对症下药。耗时不到 1ms 的组件不值得优化。
- memo / useMemo / useCallback 是优化手段,不是默认写法。它们有比较和内存代价,且
memo只有在所有 props 引用稳定时才有效——传个新函数或新对象进去,memo 直接失效。多数时候"状态下放"比堆 memo 更干净。 - 列表用稳定唯一的 key(别用 index),上千行就上虚拟化。
@tanstack/react-virtual/react-window能把"渲染万行 DOM"降成"渲染二三十行"。 - 并发特性处理"输入被重活卡住":
useTransition(你持有 setter)和useDeferredValue(你只拿到变化的值)把昂贵更新降到可中断的低优先级;配合路由级代码分割控制首屏 JS 体积。
金句:React 默认就不慢,慢的是你把变化关错了笼子。 先把代码写对、把 state 放到最小必要的位置,再用 Profiler 量出真正的瓶颈——memo 是量完之后的精确手术刀,不是张口就来的维生素。
Related