11 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-04-29 22:14 |
性能优化
概述
React 性能优化的核心原则是 "减少不必要的渲染"。本文档从诊断工具到具体手段,提供完整的调优方法论。
[!question] 思考:为什么 React 会慢? 大多数情况下,应用变慢不是因为 React 本身慢,而是因为 它重新渲染了不该渲染的组件。 一个常见的陷阱:父组件 state 变化 → 所有子组件重新执行 render(即使它们的 props 没变)。 我们的目标:让 React 只渲染真正需要更新的部分。
优化决策流程图
flowchart TD
A[页面卡顿?] --> B{使用 Profiler 定位}
B --> C[频繁重渲染?]
B --> D[首屏加载慢?]
B --> E[滚动掉帧?]
C --> F[props 不变却渲染?]
C --> G[状态更新频率过高?]
F --> H["React.memo / useMemo"]
G --> I["useCallback / 防抖节流"]
D --> J[打包体积过大?]
D --> K[某些功能很少用?]
J --> L["Code Splitting / Tree Shaking"]
K --> M["Dynamic Import"]
E --> N[列表项很多?]
N --> O["虚拟列表 Virtual Scroll"]
style H fill:#61DAFB,color:#000
style L fill:#61DAFB,color:#000
style O fill:#61DAFB,color:#000
[!warning] 第一条原则:先测量,再优化 没有 Profiler 数据的优化都是猜。先用 React DevTools 或 Chrome Performance 确认瓶颈在哪, 然后针对性地解决,而不是盲目地在每个组件上加 memo。
性能诊断工具箱
graph TB
A[发现性能问题] --> B["选择诊断工具"]
B --> C["React DevTools Profiler"]
B --> D["Chrome Performance Tab"]
B --> E["Lighthouse"]
B --> F["Web Vitals 监控"]
C --> G["定位重渲染的组件和原因"]
D --> H["分析主线程阻塞时段"]
E --> I["整体 LCP/INP/CLS 评分"]
F --> J["生产环境真实用户数据"]
style C fill:#61DAFB,color:#000
style H fill:#F5A87D,color:#000
React DevTools Profiler 使用要点
- 录制期间进行关键交互(点击、输入)
- 观察 Commit 颜色 —— 红色越深表示重渲染越多
- 展开组件树,关注 "why did this render" 原因
- 对比优化前后的 Commit 时间变化
React.memo —— 阻止子组件重渲染
const ExpensiveList = React.memo(({ items }: { items: Item[] }) => {
// props.items 引用不变时,跳过整个子树的 re-render
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}, (prevProps, nextProps) => prevProps.items === nextProps.items); // 自定义比较函数
[!warning] React.memo 的适用边界
- 只对纯组件有用——相同 props 必须产生相同的输出
- 父组件每次传新对象/函数作 prop → memo 无效
- 简单列表(几十项以内)不需要 memo
缓存计算与回调 — useMemo / useCallback
React.memo 解决的是"组件不重新渲染",而 useMemo 和 useCallback 解决的是**"值不重新生成"**。
useMemo —— 缓存昂贵的计算结果
function SearchPage({ items, keyword }: { items: string[]; keyword: string }) {
// 过滤逻辑只在 items 或 keyword 变化时重新执行
const filtered = useMemo(() => {
console.log("filtering..."); // 仅依赖变化时才打印
return items.filter(item => item.includes(keyword));
}, [items, keyword]);
return <List data={filtered} />;
}
[!tip] useMemo 的本质 它不是"性能捷径",而是跳过不必要的计算。对于 O(n) 以下的轻量操作,useMemo 反而增加内存开销。 适用场景:大数据量处理、深度嵌套对象的派生计算、API 请求参数校验。
useCallback —— 缓存函数引用
function Parent() {
const [count, setCount] = useState(0);
// count 变化时才创建新函数,Child 用 React.memo 包裹时不会被重渲染
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onAction={handleClick} /> {/* 不受 count 变化影响 */}
</>
);
}
[!important] 什么时候需要 useCallback? 仅在以下两种场景中使用:
- 函数传给
React.memo包裹的子组件,避免父状态变化导致子组件重渲染- 作为其他 Hook(如
useEffect)的依赖项否则——不加更简单,也更容易维护。
[!question] 思考:三个 memo 的关系
- React.memo: 阻止组件树 re-render(组件级别)
- useMemo: 缓存返回值(值级别)
- useCallback: 缓存函数引用(等价于
useMemo(() => fn, deps))它们共同目标是:缩小变更传播范围。当 state 更新时,让影响尽可能少地扩散到子树。
虚拟列表(Virtual Scrolling)
当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用:
// 方案1:tanstack/virtual(推荐)
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 预估每项高度
overscan: 5, // 视口上下各多渲染 5 项
});
return (
<div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index]}
</div>
))}
</div>
</div>
);
}
[!tip] 何时需要虚拟列表?
- 列表项 > ~50 且每帧渲染耗时可感知
- 固定高度的项目比可变高度更容易实现
- React Window / React Virtualized 是老牌的成熟方案
Code Splitting
Code Splitting 的核心理念:用户不需要一次性下载所有代码。按需加载可以显著降低首屏时间。
路由级拆分
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// 每个路由对应一个 chunk,用户只下载当前页面对应的代码
const Home = lazy(() => import("./pages/Home"));
const Admin = lazy(() => import("./pages/Admin"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
[!note] Next.js 的特殊之处 Next.js 基于文件系统自动实现路由级 Code Splitting,无需手动 lazy()。 在
/app目录中,每个页面文件就是一个独立的 route segment → 自动拆包。
组件级拆分
对体积大、使用频率低的第三方库进行动态导入:
import dynamic from "next/dynamic";
// SSR 关闭——图表库依赖 window,服务端没有
const Chart = dynamic(() => import("recharts"), { ssr: false });
// 带 loading fallback——提升感知体验
const HeavyEditor = dynamic(
() => import("@monaco-editor/react"),
{ loading: () => <p>正在加载编辑器...</p>, ssr: false }
);
// React 原生写法——配合 Suspense
const MapComponent = lazy(() => import("./MapComponent"));
// 可指定 preloadStrategy——预加载策略
const PrefetchModal = lazy(
() => import("./HeavyModal")
);
Bundle 分析与优化
# Next.js 项目
npx next-bundle-analyzer
# Vite / Webpack 通用方案
npm install --save-dev rollup-plugin-visualizer
npm run build && npx visualizer
[!tip] Bundle 大小目标
层级 目标大小(gzipped) 首屏 chunk < 150KB 单个 chunk < 300KB JS Total < 500KB(SPA)/ < 200KB(PWA)
[!tip] 减小 bundle 的三个高效手段
- 替换大库——
lodash→lodash-es(tree-shaking 友好),或直接用原生 API- 检查重复依赖——
npm ls react看有没有多份 React 实例- 静态资源不进 bundle——图片、视频等上传到 CDN,URL 写在代码里
Lighthouse 关键指标调优
| 指标 | 含义 | 优化方向 |
|---|---|---|
| FCP(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积、使用 CDN |
| LCP(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源、优先渲染 |
| INP(Interaction to Next Paint) | 交互响应延迟(取代 FID) | useTransition、删除同步 heavy work、Web Worker 分流 |
| CLS(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁、占位广告容器 |
React 并发优化 —— startTransition / useDeferredValue
React 18 引入的并发特性是解决 "交互卡顿" 最直接的手段。它们让 React 能够优先级调度。
import { useState, startTransition } from "react";
function SearchApp() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
// ❌ 普通 setState:阻塞 UI,用户输入时感知到延迟
// setSearchResults(heavySearch(query));
// ✅ startTransition:标记为低优先级,不阻塞当前输入
function handleChange(e: string) {
setQuery(e); // 高优先级——立即更新
startTransition(() => {
setResults(heavySearch(e)); // 低优先级——可以中断
});
}
return <input value={query} onChange={handleChange} />;
}
与 Suspense 配合的最佳实践
// 路由级懒加载 + 过渡状态
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={
<Dashboard />
} />
</Routes>
</Suspense>
[!tip] startTransition vs useDeferredValue 的选择
场景 推荐方案 表单输入 → 搜索结果 startTransition长列表滚动 → 非关键区域降级 useDeferredValue异步数据加载(路由级) Suspense+lazy复杂计算(不在渲染中) Web Worker / TanStack Query
[!summary] 性能优化 checklist
- 用 Profiler 确认瓶颈位置
- 高频重渲染组件加
React.memo- 派生计算用
useMemo,回调用useCallback- 大数据列表用虚拟滚动
- 大库和低频页面做 Code Splitting
- 耗时操作放进
startTransition- Lighthouse 评分达标?Web Vitals 监控上线?
关联笔记
- 06-性能优化 Hooks — useMemo、useCallback、useTransition 等 Hook 的详细用法
- 13-并发特性 — React 18 并发渲染机制深入解析
- 04-State 与不可变性 — State 更新模式对性能的影响
- 09-状态管理 — 全局状态管理的性能考量(Zustand / Redux / Jotai)
- 14-Serverside Rendering — SSR / RSC 的首屏性能优势
- 16-测试 — 性能回归测试:React Testing Library + Performance Assertions