原文链接:How to Measure and Optimize React Performance
作者:Anna Monus
React 应用往往会随着规模扩大而变慢:组件不必要地重渲染、包体积膨胀、交互出现延迟。最终导致核心网页指标(Core Web Vitals)变差(尤其是 “下一次绘制的交互时间” Interaction to Next Paint)、用户体验不佳、应用反应迟缓。
尽管 React 应用常被诟病存在性能问题,且目前已有更多轻量级的组件化框架替代品(如 Preact、Lit、Solid 等),但 React 仍在持续新增性能优化特性,为开发者提供了更多利用该框架构建高性能应用的方案。
本文将详细介绍如何测量和优化 React 应用性能。
如何测量 React 应用性能
有效的性能优化始于精准测量。React 提供了多种借助浏览器开发者工具测量应用性能的方式,帮助你定位性能瓶颈和不必要的重渲染。
1. React 性能追踪(React Performance Tracks)
React 性能追踪功能于 React 19.2 版本引入,是 Chrome 开发者工具 “性能”(Performance)面板中的自定义时间线条目,可与网络请求、JavaScript 执行等事件一起展示 React 专属事件。
本质上,它会将 React 内部优先级系统、组件渲染时长和服务端活动可视化,全面呈现 React 的并发渲染功能。
React 性能追踪在 “性能” 面板中分为三个独立部分:
- 调度器追踪(Scheduler track) :展示 React 内部的任务调度,包含四个子追踪项:阻塞(Blocking)、过渡(Transition)、挂起(Suspense)和空闲(Idle)。每个子追踪项用彩色条表示不同优先级的任务(例如,用户输入对应的阻塞优先级、
startTransition包裹的更新对应的过渡优先级)。若遇到交互卡顿问题,调度器追踪可帮助你判断 React 是在积极处理任务还是处于等待状态。 - 组件追踪(Components track) :以火焰图形式可视化组件树的渲染过程和副作用执行。你会看到 “挂载”(Mount,组件首次渲染)、“卸载”(Unmount,组件移除)等事件标签。颜色深浅反映渲染时长,颜色越深表示渲染相对越慢,可快速定位组件树中的性能瓶颈。
- 服务端追踪(Server tracks) :若使用React 服务端组件(React Server Components),在开发构建模式下会显示服务端组件和服务端请求相关信息。这些追踪项会捕获服务端渲染活动(包括流式传输和水合过程),有助于调试服务端渲染(SSR)的性能问题。
在开发构建模式下,React 性能追踪会自动显示;在性能分析构建模式下,调度器追踪默认启用,而组件追踪仅在安装了 React 开发者工具(React Developer Tools),或组件被 <Profiler> 包裹时才会显示。
使用方法:打开 Chrome 开发者工具,切换到性能标签页,点击录制按钮,与应用进行交互后停止录制,即可在时间线中看到 React 性能追踪条目与其他浏览器事件。
2. React 开发者工具性能分析器(Profiler)
React 开发者工具是适用于 Firefox 和 Chromium 内核浏览器(Chrome、Edge、Opera 等)的扩展程序,会在开发者工具中新增两个标签页:组件(Components)和 性能分析器(Profiler)。
性能分析器标签页用于测量组件渲染性能。使用时,打开该标签页点击录制按钮,与应用交互后再次点击录制停止,工具会生成火焰图,展示录制期间哪些组件进行了渲染。
如何解读火焰图
火焰图以层级结构展示组件:父组件位于顶部,子组件在下方。每个条形的宽度代表该组件(及其子组件)的渲染时长。颜色编码让瓶颈一目了然:
- 灰色条:本次提交(commit)中未渲染的组件
- 绿色 / 青绿色条:快速渲染的组件
- 黄色 / 橙色条:渲染较慢的组件(优化目标)
注:在 React 中,“提交”(commit)指将渲染阶段计算出的更新应用到 DOM 的阶段。一次录制会话通常会捕获多个提交(例如下图中捕获了 23 次)。
性能分析器标签页顶部的彩色条代表录制期间的所有提交,点击条形或使用箭头按钮可在不同提交之间切换,对比它们的性能表现。
如何查看组件渲染原因
React 开发者工具还能准确显示每个组件的渲染原因,避免调试不必要重渲染时的猜测。使用该功能需先在性能分析器设置中(点击标签页内的齿轮图标)启用录制时记录每个组件的渲染原因(Record why each component rendered while profiling)选项。
性能分析完成后,将鼠标悬停在火焰图中的组件上,为何渲染?(Why did this render?)下方的提示框会显示组件渲染的原因(例如:属性变化、状态变化、父组件渲染等)。
3. 性能分析器组件 API(Profiler Component API)
React 的 <Profiler> 组件允许你通过编程方式测量渲染性能。可将其包裹在组件树的任意部分,追踪渲染时间。示例如下:
import { Profiler } from "react";
function App() {
const handleRender = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
) => {
console.log({
id, // 性能分析器的唯一标识
phase, // "mount"(挂载)或 "update"(更新)阶段
actualDuration, // 本次渲染的实际耗时
baseDuration, // 组件本身及其子组件的预估渲染耗时
startTime, // 渲染开始时间
commitTime, // 提交阶段开始时间
});
};
return (
<Profiler id="App" onRender={handleRender}>
<Navigation />
<MainContent />
</Profiler>
);
}
onRender 回调会在被包裹的组件每次渲染时触发,提供渲染时长、阶段等时间数据。可利用这些数据在开发阶段识别慢渲染问题,或长期跟踪性能指标变化。
4. Chrome 开发者工具 “性能” 标签页
除了 React 性能追踪,Chrome 开发者工具的性能标签页还包含通用性能分析工具,可展示所有浏览器活动。
主线程(Main)部分位于 React 性能追踪下方,可视化主线程时间线,显示 JavaScript 执行的时机,包括应用代码(如下方截图中的 DataProcessor、ChartRenderer、ChartGrid 等自定义组件)和 React 内部渲染函数。条形越宽表示函数执行时间越长。
在下方面板的调用树(Call Tree)标签页中,会按函数拆分 CPU 耗时,显示每个条目的自身时间(Self Time,函数本身执行耗时)和总时间(Total Time,包含所有子函数执行耗时)。需注意,调用树包含所有 JavaScript 执行,而非仅 React 组件,因此需要在整体执行轨迹中识别 React 相关操作。
5. 真实用户监控(Real User Monitoring, RUM)
真实用户监控可从宏观层面展示 React 应用的核心网页指标(Core Web Vitals)表现,测量加载时间、视觉稳定性和交互响应速度。
你还可以查看具体的慢脚本,判断延迟是来自应用本身的 React 代码,还是来自分析工具等第三方代码。
在交互缓慢的场景中,脚本可能因后台正在进行水合(hydration)操作,或 React 正在处理更新而运行。
如何优化 React 应用性能
通过上述工具定位性能瓶颈后,即可开始优化 React 应用性能。重点关注对用户体验有实际影响的方面,例如提升 INP 分数、加快加载速度、减少长任务(long tasks)。
1. 运行时优化
运行时优化可改善应用运行过程中的性能表现。当性能分析发现不必要的重渲染或耗时的组件更新时,可采用以下技术:
1.1 记忆化(Memoization)
记忆化会缓存计算结果以避免重复工作。当输入未变化时,React 会返回缓存结果而非重新计算。
React 中有三种记忆化方式:
memo():记忆化组件- useMemo():记忆化值
useCallback():记忆化函数
下面简要介绍它们的用法:
1.1.1 memo()
memo()函数可对组件进行记忆化,避免属性未变化时的重渲染。只需将组件声明包裹在 memo() 中:
import { memo } from "react";
const MyComponent = memo(({ data, onAction }) => {
return <div onClick={() => onAction(data.id)}>{data.value}</div>;
});
memo() 对组件属性进行浅比较(按引用比较每个属性)。父组件中内联创建的对象和函数每次渲染都会生成新引用,因此需使用以下方法保持引用稳定:
- 对对象 / 数组使用
useMemo() - 对函数使用
useCallback()
1.1.2 useMemo()
useMemo()钩子可记忆化计算值,避免重复计算,或在多次渲染间保持对象 / 数组引用稳定。示例如下:
import { useMemo } from "react";
function MyComponent({ items, filter }) {
const filteredItems = useMemo(() => {
return items.filter((item) => item.category === filter);
}, [items, filter]); // 依赖项数组:仅当 items 或 filter 变化时才重新计算
return <ChildComponent items={filteredItems} />;
}
提示:避免对简单运算(如基础算术、属性访问)使用
useMemo()。该钩子本身存在一定开销,此类值直接计算更高效。useMemo()适用于重型计算、大型数组操作,或传递给记忆化子组件的值。
1.1.3 useCallback()
useCallback()钩子可记忆化函数,避免其在每次渲染时重新创建:
import { useCallback } from "react";
function MyComponent({ items, onItemSelect }) {
const handleClick = useCallback(
(id) => {
onItemSelect(id);
},
[onItemSelect], // 依赖项数组:仅当 onItemSelect 变化时才重新创建函数
);
return <ChildComponent items={items} onClick={handleClick} />;
}
若不使用 useCallback(),函数每次渲染都会生成新引用,破坏子组件的记忆化效果。所有接收 handleClick() 的子组件都会随之重渲染,即使函数逻辑未发生变化。
1.2 代码分割(Code Splitting)
可使用 lazy() 和 <Suspense> 将 React 代码分割为更小的代码块,按需加载组件。lazy()函数会延迟加载组件直到其被需要,<Suspense> 组件则在加载期间显示占位 UI。
简单示例:
import { lazy, Suspense } from "react";
// 按需加载 MyComponent,仅在组件被渲染时才加载对应的代码块
const MyComponent = lazy(() => import("./MyComponent"));
function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<MyComponent />
</Suspense>
);
}
1.3 列表虚拟化(List Virtualization)
列表虚拟化(又称 “窗口化” windowing)是针对大量(约 100 个以上)相似项或复杂项(如图文卡片、图表、包含多个子组件的条目)的 React 性能优化技术。长列表会影响网页性能,因为浏览器会渲染所有条目(包括屏幕外的条目)。
虽然可手动实现虚拟化,但 react-window 等库经过了充分测试,能避免滚动卡顿、定位错误、内存泄漏等常见问题。
下方示例中,react-window 每次仅渲染约 12-15 个条目(600px 可视区域 / 50px 条目高度,另加少量缓冲条目):
import { FixedSizeList } from "react-window";
function MyComponent({ items }) {
return (
<FixedSizeList
height={600} // 列表总高度
itemCount={items.length} // 条目总数
itemSize={50} // 每个条目的高度
width="100%" // 列表宽度
>
{({ index, style }) => (
<div key={items[index].id} style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
);
}
提示:react-window 库会使包体积增加约 4-5 KB,但能通过避免不必要的 DOM 节点(例如,1000 个条目的列表仅渲染可视区域条目,而非全部 1000 个)消除长列表的渲染开销。仅当性能分析显示列表渲染是瓶颈时使用该库。
1.4 状态管理
状态更新时,React 会重新渲染组件及其所有子组件,因此状态的存放位置直接影响应用性能。
“状态共存”(State colocation)是 React 性能优化技巧,指将状态尽可能靠近其使用的组件。除非需要共享状态,否则避免将状态提升到父组件:
// 反面示例:父组件中的状态导致所有子组件重渲染
function MyComponent() {
const [value, setValue] = useState("");
return (
<>
<Child1 value={value} onChange={setValue} />
<Child2 /> {/* 未使用 value,但会随父组件重渲染 */}
<Child3 /> {/* 未使用 value,但会随父组件重渲染 */}
</>
);
}
// 正面示例:状态隔离在需要它的组件中
function Child1() {
const [value, setValue] = useState("");
// ...
}
当确实需要在多个组件间共享状态时,仅将其提升到最近的公共祖先组件,以最小化不必要的重渲染,避免过度的属性透传(prop drilling)。
此外,可使用 Context API 在组件间共享状态而无需属性透传,但需谨慎使用 ——Context API 可能导致不必要的重渲染:当上下文值变化时,所有消费该上下文的组件都会重渲染,无论组件是否实际使用了变化的部分。
可通过以下方式缓解该问题:
- 拆分上下文:为不同数据创建独立的上下文
- 使用支持选择器优化的状态管理库(如 Zustand 或 Jotai),这些库体积较轻,允许组件仅订阅所需的状态部分,避免无关状态变化时的重渲染。
2. 并发特性(Concurrency Features)
React 18 引入了并发渲染能力,可在处理繁重更新时保持应用响应性。并发渲染允许 React 在处理耗时操作(如过滤大型数据集、渲染复杂组件)时,保持 UI 的可交互性。
可通过两个 React 钩子管理更新优先级:
useTransition:将自定义状态更新标记为非紧急更新useDeferredValue:延迟处理通过属性接收的值
两者均能在后台处理延迟更新的同时,保证紧急更新的响应性。
2.1 useTransition
useTransition 可标记非紧急更新,让 React 优先处理更重要的更新。状态更新仍会立即发生,但 React 可能暂停或重启相关的渲染工作。
useTransition 钩子返回两个值:
isPending:布尔值,标识过渡是否正在进行startTransition():包裹低优先级更新的函数
下方示例展示如何使用 useTransition 加速大型数据表格的过滤操作:
function DataTable() {
const [searchText, setSearchText] = useState("");
const [filteredRows, setFilteredRows] = useState(allRows);
const [isPending, startTransition] = useTransition();
const handleChange = (text) => {
// 紧急更新:立即更新输入框值
setSearchText(text);
// 非紧急更新:在空闲时更新表格
startTransition(() => {
const filtered = allRows.filter((row) => row.name.includes(text));
setFilteredRows(filtered);
});
};
return (
<>
<input
value={searchText}
onChange={(e) => handleChange(e.target.value)}
placeholder="搜索..."
/>
{/* 过渡期间可显示加载状态 */}
{isPending && <div>过滤中...</div>}
<table>
{filteredRows.map((row) => (
<tr key={row.id}>{row.name}</tr>
))}
</table>
</>
);
}
该示例中存在两个相互竞争的更新:
- 搜索输入框更新(紧急)
- 过滤表格行更新(非紧急)
用户输入必须即时响应,但数千行数据的重渲染可延迟。通过 startTransition() 包裹非紧急更新,即使表格需过滤数千行数据,用户也能获得即时反馈。
若不使用 useTransition,React 会以相同优先级处理两个更新,导致输入框在表格重渲染完成前出现卡顿。
2.2 useDeferredValue
当需要延迟处理通过属性或外部源接收的值时,可使用useDeferredValue 钩子。例如,当状态由父组件管理,无法直接控制状态更新时,该钩子非常有用。
下方示例中,filterText 来自父组件。通过延迟处理该值,React 可优先保证用户输入响应性,而非立即更新表格:
function DataTable({ filterText }) {
// 延迟处理 filterText,优先响应紧急更新
const deferredFilter = useDeferredValue(filterText);
// 记忆化过滤结果,仅当 deferredFilter 变化时重新计算
const filteredRows = useMemo(() => {
return allRows.filter((row) => row.name.includes(deferredFilter));
}, [deferredFilter]);
return (
<table>
{filteredRows.map((row) => (
<tr key={row.id}>{row.name}</tr>
))}
</table>
);
}
如上述代码所示,useDeferredValue 会延迟过滤操作,确保用户输入时 UI 保持响应。同时,我们将过滤逻辑包裹在 useMemo 中(见上文),确保仅当 deferredFilter 变化时才重新计算,避免每次渲染时的不必要运算。
提示:直接更新状态时使用
useTransition;通过属性或第三方库接收值时使用useDeferredValue。
3. 包体积优化
JavaScript 包体积直接影响页面加载时间。包体积越大,下载时间越长、解析工作量越大、交互延迟越久,在移动设备和低速网络下尤为明显。
Chrome Lighthouse 提供树形图(Treemap)视图,可可视化包结构。在 Chrome 开发者工具中运行 Lighthouse 审计后,点击查看树形图(View Treemap)按钮,即可查看 JavaScript 代码的构成。
需注意,开发构建模式下的包结构可能具有误导性 —— 开发构建未经过压缩(minification)、摇树优化(tree shaking)、代码分割等构建阶段的优化。而生产构建模式下,树形图通常展示的是打包工具拆分的代码块,而非单个包,除非在构建工具配置文件中启用了源映射(source maps)。
可通过以下方式在不同构建工具中启用源映射(详见 Vite、Webpack、esbuild 官方文档):
// Vite (vite.config.js)
export default {
build: {
sourcemap: true,
},
};
// Webpack (webpack.config.js)
module.exports = {
devtool: "source-map",
};
// esbuild (命令行)
esbuild --sourcemap
如需更深入的分析,可使用专门的包分析工具,如 Webpack Bundle Analyzer 或 Rollup Plugin Visualizer(后者也适用于 Vite)。这两款工具都会生成交互式树形图,展示哪些依赖对包体积的贡献最大。你可能会发现一些意外情况,例如某个日期库增加了 300 KB 体积,或某个重量级 UI 框架可替换为更轻量的替代品。
减少包体积的一个重要原则是 “按需导入”。即使是支持摇树优化的库,若默认提供加载全部内容的便捷入口,也可能导致包体积大幅增加。Chart.js 就是一个常见例子:从 chart.js/auto 入口导入会自动注册 Chart.js 中的所有图表控制器、比例尺、元素和插件:
import Chart from "chart.js/auto"; // 导入所有图表类型(不推荐)
这种方式虽能确保所有图表类型开箱即用,但会导致所有图表类型在运行时被导入和注册,即使从未使用,打包工具也会将其包含在内。
Tree Shaking的最佳实践是避免导入未使用的代码。例如,以下代码仅导入和注册柱状图所需的组件:
import {
Chart as ChartJS,
BarController, // 柱状图控制器
BarElement, // 柱状图元素
CategoryScale, // 分类比例尺
LinearScale, // 线性比例尺
} from "chart.js";
// 仅注册所需组件
ChartJS.register(BarController, BarElement, CategoryScale, LinearScale);
4. 服务端渲染(Server-Side Rendering, SSR)
服务端渲染通过向浏览器发送预渲染的 HTML(浏览器仅需进行水合操作),而非让客户端从零渲染整个应用,可显著提升 React 应用性能。这能让用户更快看到有意义的内容,通常会改善首次内容绘制(FCP)和最大内容绘制(LCP)指标。
流式服务端渲染(Streaming SSR)
自 React 18 起,服务端渲染不再局限于单次渲染整个页面。React 应用现在可在服务端增量渲染:
- renderToPipeableStream API 允许服务端在组件渲染完成后,将 HTML 分块流式传输到浏览器。这意味着首屏内容可优先交付,数据密集型部分后续流式加载,让浏览器更早开始解析和绘制。
<Suspense>组件允许你定义渲染可暂停和恢复的位置,并在暂停期间显示占位 UI。在服务端,它支持选择性流式传输和增量内容展示;在客户端,它允许在水合期间延迟渲染。
这两种技术常结合使用,但解决的问题不同:流式传输控制 HTML 向浏览器的发送方式,而 <Suspense> 控制渲染的暂停和恢复位置。两者也可单独使用:renderToPipeableStream API 可在不使用 <Suspense> 的情况下逐步发送 HTML;<Suspense> 也可在不使用流式传输的情况下,在客户端延迟渲染(如上文代码分割部分所述)。
React 服务端组件(React Server Components, RSC)
React 18 还引入了 React 服务端组件,将非交互性组件(即无客户端状态或副作用的组件)的渲染转移到服务端。服务端组件不会在浏览器中运行,因此不发送任何 JavaScript,也无需水合操作。
与完整的服务端渲染(发送需在客户端水合的 HTML)不同,服务端组件发送组件树的序列化表示,React 客户端运行时会将其重构并与(交互性的)客户端组件合并。需注意,服务端组件旨在补充客户端组件,而非替代它们。
5. React 编译器(React Compiler)
React 编译器是一款构建时优化工具,可自动为 React 组件添加记忆化,通常无需手动编写 useMemo、useCallback 和 React.memo()。它并非 React 核心部分,需作为 Babel 插件添加到构建流程中。React 编译器于 2025 年 10 月发布稳定版本。
它开箱即用,无需额外配置(也可根据需求自定义配置)。React 编译器在构建时分析组件代码,在所有可安全提升性能的位置插入记忆化逻辑。与手动记忆化不同,编译器的记忆化应用更精准,包括单个表达式和条件渲染路径。
该工具的核心假设是组件为纯函数(即相同输入产生相同输出,且渲染期间不执行副作用)。因此,React 编译器要求代码遵循 React 规则:不可修改属性和状态,正确使用钩子。
虽然 React 编译器让常见性能优化自动化且保持一致性,但编译器生成的记忆化可能降低调试透明度,且不纯或遗留代码可能难以从中获益。
如何在生产环境监控 React 应用
开发工具仅能测量本地环境的性能,而你可能还想了解应用在生产环境的表现。
最佳方式是部署真实用户监控(RUM)工具,了解实际用户与应用的交互情况及遇到的性能问题。由于 React 应用性能会随时间逐渐下降,即使配置完善,也可能出现意外问题。
DebugBear RUM 会以直方图形式展示最重要的页面加载里程碑,以及其他关键真实用户指标。
你还可以设置性能预算,当自动化实验室测试中某个性能指标(如核心网页指标)超过预设阈值时,会收到警报。
总结
测量和优化 React 应用性能的核心步骤的是:先通过 React 性能追踪、开发者工具性能分析器等工具定位瓶颈,再针对性地应用记忆化、代码分割、列表虚拟化等优化技术,最后通过真实用户监控持续跟踪生产环境性能。随着 React 生态的发展,React 编译器、并发特性等新工具和新特性,也为构建高性能应用提供了更多便捷方案。