React 性能优化 是做什么的?
适用于 React 18/19 和 Next.js 的性能优化模式,改编自 Vercel Labs react-best-practices(MIT,v1.0.0)。此技能按优先级组织规则,并为主动代码审查和重构提供决策树指导。
何时激活
- 编写或审查 React/Next.js 代码以优化性能
- 诊断页面加载缓慢、交互迟滞或客户端 CPU 占用过高问题
- 审查包体积或 Lighthouse Core Web Vitals 回归
- 消除服务端组件 / API 路由中的请求瀑布流
- 减少客户端重渲染
- 优化长列表、动画或水合(hydration)
- 审查涉及
app/、pages/、components/或数据层的 PR 中的优化选择
优先级索引
| 优先级 | 类别 | 前缀 | 适用场景 |
|---|---|---|---|
| 1 — 致命 | 消除瀑布流 | async- | 任何时候 await 后跟独立的 await |
| 2 — 致命 | 包体积优化 | bundle- | 首次加载的 JS、路由级导入、第三方库 |
| 3 — 高 | 服务端性能 | server- | RSC、Server Actions、API 路由、SSR |
| 4 — 中高 | 客户端数据请求 | client- | SWR / TanStack Query / hooks 中的原生 fetch |
| 5 — 中 | 重渲染优化 | rerender- | 高频状态更新、父子组件扇出 |
| 6 — 中 | 渲染性能 | rendering- | 长列表、动画、水合 |
| 7 — 低中 | JavaScript 性能 | js- | 热点循环、频繁分配 |
| 8 — 低 | 高级模式 | advanced- | Effect-event 集成、稳定引用 |
1. 消除瀑布流(致命)
“瀑布流是 #1 的性能杀手”——每个顺序的
await都会增加完整的网络延迟。
在 await 之前检查廉价条件
在等待远程数据之前,先检查同步条件(props、环境变量、硬编码的标志)。
// 错误
async function Page({ id }: { id: string }) {
const flag = await getFlag("show-page");
if (!flag || !id) return null;
const data = await getData(id);
// ...
}
// 正确 — 先通过廉价同步条件短路
async function Page({ id }: { id: string }) {
if (!id) return null;
const flag = await getFlag("show-page");
if (!flag) return null;
const data = await getData(id);
}
延迟 await 直到使用
将 await 移到使用它的分支中。
// 错误 — 在决定是否需要数据之前就 await
const user = await getUser(id);
if (mode === "guest") return renderGuest();
return renderUser(user);
// 正确
if (mode === "guest") return renderGuest();
const user = await getUser(id);
return renderUser(user);
Promise.all 用于独立工作
// 错误 — 顺序执行
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
// 正确 — 并行执行
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
部分依赖 — 提前开始,延迟 await
// 正确 — 启动所有 promise,只在需要结果时才 await
const userP = getUser(id);
const postsP = getPosts(id);
const profile = await getProfile(id);
if (profile.private) return null;
const [user, posts] = await Promise.all([userP, postsP]);
使用 Suspense 进行流式传输
将 <Suspense> 边界推近数据,以便页面可以在较慢的子树流式传输时渲染其能渲染的内容。权衡:内容到达时可能发生布局偏移——预留空间(骨架屏或 min-height)。
服务端组件:通过组合实现并行
// 错误 — 一个组件内的兄弟 await 顺序执行
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// 正确 — 拆分为子组件,React 并行运行它们
export default async function Page() {
return (
<View>
<UserSection />
<CartSection />
</View>
);
}
2. 包体积优化(致命)
直接导入,而非 barrel 文件
Barrel index.ts 文件迫使打包器遍历整个模块图,即使 tree-shaking 移除了大部分内容。在许多真实应用中,直接导入可以节省 200-800ms 的首次加载 JS。
// 错误
import { Button, Card, Modal } from "@/components";
// 正确
import { Button } from "@/components/Button";
import { Card } from "@/components/Card";
import { Modal } from "@/components/Modal";
Next.js 13.5+ 具有 Optimize Package Imports 功能,可自动为列出的包执行此操作——请使用它;对于未列出的库,仍需要手动直接导入。
静态可分析路径
// 错误 — 破坏打包器/跟踪分析
const mod = await import(`./pages/${name}`);
// 正确 — 每个分支显式定义
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
对重组件使用动态导入
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton />,
ssr: false, // 当仅客户端时
});
延迟第三方脚本
在水合之后加载分析、日志记录、支持组件。使用 next/script 并设置 strategy="afterInteractive"(默认)或 "lazyOnload"。
条件模块加载
if (user.role === "admin") {
const { AdminPanel } = await import("./admin/AdminPanel");
// ...
}
悬停/聚焦时预加载
在悬停时触发 <link rel="preload"> 或 import(),以便在用户点击时包已在缓存中。
3. 服务端性能(高)
像 API 路由一样验证 Server Actions
每个 "use server" 函数都是一个公共端点。在 action 内部进行身份验证和授权——永远不要依赖调用它的客户端组件的门控。
"use server";
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const targetId = String(formData.get("id"));
if (session.user.role !== "admin" && session.user.id !== targetId) {
throw new Error("Forbidden");
}
await db.user.delete({ where: { id: targetId } });
}
React.cache() 用于每次请求的去重
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
React.cache 在单个请求内去重。从三个服务端组件调用 getUser("1") 在同一渲染中=一次数据库查询。
跨请求数据的 LRU 缓存
对于不随请求变化的数据(配置、查找表),使用 LRU 缓存或 unstable_cache 在 React 外部缓存。
避免 RSC props 中的重复序列化
当一个服务端组件将相同的数据渲染到多个客户端组件时,数据会为每个消费者序列化一次。将客户端组件向上提升并传递 children。
将静态 I/O 提升到模块作用域
// 正确 — 在模块加载时运行一次
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
RSC/SSR 中禁止可变的模块级状态
服务器上的模块状态在所有请求之间共享——用户之间存在竞态条件。使用请求作用域的存储(headers()、cookies()、异步上下文)代替。
最小化传递给客户端组件的数据
只序列化客户端所需的内容。在数据库层剥离字段、分页、投影列。
使用 Promise.all 并行化每个项目的嵌套获取
const users = await getUsers();
const enriched = await Promise.all(
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
);
使用 after() 进行非阻塞工作
Next.js 15 的 after() 在响应发送后运行工作——日志记录、缓存预热、分析。
import { after } from "next/server";
export async function GET() {
const data = await getData();
after(() => logAnalytics(data));
return Response.json(data);
}
4. 客户端数据请求(中高)
SWR / TanStack Query 用于去重
多个组件调用 useUser(id) 应共享一个网络请求和一个缓存条目。使用 SWR 或 TanStack Query——绝不应对共享数据使用自己编写的 useEffect + fetch。
去重全局事件监听器
// 错误 — 每个组件添加自己的
useEffect(() => {
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// 正确 — 通过钩子 + 全局主题实现单一共享监听器
const useScroll = createScrollHook(); // 底层为单例主题
滚动的被动监听器
window.addEventListener("scroll", handler, { passive: true });
提升滚动流畅度;监听器不能调用 preventDefault()。
localStorage:版本化 + 最小化
- 始终存储
version字段;模式更改时递增版本号,并迁移或丢弃旧数据 - 保持负载小巧——
localStorage是同步的,会阻塞主线程
5. 重渲染优化(中)
不要订阅仅在回调中使用的状态
// 错误 — 每次 count 发生变化时都会重渲染
const count = useStore((s) => s.count);
const handler = () => doSomething(count);
// 正确 — 在调用时读取一次
const handler = () => {
const count = useStore.getState().count;
doSomething(count);
};
将昂贵的工作提取到记忆化组件中
// 正确 — 子组件仅当 `items` 变化时才重渲染
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
return <Chart data={transform(items)} />;
});
提升默认的非原始 props
// 错误 — 每次渲染创建新数组,破坏 memo
<List items={items ?? []} />
// 正确
const EMPTY: Item[] = [];
<List items={items ?? EMPTY} />
effect 中的原始依赖项
// 错误 — 每次渲染创建新对象引用
useEffect(() => {}, [{ id, name }]);
// 正确 — 原始值
useEffect(() => {}, [id, name]);
订阅派生布尔值,而非原始值
// 错误 — 任何购物车更改都会重渲染
const cart = useStore((s) => s.cart);
const hasItems = cart.length > 0;
// 正确 — 仅当空/非空状态翻转时才重渲染
const hasItems = useStore((s) => s.cart.length > 0);
在渲染期间派生,绝不通过 useEffect
// 错误
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// 正确
const full = `${first} ${last}`;
使用函数式 setState 获取稳定的回调
// 正确
const increment = useCallback(() => setCount((c) => c + 1), []);
对昂贵值使用惰性状态初始化器
const [tree] = useState(() => parseTree(largeInput));
避免对简单原始值使用 memo
useMemo(() => x + 1, [x]) 是开销。Memo 在对象引用和昂贵计算上才值得使用。
拆分具有独立依赖项的钩子
// 错误 — 如果任一源发生变化,两个选择器都会重新运行
const { a, b } = useSomething(source1, source2);
// 正确
const a = useA(source1);
const b = useB(source2);
将交互逻辑移到事件处理程序中
事件处理程序仅在用户操作时运行——useEffect 在依赖项变化时重新运行。
对非紧急更新使用 startTransition
const [pending, startTransition] = useTransition();
startTransition(() => setFilters(newFilters));
对昂贵渲染使用 useDeferredValue
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
对瞬态频繁值使用 useRef
对于变化频繁但不应触发重渲染的值(时间戳、上次按键、累加器)。
不要在组件内部定义组件
// 错误 — 每次 Outer 渲染都会创建新的 Inner 组件
function Outer() {
const Inner = () => <span />;
return <Inner />;
}
每次渲染都会创建一个新的 Inner 类型,这会破坏协调并卸载子组件。
6. 渲染性能(中)
动画化容器而非 SVG
围绕 SVG 转换 <div> 容器是 GPU 加速的;而转换 SVG 本身会触发绘制。
对长列表使用 content-visibility: auto
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
浏览器跳过屏幕外渲染——对于包含数百行的列表,这是一个重大的优化。
提升静态 JSX
const STATIC_HEADER = <h1>Title</h1>;
function Page() {
return <>{STATIC_HEADER}<Body /></>;
}
SVG:降低坐标精度
d="M10.123456,20.654321" → d="M10.12,20.65"。每个数字都会消耗字节;视觉差异在亚像素级别。
通过内联脚本实现无闪烁水合
对于水合前所需的值(主题、语言环境),内联一个 <script>,在 React 挂载之前设置 document.documentElement.dataset.*。
窄范围内抑制预期的水合不匹配
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
仅对已知会分歧的叶子节点使用——切勿用于包含其他子节点的树。
使用 <Activity> 进行显示/隐藏而非挂载/卸载
React 19 的 <Activity mode="visible|hidden"> 保持树状态和 effects 挂载但隐藏——对于标签页和折叠面板来说,比卸载/重新挂载更廉价。
条件渲染使用三元表达式而非 &&
// 错误 — `0` 会渲染为文本节点
{count && <Badge>{count}</Badge>}
// 正确
{count > 0 ? <Badge>{count}</Badge> : null}
useTransition 用于加载状态
将 startTransition 与 action 配对;React 在下一个状态计算期间,会显示先前的 UI 并同时设置 isPending。
React DOM 资源提示
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com")