React 性能优化最常见的误区,是一上手就给所有组件套 React.memo、给所有函数包 useCallback。结果代码变丑了,页面还是卡。真正的问题几乎总在别处:不必要的重渲染、写错依赖的 useEffect、以及把高频状态放得太靠上。这篇文章按”测量 → 定位 → 结构性优化 → 兜底记忆化”的顺序,把 React 18/19 环境下可落地的优化手段讲透,代码均可直接运行。
React 到底慢在哪:三类真实诱因
用户口中的”卡”,在 React 里通常对应三种完全不同的病灶,治法也完全不同。先分清病灶,再谈手段:
| 症状 | 真实诱因 | 正确解法 | 无效解法 |
|---|---|---|---|
| 打字/滚动一顿一顿 | 高频状态放在顶层组件,每次输入触发整棵子树重渲染 | 状态下推、useDeferredValue | 给叶子组件加 memo |
| 列表操作明显掉帧 | 一次渲染上千个 DOM 节点,或 key 用了数组索引 | 虚拟列表、稳定 key | useMemo 缓存列表数据 |
| 首屏白屏久、交互延迟 | 包体过大、请求瀑布流,属于加载性能而非渲染性能 | 代码分割、并行请求 | 任何记忆化 API |
第三类问题本质是加载性能,跟 Hooks 无关,处理思路可以看站内这篇前端性能优化:Lighthouse 90+ 到首屏 1s。本文聚焦前两类,也就是运行时渲染性能。
先测量:别靠猜找瓶颈
优化前必须有数据。React DevTools 的 Profiler 面板打开”Record why each component rendered”后,能直接告诉你某个组件是因为 props、state 还是 context 变化而重渲染。想把数据留在代码里长期观测,可以用内置的 Profiler 组件:
import { Profiler } from 'react';
function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
// phase: 'mount' | 'update' | 'nested-update'
// actualDuration: 本次提交实际耗时;baseDuration: 不做任何优化的预估耗时
if (actualDuration > 16) {
console.warn('[slow render]', id, phase, actualDuration.toFixed(2) + 'ms');
// 生产环境可上报到监控平台,只采样 1% 避免打爆日志
}
}
export default function Page() {
return (
<Profiler id="OrderList" onRender={onRender}>
<OrderList />
</Profiler>
);
}
两个判断基线记住就够用:单次提交超过 16ms 就可能掉帧;如果 actualDuration 长期贴近 baseDuration,说明你的记忆化根本没生效。把这类数据接进线上监控体系的做法,可以参考前端性能监控:Web Vitals与Sentry实战。
useEffect 的四个经典陷阱
陷阱一:依赖漏项导致闭包过期
最高频的 bug:依赖数组写了 [],副作用里却读了会变的 state,于是永远读到首次渲染那一份”冻住”的值。计数器停在 1、轮询永远用旧参数,都是这个原因。
// ❌ 反例:count 被闭包冻结在初始值,页面永远显示 1
useEffect(() => {
const t = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(t);
}, []);
// ✅ 正解一:用函数式更新,彻底不依赖外部 count
useEffect(() => {
const t = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(t);
}, []);
// ✅ 正解二:依赖会变的对象时,只依赖原始值而非整个对象
useEffect(() => {
fetchDetail(user.id);
}, [user.id]); // 而不是 [user],否则父组件每次渲染新对象都会重跑
陷阱二:用 useEffect 同步派生状态
“A 变了就 setB”这种写法会多触发一轮渲染,而且中间态可能被用户看到。凡是能由现有 state 算出来的值,直接在渲染期间算,不要放进 state:
// ❌ 反例:两次渲染 + 状态可能不一致
const [list, setList] = useState([]);
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(list.reduce((s, i) => s + i.price, 0)); }, [list]);
// ✅ 正解:派生值直接算,昂贵才用 useMemo 包
const total = list.reduce((s, i) => s + i.price, 0);
// 上千条且计算复杂时:
const total2 = useMemo(() => heavySum(list), [list]);
陷阱三:请求竞态没有清理
搜索框快速输入时,先发的请求可能后返回,把新结果覆盖成旧数据。清理函数里必须中断或标记失效,这也是 StrictMode 开发环境故意双调用 effect 的用意——帮你暴露没写清理的副作用。
useEffect(() => {
const ctrl = new AbortController();
(async () => {
try {
const res = await fetch(`/api/search?q=${keyword}`, { signal: ctrl.signal });
setData(await res.json());
} catch (e) {
if (e.name !== 'AbortError') setError(e);
}
})();
return () => ctrl.abort(); // 关键:卸载或依赖变化时中断上一次请求
}, [keyword]);
陷阱四:能在事件里做的事塞进了 effect
表单提交后弹提示、上报埋点,这些由”用户动作”触发的逻辑应该写在事件处理函数里,而不是监听 state 变化。放进 effect 会导致状态回填、页面初始化时也误触发。判断标准很简单:这段代码是同步外部系统(订阅、DOM、定时器),还是响应一次用户操作?后者不该用 effect。
记忆化三件套:选型比使用更重要
| API | 缓存对象 | 该用的时机 | 常见滥用 |
|---|---|---|---|
React.memo | 组件渲染结果 | 子树较大、props 稳定且父组件高频重渲染 | 包在只有几个 DOM 的叶子组件上 |
useMemo | 计算结果 | 计算确实昂贵,或结果要作为 memo 组件的 props | 缓存 a + b 这类瞬时计算 |
useCallback | 函数引用 | 函数传给 memo 子组件或作为其他 Hook 的依赖 | 给普通 DOM 的 onClick 也包一层 |
关键认知:React.memo 只在 props 浅比较相等时才跳过渲染。父组件里现场写的对象字面量、箭头函数每次都是新引用,会让 memo 完全失效——所以 memo 必须和 useCallback/useMemo 成套使用,单独用往往白写。
const Row = React.memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
function List({ items }) {
const [selected, setSelected] = useState(null);
// ✅ 引用稳定,Row 的 memo 才有意义
const onSelect = useCallback((id) => setSelected(id), []);
// ❌ 若写成 style={{ margin: 8 }},每次都是新对象,memo 立即失效
return <ul>{items.map(i => <Row key={i.id} item={i} onSelect={onSelect} />)}</ul>;
}
比记忆化更有效:结构性优化
大多数卡顿不需要记忆化,只需要把状态放对位置。状态下推指把只有局部用到的 state 移到真正使用它的组件内部,这样输入时重渲染范围就从整页缩小到一个输入框;内容提升则是把不依赖该 state 的子树通过 children 传进来,让它在父组件重渲染时保持同一份 element 引用而被跳过。
// ❌ keyword 放在页面顶层:每敲一个字,ExpensiveChart 也跟着重渲染
function Page() {
const [keyword, setKeyword] = useState('');
return (<><input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ExpensiveChart /></>);
}
// ✅ 状态下推:搜索状态收进 SearchBox,图表不再受影响
function Page() {
return (<><SearchBox /><ExpensiveChart /></>);
}
// ✅ 内容提升:无法下推时,把静态子树当 children 传入
function Panel({ children }) {
const [open, setOpen] = useState(false);
return (<div><button onClick={() => setOpen(!open)}>toggle</button>{open && children}</div>);
}
另外要提醒的是 Context:同一个 Provider 的 value 变化会让所有消费者重渲染。把”高频变动的值”和”低频配置”拆成两个 Context,是比记忆化更彻底的做法。这一点上 Vue 生态的 store 粒度设计思路很值得对照,可以看前端状态管理实战:Pinia 进阶与最佳实践,两个框架在”细粒度订阅”上的取舍差异,在2026 前端框架对比:React vs Vue vs Svelte 怎么选里也有对比。
高频输入与长列表:并发特性怎么用
React 18 起提供的并发能力,专门治”输入被昂贵渲染拖住”这类问题。useDeferredValue 让昂贵子树用旧值先撑住,输入框保持即时响应;useTransition 则把切换标签、跳分页这种非紧急更新标记为可中断。
function SearchPage({ allItems }) {
const [keyword, setKeyword] = useState('');
const deferred = useDeferredValue(keyword); // 滞后值
const list = useMemo(
() => allItems.filter(i => i.name.includes(deferred)),
[allItems, deferred]
);
const stale = keyword !== deferred; // 可用于显示"计算中"
return (
<>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<div style={{ opacity: stale ? 0.6 : 1 }}><ResultList data={list} /></div>
</>
);
}
// 非紧急更新:切换视图时不阻塞点击反馈
const [isPending, startTransition] = useTransition();
startTransition(() => setTab(next));
但要清楚:并发特性只是让渲染可中断、可延后,并不会让计算变快。一次要渲染上千行时,正确答案是虚拟化——只渲染视口内的十几行,DOM 节点数从数千降到几十,这才是量级上的改善。react-window、@tanstack/react-virtual 都是成熟选择。同时别忘了 key:用数组索引当 key,删除中间项会让后面所有行的状态错位,必须用业务唯一 ID。
React 编译器:以后能少写多少记忆化
React 官方推出的编译器(React Compiler)会在构建期自动分析组件,把该缓存的值和组件自动记忆化,目标就是让开发者不必再手写满屏的 useMemo/useCallback。接入方式是加一个 Babel 插件(版本迭代较快,具体版本号以官方文档为准):
// vite.config.js —— 启用 React 编译器(需先安装 babel-plugin-react-compiler)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: { plugins: [['babel-plugin-react-compiler', {}]] },
}),
],
});
需要注意两点:一是编译器依赖代码遵守 React 规则(不在渲染期间改变 props/state、不写副作用),存量项目建议先用 ESLint 规则扫一遍再开;二是它替代的是”手写记忆化”,本文讲的状态下推、虚拟化、Context 拆分这些结构性优化,编译器一个都替不了。构建链路的接入差异可以对照前端构建工具演进:Webpack → Vite → Rspack 横评。
落地清单:按这个顺序做
- 先量:Profiler 录制交互,找出
actualDuration> 16ms 的组件,别凭感觉动手。 - 查 effect:依赖数组是否漏项、是否在同步派生状态、异步是否有清理函数。
- 调结构:状态下推、内容提升、Context 按变更频率拆分——收益最大且不增加代码复杂度。
- 治列表:稳定 key + 虚拟化,超过 200 行就该考虑。
- 用并发:高频输入配
useDeferredValue,视图切换配useTransition。 - 最后才记忆化:memo 与 useCallback 成套使用,改完必须回到第 1 步复测。
- 加回归防线:把关键交互的渲染耗时写成断言,纳入 CI,参考前端自动化测试:Vitest 与 Playwright 实战。
一句话总结:React 性能优化的顺序是”测量 → 改结构 → 用并发 → 最后记忆化”。把状态放对位置,比包一百个 useMemo 都管用;而没有测量的优化,只是在给代码增加维护成本。




