React 性能优化最佳实践
React 性能优化最佳实践
React 应用的性能直接影响用户体验,性能优化是前端开发中不可忽视的重要环节。React 提供了多种优化手段,从渲染优化、状态管理到代码分割,合理运用这些技术可以显著提升应用的响应速度和流畅度。
本文会从"为什么会卡"这个根本问题出发,逐步展开 React 的渲染机制、各类优化 API 的原理与正确用法、React 18 引入的并发特性、代码分割与虚拟列表等工程手段,并配合大量可运行代码、真实场景案例与优化前后的性能对比数据,帮助你建立一套系统化的性能优化方法论。
先理解:React 为什么会卡
在谈优化之前,必须先理解性能问题的来源。可以把浏览器每一帧的工作想象成一条流水线:JavaScript 执行 → 样式计算 → 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。浏览器希望每秒完成 60 帧,也就是每帧只有大约 16.6 毫秒的预算。一旦某一帧里 JavaScript 执行时间过长(比如一次渲染算了 50 毫秒),这一帧就会"掉帧",用户看到的就是卡顿、输入延迟、滚动不跟手。
React 的工作是把组件树转换成真实 DOM。当状态变化时,React 会重新执行相关组件的渲染函数(render),生成新的虚拟 DOM 树,再通过 diff 算法找出差异,最后把差异应用到真实 DOM 上。这个过程分两个阶段:
性能问题的根源通常有三类:
理解了这三类,后面所有优化手段都可以对号入座:React.memo / useMemo / useCallback 主要解决"渲染过于频繁";虚拟列表、代码分割解决"单次渲染太重";useTransition / useDeferredValue / Web Worker 解决"主线程被长任务占据"。
一个关键前提:默认的重渲染规则
很多人误以为"父组件渲染只会更新变化的子组件",这是错的。React 的默认规则是:当一个组件重新渲染时,它的所有子组件默认都会重新渲染,无论 props 是否变化。这是设计上的取舍——React 假设 render 是廉价的,用"全量重算 + diff"换取心智负担的降低。
也正因如此,性能优化的很多工作就是"打破这条默认规则":用 React.memo 让子组件在 props 不变时跳过 render。理解这一点,你才能明白为什么 useMemo/useCallback 经常要和 React.memo 搭配使用——单独用 useCallback 缓存函数、但子组件没被 memo 包裹,是完全无效的。
渲染优化核心策略
React.memo - 组件级缓存:
React.memo 是一个高阶组件,用于缓存函数组件的渲染结果。当组件接收相同的 props 时,React.memo 会跳过渲染,直接复用上次的结果。React.memo 接受一个可选的比较函数作为第二个参数,可以自定义 props 比较逻辑。需要注意的是,React.memo 只进行浅比较(shallow compare),如果 props 包含对象或数组,需要确保引用稳定性。React.memo 适用于纯展示组件、接收复杂 props 的组件、以及渲染开销较大的组件。
可以把 React.memo 类比成一道"门卫":每次父组件想让子组件重渲染时,门卫会先比较这次的 props 和上次的 props。如果一模一样(浅比较相等),门卫就拦下这次渲染,直接返回上次的结果;只有 props 真的变了,才放行执行 render。
useMemo 和 useCallback - 值和函数缓存:
useMemo 用于缓存计算结果,避免在每次渲染时重复执行昂贵的计算。useCallback 用于缓存函数引用,避免在每次渲染时创建新的函数实例。两者都接受依赖数组,只有当依赖项变化时才重新计算。它们本质上是同一个东西:useCallback(fn, deps) 完全等价于 useMemo(() => fn, deps)。useMemo 和 useCallback 本身也有开销(要保存缓存、比较依赖),应该根据实际场景权衡使用。对于简单的计算或不需要传递给子组件的函数,可能不需要缓存。
避免渲染中的对象和函数创建:
在组件渲染中直接创建对象或函数会导致每次渲染都创建新的引用,这会破坏 React.memo 和 useEffect 的依赖检查。解决方案包括:将静态对象和函数移到组件外部、使用 useMemo 缓存对象、使用 useCallback 缓存函数、使用状态管理库管理复杂状态。
为什么这些优化很重要:一组直观数据
下面这组数据来自一个典型的中后台管理系统(表格 + 筛选 + 图表),在优化前后用 React Profiler 与 Chrome Performance 面板测得的量级参考值:
| 场景 | 优化前 | 优化后 | 手段 |
| --- | --- | --- | --- |
| 输入筛选框每敲一个字,组件渲染次数 | 约 1200 次 | 约 40 次 | React.memo + useMemo |
| 一万行表格首次渲染耗时 | 约 1800 毫秒 | 约 90 毫秒 | 虚拟列表 |
| 一万行表格 DOM 节点数 | 约 60000 个 | 约 400 个 | 虚拟列表 |
| 大列表筛选时的输入延迟 | 约 300 毫秒 | 小于 16 毫秒 | useDeferredValue |
| 首屏 JS bundle 体积 | 约 1400 KB | 约 380 KB | 路由级代码分割 |
| 首屏可交互时间 TTI | 约 4200 毫秒 | 约 1500 毫秒 | 代码分割 + 懒加载 |
| 复杂图表拖动时的帧率 FPS | 约 22 FPS | 约 58 FPS | React.memo + Web Worker |
这些数字会随硬件与数据规模变化,但量级上的差距是真实且稳定的——正确的优化往往能带来一个数量级的提升,而不是零点几个百分点。
基础优化代码示例
import React, { useState, useMemo, useCallback, useEffect, Suspense } from 'react';
// React.memo 基础用法
const MemoizedComponent = React.memo(function UserCard({ user, onSelect }) {
return (
<div className="user-card" onClick={() => onSelect(user.id)}>
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});
// 自定义比较函数:仅当关心的字段变化时才重渲染
const UserCard = React.memo(
function UserCard({ user, onSelect }) {
return <div onClick={() => onSelect(user.id)}>{user.name}</div>;
},
(prevProps, nextProps) => {
// 返回 true 表示"相等",跳过重新渲染
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
}
);
// useMemo 缓存计算结果
function UserList({ users, filterText }) {
// 只有当 users 或 filterText 变化时才重新过滤
const filteredUsers = useMemo(() => {
console.log('Filtering users...');
return users.filter(user =>
user.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [users, filterText]);
// 缓存排序结果,避免每次渲染都重新排序
const sortedUsers = useMemo(() => {
return [...filteredUsers].sort((a, b) => a.name.localeCompare(b.name));
}, [filteredUsers]);
return (
<ul>
{sortedUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// useCallback 缓存函数
function ParentComponent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// 缓存事件处理函数,使传给 memo 子组件的引用保持稳定
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
// 缓存带参数的回调
const handleItemDelete = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
// 缓存复杂对象,避免每次渲染生成新引用
const config = useMemo(() => ({
theme: 'dark',
locale: 'zh-CN',
onAction: handleClick
}), [handleClick]);
return (
<div>
<ChildComponent onClick={handleClick} config={config} />
<ItemList items={items} onDelete={handleItemDelete} />
</div>
);
}useMemo / useCallback 的正确与错误对比
useMemo 和 useCallback 是被误用最多的两个 API。核心原则只有一句:只有当缓存的值最终会被"依赖引用"的地方消费时,缓存才有意义。所谓"依赖引用"的地方,指的是 React.memo 子组件的 props、useEffect / useMemo / useCallback 的依赖数组。如果一个函数只是在本组件内部调用、不传给别人,缓存它几乎没有收益,反而增加了内存和比较开销。
// ❌ 错误一:useCallback 有了,但子组件没被 memo,等于白缓存
function BadCase1() {
const [n, setN] = useState(0);
// 花了成本缓存 handleClick,但 PlainButton 每次父级渲染都会重渲染
const handleClick = useCallback(() => console.log('click'), []);
return <PlainButton onClick={handleClick} />; // PlainButton 未用 React.memo
}
// ❌ 错误二:依赖数组写错,闭包捕获旧值(stale closure)
function BadCase2({ query }) {
const [results, setResults] = useState([]);
// 依赖数组漏了 query,回调永远拿到首次渲染的 query
const search = useCallback(() => {
fetch('/api?q=' + query).then(r => r.json()).then(setResults);
}, []); // 应为 [query]
return <SearchButton onSearch={search} />;
}
// ❌ 错误三:给极廉价的计算加 useMemo,得不偿失
function BadCase3({ a, b }) {
// a + b 本身就是纳秒级操作,useMemo 的比较与缓存开销更大
const sum = useMemo(() => a + b, [a, b]);
return <div>{sum}</div>;
}
// ✅ 正确一:memo 子组件 + useCallback 稳定引用,配合才生效
const FancyButton = React.memo(function FancyButton({ onClick }) {
console.log('FancyButton render');
return <button onClick={onClick}>提交</button>;
});
function GoodCase1() {
const [n, setN] = useState(0);
const handleClick = useCallback(() => console.log('submit'), []);
return (
<div>
<span>{n}</span>
<button onClick={() => setN(n + 1)}>+1</button>
{/* 点 +1 时 FancyButton 不再重渲染 */}
<FancyButton onClick={handleClick} />
</div>
);
}
// ✅ 正确二:useMemo 缓存真正昂贵的计算
function GoodCase2({ rows }) {
const stats = useMemo(() => {
// 假设 rows 有十万条,聚合计算耗时明显
let total = 0, max = -Infinity;
for (const r of rows) {
total += r.amount;
if (r.amount > max) max = r.amount;
}
return { total, avg: total / rows.length, max };
}, [rows]);
return <SummaryBar stats={stats} />;
}
// ✅ 正确三:把不依赖 props/state 的常量移到组件外,根本不需要 useMemo
const STATIC_CONFIG = { theme: 'dark', pageSize: 20 };
function GoodCase3() {
return <Table config={STATIC_CONFIG} />;
}React 18 并发特性:useTransition 与 useDeferredValue
React 18 最重要的能力是并发渲染(Concurrent Rendering)。它让 React 可以在 render 阶段"暂停、恢复、放弃"一次渲染,从而把渲染任务标记为"紧急"或"非紧急"。紧急更新(如输入框打字、点击)优先响应,非紧急更新(如根据输入过滤大列表)可以被打断,让位给用户交互。这从根本上解决了"主线程被长任务占据导致输入卡顿"的问题。
useTransition 用于把一段状态更新标记为"过渡(transition)",即非紧急。它返回 isPending 标志和 startTransition 函数。被 startTransition 包裹的更新,会以低优先级渲染,不会阻塞紧急更新。
useDeferredValue 用于生成一个"滞后版本"的值。当值频繁变化时,UI 中依赖这个延迟值的重活可以滞后处理,用户输入始终保持流畅。它可以理解为 React 内建的、可被打断的"防抖",但比传统防抖更聪明——它不是固定延迟,而是根据设备繁忙程度自适应。
import React, { useState, useTransition, useDeferredValue, useMemo } from 'react';
// 场景:一个搜索框,输入后要过滤并渲染一个上万项的大列表
// 问题:不优化时,每次打字都要过滤+渲染上万项,输入严重卡顿
// 方案 A:useTransition —— 把"过滤大列表"标记为非紧急
function SearchWithTransition({ allItems }) {
const [query, setQuery] = useState('');
const [list, setList] = useState(allItems);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
// 紧急更新:输入框必须立刻响应
setQuery(value);
// 非紧急更新:大列表过滤可以被打断
startTransition(() => {
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setList(filtered);
});
};
return (
<div>
<input value={query} onChange={handleChange} placeholder="搜索..." />
{isPending && <span className="hint">更新中...</span>}
<BigList items={list} />
</div>
);
}
// 方案 B:useDeferredValue —— 更简洁,直接延迟消费值
function SearchWithDeferred({ allItems }) {
const [query, setQuery] = useState('');
// deferredQuery 会在设备空闲时才追上 query
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(() => {
return allItems.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [allItems, deferredQuery]);
// 输入是否"落后于"渲染,可用来显示加载态
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<BigList items={filtered} />
</div>
</div>
);
}useTransition 与 useDeferredValue 如何选择? 如果你能控制那次状态更新的调用点(比如自己写 setState),用 useTransition 更直接;如果值来自 props 或上层、你无法包裹它的更新,就用 useDeferredValue 在消费侧做延迟。二者可以并存。
下面是一个真实场景的对比数据,来自一个包含两万条商品的搜索页:
| 指标 | 无优化 | useDeferredValue | useTransition |
| --- | --- | --- | --- |
| 输入单字符延迟 | 约 280 毫秒 | 小于 16 毫秒 | 小于 16 毫秒 |
| 列表更新到位耗时 | 约 280 毫秒 | 约 200 毫秒(可打断) | 约 200 毫秒(可打断) |
| 打字期间掉帧 | 严重 | 几乎无 | 几乎无 |
| 代码侵入性 | 低 | 低 | 中(需包裹更新) |
代码分割:让首屏只加载它真正需要的东西
代码分割(Code Splitting) 的核心思想是:不要把整个应用打包成一个巨大的 JS 文件让用户一次性下载,而是按需拆分,用户访问到哪个页面/功能,才加载对应的代码块(chunk)。这是优化首屏体积、TTI(可交互时间)、LCP(最大内容绘制)最有效的手段之一。
React 提供 React.lazy 配合 Suspense 实现组件级别的懒加载。构建工具(Webpack、Vite、Rollup)看到动态 import 语句时,会自动把这部分代码拆成独立 chunk。
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
// 路由级代码分割:每个页面单独打包,访问才下载
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Report = lazy(() => import('./pages/Report'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/report" element={<Report />} />
</Routes>
</Suspense>
);
}
// 组件级懒加载:重型组件(如富文本编辑器、图表库)按需加载
const HeavyEditor = lazy(() => import('./HeavyRichTextEditor'));
function ArticlePage() {
const [editing, setEditing] = useState(false);
return (
<div>
<button onClick={() => setEditing(true)}>编辑</button>
{editing && (
<Suspense fallback={<div>编辑器加载中...</div>}>
<HeavyEditor />
</Suspense>
)}
</div>
);
}
// 预加载(preload):在用户可能点击前提前加载,消除等待
const heavyImport = () => import('./HeavyRichTextEditor');
const HeavyEditorPreload = lazy(heavyImport);
function PreloadExample() {
return (
<button
// 鼠标悬停时就开始下载,点击时几乎无等待
onMouseEnter={() => { heavyImport(); }}
onClick={() => {/* 打开编辑器 */}}
>
打开编辑器
</button>
);
}某电商后台做完路由级代码分割后的对比数据:
| 指标 | 单包打包 | 路由分割后 |
| --- | --- | --- |
| 首屏 JS 体积 | 1420 KB | 385 KB |
| 首屏 JS gzip 后 | 410 KB | 118 KB |
| TTI 可交互时间 | 4200 毫秒 | 1500 毫秒 |
| LCP 最大内容绘制 | 3100 毫秒 | 1200 毫秒 |
| chunk 数量 | 1 个 | 14 个 |
需要注意:分割粒度不是越细越好,过多的小 chunk 会带来大量 HTTP 请求和网络往返开销。一般以路由为单位分割,再对超大的第三方库(图表、编辑器、地图)单独懒加载即可。
虚拟列表:长列表渲染的终极方案
当你要渲染一个上万行的表格或列表时,即使每一行都用了 React.memo,DOM 里真实存在上万个节点本身就会拖垮浏览器——布局、绘制、内存都扛不住。虚拟列表(Virtualization / Windowing) 的思路是:无论数据有多少条,只渲染当前视口(viewport)内可见的那几十条,配合滚动动态替换内容,让 DOM 节点数始终维持在一个很小的常量级别。
可以类比成公交车站的电子屏:站台后面排着一万人的队伍,但屏幕上永远只显示当前窗口能看到的十几个人,滚动时快速替换显示内容,用户感觉队伍很长,实际渲染的永远只有一屏。
react-window 是目前最轻量常用的虚拟列表库(react-virtualized 的作者精简重写版)。
import React, { useCallback } from 'react';
import { FixedSizeList, VariableSizeList } from 'react-window';
// 定高列表:每行高度相同,最简单高效
function VirtualizedList({ items }) {
// Row 必须用 style,react-window 通过绝对定位实现虚拟滚动
const Row = useCallback(({ index, style }) => (
<div style={style} className="row">
<span>{index}</span>
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
), [items]);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={48}
overscanCount={5} // 视口外额外渲染 5 行,滚动更顺滑
>
{Row}
</FixedSizeList>
);
}
// 不定高列表:每行高度不同(如聊天气泡、动态内容)
function VariableList({ messages }) {
const getItemSize = useCallback(
(index) => (messages[index].long ? 120 : 60),
[messages]
);
const Row = useCallback(({ index, style }) => (
<div style={style}>{messages[index].text}</div>
), [messages]);
return (
<VariableSizeList
height={600}
width="100%"
itemCount={messages.length}
itemSize={getItemSize}
>
{Row}
</VariableSizeList>
);
}
// 结合无限滚动加载更多
function InfiniteList({ items, loadMore, hasMore }) {
const Row = useCallback(({ index, style }) => {
if (index === items.length) {
return <div style={style}>加载中...</div>;
}
return <div style={style}>{items[index].name}</div>;
}, [items]);
const handleItemsRendered = ({ visibleStopIndex }) => {
// 快滚到底部时预加载下一页
if (hasMore && visibleStopIndex >= items.length - 5) {
loadMore();
}
};
return (
<FixedSizeList
height={600}
width="100%"
itemCount={hasMore ? items.length + 1 : items.length}
itemSize={48}
onItemsRendered={handleItemsRendered}
>
{Row}
</FixedSizeList>
);
}一个十万行数据表格的虚拟化前后对比:
| 指标 | 全量渲染 | 虚拟列表 |
| --- | --- | --- |
| 实际 DOM 节点数 | 约 300000 个 | 约 500 个 |
| 首次渲染耗时 | 约 6500 毫秒(几乎卡死) | 约 60 毫秒 |
| 滚动帧率 FPS | 约 8 FPS | 约 58 FPS |
| 内存占用 | 约 480 MB | 约 45 MB |
| 页面能否响应交互 | 否 | 是 |
虚拟列表适用于数据量大于 100 条、且每项渲染有一定成本的场景。缺点是:无法用浏览器原生 Ctrl+F 搜索到未渲染的内容、SEO 不友好、实现锚点跳转较复杂——需按业务权衡。
用 Web Worker 卸载计算:给主线程减负
前面的并发特性解决的是"让 React 渲染可被打断",但如果卡顿来自一段纯 JavaScript 大计算(比如解析 10 MB 的 CSV、对十万条数据做复杂聚合、图像处理),这段计算无论如何都会占用主线程。此时最彻底的方案是把计算搬到 Web Worker——一个独立的后台线程,算完再把结果传回主线程,主线程始终保持流畅可交互。
// worker.js —— 运行在独立线程
self.onmessage = function (e) {
const { rows } = e.data;
// 假设这是一段耗时数百毫秒的重计算
const result = rows
.filter(r => r.active)
.map(r => ({ id: r.id, score: heavyScore(r) }))
.sort((a, b) => b.score - a.score);
self.postMessage(result);
};
function heavyScore(r) {
let s = 0;
for (let i = 0; i < 5000; i++) {
s += Math.sqrt(r.value * i) % 7;
}
return s;
}// 在 React 组件里使用 Web Worker
import React, { useState, useEffect, useRef, useCallback } from 'react';
function useWorker(workerFactory) {
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = workerFactory();
return () => workerRef.current && workerRef.current.terminate();
}, [workerFactory]);
return workerRef;
}
function HeavyComputePage({ rows }) {
const [result, setResult] = useState([]);
const [computing, setComputing] = useState(false);
const workerRef = useWorker(
useCallback(() => new Worker(new URL('./worker.js', import.meta.url)), [])
);
const runCompute = useCallback(() => {
const worker = workerRef.current;
if (!worker) return;
setComputing(true);
worker.onmessage = (e) => {
setResult(e.data);
setComputing(false);
};
// 主线程只负责发数据、收结果,重活在 worker 里跑
worker.postMessage({ rows });
}, [rows, workerRef]);
return (
<div>
<button onClick={runCompute} disabled={computing}>
{computing ? '计算中(页面依然流畅)...' : '开始计算'}
</button>
<ResultTable data={result} />
</div>
);
}对比数据:对十万条数据做一次复杂聚合排序。
| 方案 | 计算耗时 | 计算期间主线程 | 用户能否交互 |
| --- | --- | --- | --- |
| 主线程直接算 | 约 850 毫秒 | 完全阻塞 | 否,页面冻结 |
| useTransition | 约 850 毫秒 | 可被打断 | 部分可交互 |
| Web Worker | 约 850 毫秒 | 空闲 | 完全流畅 |
性能测量:用 Profiler 找到真正的瓶颈
优化的第一原则是:先测量,再优化。凭直觉猜"这里可能慢"往往南辕北辙。React 提供了两种测量工具。
其一是 React DevTools 的 Profiler 面板(图形化),可以录制一段交互,看到每个组件渲染了几次、各耗时多少、为什么渲染(props/state/hook 变化)。其二是代码里的
import React, { Profiler } from 'react';
// onRender 回调会在每次被包裹的子树 commit 后触发
function onRenderCallback(
id, // Profiler 的标识
phase, // "mount"(首次挂载)或 "update"(重渲染)
actualDuration,// 本次渲染耗时(含子孙)
baseDuration, // 不含 memo 优化时的预估耗时
startTime, // 本次渲染开始时间
commitTime // 本次 commit 时间
) {
if (actualDuration > 16) {
console.warn(
\`[慢渲染] \${id} (\${phase}) 耗时 \${actualDuration.toFixed(1)}ms\`
);
}
}
function App() {
return (
<Profiler id="UserDashboard" onRender={onRenderCallback}>
<UserDashboard />
</Profiler>
);
}
// 也可以用浏览器原生 Performance API 精确测量某段逻辑
function measureExpensiveWork(work) {
performance.mark('work-start');
const result = work();
performance.mark('work-end');
performance.measure('expensive-work', 'work-start', 'work-end');
const entry = performance.getEntriesByName('expensive-work').pop();
console.log(\`耗时: \${entry.duration.toFixed(2)}ms\`);
return result;
}测量时重点关注三个信号:某个组件是否渲染次数远超预期(说明该用 memo)、某次渲染 actualDuration 是否很长(说明单次渲染太重,考虑拆分或虚拟化)、baseDuration 与 actualDuration 差距(差距大说明 memo 起了作用,差距小说明 memo 没帮上忙,白加)。
自定义防抖与节流 Hook
在有并发特性之前,防抖(debounce)和节流(throttle)是控制高频更新的经典手段,至今仍常用于搜索请求、滚动监听、窗口 resize 等场景。防抖是"等你停下来再执行",节流是"固定间隔执行一次"。
import { useState, useEffect, useRef, useCallback } from 'react';
// 防抖值 Hook:值停止变化 delay 毫秒后才更新
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // 值又变了就取消上一次
}, [value, delay]);
return debounced;
}
// 防抖回调 Hook
function useDebouncedCallback(callback, delay = 300) {
const timerRef = useRef(null);
const callbackRef = useRef(callback);
callbackRef.current = callback; // 始终指向最新回调,避免闭包陈旧
return useCallback((...args) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
}
// 节流回调 Hook:固定间隔最多执行一次
function useThrottledCallback(callback, interval = 200) {
const lastRun = useRef(0);
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback((...args) => {
const now = Date.now();
if (now - lastRun.current >= interval) {
lastRun.current = now;
callbackRef.current(...args);
}
}, [interval]);
}
// 使用:搜索框防抖,避免每敲一个字就发一次请求
function SearchBox() {
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 400);
useEffect(() => {
if (!debouncedText) return;
fetch('/api/search?q=' + encodeURIComponent(debouncedText))
.then(r => r.json())
.then(/* setResults */);
}, [debouncedText]);
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}传统防抖与 useDeferredValue 有何区别?防抖是固定延迟、会丢弃中间态、且不可打断;useDeferredValue 是自适应延迟、React 内建、可被更高优先级打断。对纯前端过滤/渲染,优先用 useDeferredValue;对需要节流网络请求的场景,防抖仍不可替代。
状态管理优化
状态提升与下沉:
状态提升是指将状态移动到最近的共同父组件,状态下沉是指将状态移动到使用它的组件中。合理的状态位置可以减少不必要的渲染,简化数据流。对于多个组件共享的状态,应该提升到最近的共同父组件;对于只有一个组件使用的状态,应该下沉到该组件中。一个常见的性能陷阱是:把一个只有局部子树关心的状态放在了顶层,导致每次它变化整棵树都重渲染——把它下沉到真正使用的子组件,就能把重渲染范围缩到最小。
状态共置(colocation)与内容提升(lifting content up):
如果一个高频变化的状态不得不放在较高层,可以用"把不变的内容通过 children 传入"的技巧,让不变部分不随状态重渲染。
// ❌ 高频 color 状态放在顶层,导致昂贵的 <ExpensiveTree /> 每次都重渲染
function BadColorApp() {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<ExpensiveTree /> {/* 与 color 无关,却被连累重渲染 */}
</div>
);
}
// ✅ 技巧一:把状态下沉到只需要它的小组件
function GoodColorApp() {
return (
<div>
<ColorPicker /> {/* color 状态封装在这里 */}
<ExpensiveTree />
</div>
);
}
function ColorPicker() {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
</div>
);
}
// ✅ 技巧二:通过 children 把不变内容"提"进来,隔离重渲染
function ColorContainer({ children }) {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
{children} {/* children 引用稳定,color 变时不重渲染 */}
</div>
);
}
function App2() {
return (
<ColorContainer>
<ExpensiveTree />
</ColorContainer>
);
}不可变数据更新:
React 依赖引用比较来检测状态变化,直接修改状态会导致 React 无法检测到变化。使用展开运算符、Object.assign 或 immer 库进行不可变更新,确保每次更新都创建新的引用。immer 库提供了更直观的 API,可以在修改草稿的同时保持不可变性。
import { produce } from 'immer';
function OptimizedUpdates() {
const [items, setItems] = useState([]);
// 函数式更新:不依赖外部 items,useCallback 可空依赖
const addItem = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []);
// 批量更新:一次 setState 合并多条
const addMultipleItems = useCallback((newItems) => {
setItems(prev => [...prev, ...newItems]);
}, []);
// immer 处理深层不可变更新,写法直观又安全
const updateItem = useCallback((id, updates) => {
setItems(produce(draft => {
const item = draft.find(i => i.id === id);
if (item) Object.assign(item, updates);
}));
}, []);
return <ItemList items={items} onAdd={addItem} onUpdate={updateItem} />;
}批量更新机制:
React 18 引入了自动批处理(automatic batching),将多个状态更新合并为一次渲染。在事件处理器、setTimeout、Promise、原生事件处理器中的更新都会自动批处理(React 17 只在合成事件里批处理)。对于需要立即读取 DOM 的极少数场景,可以使用 flushSync 强制同步更新,但它会牺牲批处理带来的性能优势,应谨慎使用。
import { flushSync } from 'react-dom';
// React 18 中,下面三次更新自动合并为一次渲染(即使在 setTimeout 里)
setTimeout(() => {
setA(1);
setB(2);
setC(3); // 只触发一次渲染
}, 0);
// 需要在更新后立刻读取 DOM 时,才用 flushSync(会打断批处理)
function scrollToBottomAfterAdd(newMsg) {
flushSync(() => {
setMessages(prev => [...prev, newMsg]);
});
// 此时 DOM 已更新,可安全读取滚动高度
listRef.current.scrollTop = listRef.current.scrollHeight;
}外部状态订阅 useSyncExternalStore:
当你需要订阅一个 React 之外的数据源(如自定义 store、浏览器 API、第三方状态库),React 18 提供了 useSyncExternalStore。它能保证在并发渲染下读取到一致的快照,避免"撕裂(tearing)"——即同一次渲染里不同组件读到不同版本的数据。
import { useSyncExternalStore } from 'react';
// 订阅浏览器在线状态
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine, // 客户端快照
() => true // 服务端快照(SSR)
);
}
// 订阅一个极简自定义 store
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState: (next) => {
state = typeof next === 'function' ? next(state) : next;
listeners.forEach(l => l());
},
subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); }
};
}
const counterStore = createStore(0);
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe,
counterStore.getState
);
return <button onClick={() => counterStore.setState(c => c + 1)}>{count}</button>;
}清理副作用防止内存泄漏
内存泄漏是长时间运行的单页应用的隐形杀手。定时器、事件监听、订阅、未完成的请求如果不在组件卸载时清理,会导致回调持有已卸载组件的引用,内存持续增长,甚至在卸载后调用 setState 报警告。useEffect 的返回函数就是专门用来清理这些副作用的。
import React, { useState, useEffect } from 'react';
// 清理 fetch 请求,避免卸载后 setState 与竞态
function DataFetcher({ url }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(url, { signal: controller.signal });
const json = await response.json();
setData(json);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
fetchData();
// 卸载或 url 变化时中断上一次请求
return () => controller.abort();
}, [url]);
return data ? <DataDisplay data={data} /> : <Loading />;
}
// 清理定时器与事件监听
function Clock() {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
const onResize = () => console.log('resize');
window.addEventListener('resize', onResize);
return () => {
clearInterval(timer); // 清定时器
window.removeEventListener('resize', onResize); // 清监听
};
}, []);
return <div>{new Date(now).toLocaleTimeString()}</div>;
}对于大型数据缓存,考虑使用 WeakMap 和 WeakSet——它们对键是弱引用,当键对象不再被其他地方引用时,垃圾回收器可以自动回收对应缓存,避免手动清理遗漏导致的泄漏。
React Compiler:自动优化的未来
React 团队推出的 React Compiler(原 React Forget)是一个编译期优化工具。它会在构建时自动分析组件,插入等价于 useMemo / useCallback / React.memo 的记忆化逻辑,让开发者无需手写这些缓存 API 就能获得接近手动优化的性能。
它的意义在于:手写 useMemo/useCallback 心智负担重、容易写错依赖、容易过度使用。React Compiler 通过静态分析精确地知道哪些值需要记忆、依赖是什么,从而生成更可靠的优化代码。它遵循 React 规则(组件纯函数、Hooks 规则),因此代码越符合规范,编译器优化得越好。
需要强调:即便有了 React Compiler,理解本文的优化原理依然重要——编译器解决的是"渲染过于频繁"这一类,而虚拟列表、代码分割、Web Worker、并发特性这些工程手段是编译器无法自动替你做的。此外,在编译器尚未全面普及、或存在无法被自动优化的边界场景时,手动优化仍是必备技能。
// 启用 React Compiler 后(示意),下面的组件无需手写 useMemo/useCallback
// 编译器会自动为 expensiveList 和 handleClick 插入记忆化
function ProductList({ products, keyword }) {
const expensiveList = products
.filter(p => p.name.includes(keyword))
.sort((a, b) => b.sales - a.sales);
const handleClick = (id) => {
console.log('clicked', id);
};
return (
<ul>
{expensiveList.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
))}
</ul>
);
}其他优化策略与常见坑
图表等重型第三方组件的重渲染:
图表库(ECharts、Recharts、D3)的重新绘制成本极高。常见坑是:父组件任意状态变化都让图表整体重绘。解决方案是用 React.memo 包裹图表容器,并确保传入的 data、option 引用稳定(用 useMemo 缓存),只有数据真正变化时才重绘。某监控大盘把六个实时图表用 memo + 稳定引用优化后,拖动布局时 FPS 从 22 提升到 58。
列表 key 的正确使用:
用数组索引 index 作为 key 是常见错误。当列表发生插入、删除、排序时,index 会错位,导致 React 复用错误的 DOM/状态,出现输入框内容错乱、动画异常、性能变差。应使用数据的稳定唯一标识(如 id)作为 key。
useEffect 依赖导致的连锁重渲染:
在 useEffect 里 setState、又把该 state 放进依赖,容易形成渲染死循环或频繁多余渲染。要仔细核对依赖数组,必要时用函数式更新去掉不必要的依赖。
过度优化:
到处滥用 memo/useMemo/useCallback 反而增加代码复杂度和轻微开销。原则是:先测量确认瓶颈,再针对性优化,不要凭感觉给每个函数都套 useCallback。
优化手段适用场景对照表(总结)
| 优化手段 | 主要解决 | 典型场景 | 代价/注意 |
| --- | --- | --- | --- |
| React.memo | 渲染过于频繁 | 纯展示子组件、列表项 | 仅浅比较,需配合稳定引用 |
| useMemo | 单次渲染有昂贵计算 | 大数组过滤/排序/聚合 | 廉价计算别用,反增开销 |
| useCallback | 函数引用稳定 | 传给 memo 子组件的回调 | 子组件未 memo 则无效 |
| useTransition | 主线程被长任务占据 | 输入触发大列表更新 | 需能包裹更新调用点 |
| useDeferredValue | 高频值拖累重渲染 | 搜索过滤大列表 | 消费侧延迟,自适应 |
| React.lazy + Suspense | 首屏体积过大 | 路由/重型组件懒加载 | 分割粒度别过细 |
| 虚拟列表 react-window | 单次渲染 DOM 太多 | 上万行表格/长列表 | 原生搜索/SEO 受限 |
| Web Worker | 纯 JS 大计算阻塞 | CSV 解析、复杂聚合 | 数据需可序列化传递 |
| 防抖/节流 Hook | 高频事件/请求 | 搜索请求、scroll/resize | 网络节流仍首选防抖 |
| useSyncExternalStore | 外部数据源订阅 | 自定义 store、浏览器 API | 避免并发撕裂 |
| immer/不可变更新 | 状态更新可被检测 | 深层嵌套状态 | 保证引用变化 |
| 状态下沉/children 隔离 | 缩小重渲染范围 | 局部高频状态 | 合理规划状态位置 |
| Profiler 测量 | 定位真实瓶颈 | 优化前的诊断 | 先测量再优化 |
| React Compiler | 自动记忆化 | 减少手写 memo | 需遵守 React 规则 |
总结
React 性能优化不是零散技巧的堆砌,而是一套有内在逻辑的方法论:先用 Profiler 测量、定位瓶颈属于"渲染过于频繁""单次渲染太重""主线程被长任务占据"中的哪一类,再选择对应手段。渲染频繁用 React.memo / useMemo / useCallback(或交给 React Compiler);单次太重用虚拟列表、代码分割、组件拆分;主线程阻塞用 useTransition / useDeferredValue / Web Worker。同时做好状态位置规划、不可变更新、副作用清理,避免内存泄漏与连锁重渲染。最重要的一条原则始终是:先测量,再优化,不要过度优化——把精力花在真正影响用户体验的瓶颈上。