REACT · Vol.VI · LESSON 41 · 性能优化与部署
性能监控与 React Profiler
两套指标,两把尺子
性能卷走到这里,你手里攒了一整套手段:第 37 篇的 memo、第 38 篇的虚拟列表、第 39 篇的拆包、第 40 篇的构建。但后端老兵都懂:不会测量就等于不会优化——没看监控就改代码,那叫玄学调优。
动手之前先分清:前端性能其实是两套完全不同的指标,量的是两件事。
| 指标体系 | 测什么 | 用户的抱怨 | 后端类比 |
|---|---|---|---|
| 运行时渲染性能 | 交互之后界面多快更新:点击、输入、滚动 | 「点了没反应」「打字掉帧」 | 接口 RT(P99 尾延迟最要命) |
| 页面加载性能 | 从输入 URL 到内容可用的全过程 | 「白屏好几秒」「一直转圈」 | 冷启动到可对外服务的时间 |
两套指标用不同工具测:运行时找 React DevTools 的 Profiler,加载找 Lighthouse 和 Web Vitals。搞混的代价是真金白银:首屏慢的人拼命加 memo(方向错了),列表卡的人忙着拆包(白忙一场)。
后端你不会「感觉接口慢」就动手——先看 APM 的 P95/P99、慢调用榜单,再 profile 到方法。前端同款:Profiler 是火焰图,Lighthouse 是压测报告,Web Vitals 是 SLA 指标。本篇把这套流程装进肌肉记忆。
顺便立个规矩:优化必须「一次只改一处,改完复测」。攒一堆优化一起上线,好了不知谢谁、坏了不知赖谁——和你不会把十个重构混进一个提交,是同一个道理。
Profiler:前端版火焰图
装好 React Developer Tools 浏览器扩展后,DevTools 会多出 Profiler 标签。使用流程固定四步:
- 录制:点 Profiler 左上角的圆点开始记录;
- 操作:在页面上做一遍真实用户动作——输入搜索、勾选待办;
- 停止:得到火焰图(Flamegraph);
- 归因:找两类异常——渲染次数多(不该重渲染的也渲染了)和单次耗时长(self time 高的组件)。
用过 JProfiler 或 async-profiler 的话,这张图不用教:横向是时间,条越宽越耗时,嵌套对应组件树。差别只有一个维度:Profiler 还统计每个组件渲染了几次——「次数」是 React 特有的病灶。
准备一个「看起来人畜无害」的列表页当靶子:
import { useState } from "react";
type Todo = { id: number; title: string; done: boolean };
function buildSeed(): Todo[] {
return []; // 造 300 条测试数据,从略
}
export default function ListPage() {
const [todos, setTodos] = useState<Todo[]>(buildSeed);
const [keyword, setKeyword] = useState("");
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
const visible = todos.filter((t) => t.title.includes(keyword));
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul>
{visible.map((t) => (
<TodoRow key={t.id} todo={t} onToggle={() => toggle(t.id)} />
))}
</ul>
</>
);
}
function TodoRow({ todo, onToggle }: { todo: Todo; onToggle: () => void }) {
console.log("render:", todo.id); // 面板里刷屏的那行日志就是它
return (
<li onClick={onToggle}>
{todo.title} {todo.done ? "✓" : ""}
</li>
);
}
录制后在输入框里敲一个字:ListPage 重渲染是必要的(keyword 变了),但 300 个 TodoRow 的条全部亮起,console 刷屏 300 行 render 日志——输入一个字母,300 行重算,这就是 Profiler 给你的实锤。录制时一次只测一个场景,先关掉无关的轮询和动画,变量不隔离,火焰图就是一锅粥。
定位到病灶:重渲染三大来源
火焰图只负责指认「谁在多余的渲染」,开药方要回到第 37 篇总结的三大来源,逐一比对靶子:
- 父级连带:ListPage 因 keyword 重渲染,子组件默认跟随——React 不知道你会不会用到新的 props;
- props 引用不稳定:
() => toggle(t.id)每次渲染都生成新函数,对 TodoRow 来说 props 「变了」; - Context 整包变化:本例没有,但 Provider 的 value 是大对象时,改一个字段全家遭殃(拆 Context 第 37 篇讲过)。
对症下药,两个改动:
import { useState, useCallback, memo } from "react";
// memo:props 浅比较全相等就跳过重渲染(第 37 篇)
interface RowProps { todo: Todo; onToggle: (id: number) => void; }
const TodoRow = memo(function TodoRow({ todo, onToggle }: RowProps) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.title} {todo.done ? "✓" : ""}
</li>
);
});
export default function ListPage() {
const [todos, setTodos] = useState<Todo[]>(buildSeed);
const [keyword, setKeyword] = useState("");
// useCallback:把回调引用钉住,memo 才有得比
const toggle = useCallback((id: number) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}, []);
const visible = todos.filter((t) => t.title.includes(keyword));
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul>
{visible.map((t) => (
<TodoRow key={t.id} todo={t} onToggle={toggle} />
))}
</ul>
</>
);
}
注意 onToggle 变了:不再是每行一个新闭包,而是把 id 当参数传出去,引用全局稳定。复测:再敲一个字,TodoRow 一条不亮——todo 引用没变(filter 返回原对象)、toggle 被 useCallback 钉住,memo 全部命中;ListPage 自身照旧重渲染,那是必要成本。两个补刀:
- memo 不是免费的:浅比较也花时间,十几行的小组件不值得包。顺序永远是 Profiler 实锤 → 再 memo,反过来就是第 37 篇批评过的「无脑全面 memo」;
- 行数上千还卡:memo 挡得住「不该渲染的」,挡不住「必须渲染的 500 行 DOM」。这时换武器——第 38 篇的虚拟列表,只渲染可视区那 20 行。
memo 跳过重渲染的前提是 props 逐项「相等」。你每次传新 lambda 过去,等于每次都用新 key 查缓存——永远 miss。useCallback / useMemo(第 11 篇)的意义就是把「缓存 key」钉稳,函数组件优化的硬前提。
加载性能:Web Vitals 与 Lighthouse
运行时解决「卡」,加载要解决「白屏久」。测量必须对着生产包——第 40 篇说过 dev/build 是两条链路,dev 没有压缩分包,指标全偏悲观。正确姿势:npm run build 后 vite preview 起本地服务,Lighthouse 对产物测。
加载性能的行业标准是 Google 的 Web Vitals 三件套:
| 指标 | 达标线 | 测什么 | 后端可观测性类比 |
|---|---|---|---|
| LCP(最大内容绘制) | ≤ 2.5s | 首屏最大元素渲染完成,≈「页面可看了」 | 冷启动到首响 |
| INP(交互到下一帧) | ≤ 200ms | 全程所有交互延迟的近最差值 | P99 RT |
| CLS(累积布局偏移) | ≤ 0.1 | 元素突然跳动、「骗走」用户点击的程度 | 错误率 / 事故率 |
三个数字都有官方达标线,过线即「良好」。它们来自真实用户:上线后用 web-vitals 库上报到监控后台,就是前端的埋点。顺带纠正:2024 年 3 月起 INP 已正式取代 FID,还在教 FID 的教程可以合上了。
Lighthouse 用法:DevTools 的 Lighthouse 面板选 Performance、Mobile、模拟限速,一键出报告——分数、指标实测值,还会把「首屏加载 2.1 MB JS」这类问题列成改进项清单。最常见的就是包体积过大,用 bundle 分析定位:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({ filename: "stats.html", gzipSize: true }),
],
});
build 后打开 stats.html:每个方块是一个模块,面积即体积占比。一眼看到 echarts 占 40%?第 39 篇的 lazy 和 manualChunks 就知道该切谁了。
后端拿到 200 MB 的 fat jar,你会看依赖树找最重的传递依赖、查重复引入。stats.html 就是前端的「依赖体积树」——rollup-plugin-visualizer 之于 bundle,约等于 mvn dependency:tree 之于 jar,都是把「黑盒的胖」拆成「可归因的胖」。
案例走查与 SOP
把本卷工具串成两条案例主线,从症状到药方走一遍:
| 案例 | 症状 | 测量结果 | 归因 | 药方 |
|---|---|---|---|---|
| 列表页卡 | 输入搜索词时掉帧 | Profiler:每次按键 300 个 TodoRow 全重渲染 | 父级连带 + 内联回调引用不稳 | memo + useCallback;上千行上虚拟列表(第 38 篇) |
| 首屏慢 | Lighthouse 报 LCP 4.8s | Network:首屏拉 2.1 MB JS,其中 echarts 占 1 MB | 单 bundle 全量加载 | 路由级 lazy + 重库级拆分 + hover 预取(第 39 篇) |
两条案例走的是同一条路,值得固化成 SOP——先测量、再归因、后优化:
- 先测量:拿数字。Profiler 录制、Lighthouse 报告、Network 面板截图,没有数字不动手;
- 再归因:把数字落到具体对象——哪个组件渲染多了、哪个模块体积大了。归因不到组件 / 模块级别的优化,都是赌博;
- 后优化:一处改动,复测确认收益,再进行下一处。药方永远从测量结果里开,不从经验直觉里开。
先看监控锁定慢接口,再用 arthas / JProfiler 定位到方法行,改完回归压测——和「先测量、再归因、后优化」一模一样。跳过前两步直接「我觉得加个缓存就好了」,在哪个技术栈里都是事故来源。
- 前端性能两套指标:运行时渲染(≈接口 RT)用 Profiler 测,页面加载(≈冷启动)用 Lighthouse + Web Vitals 测,先分清再动手。
- Profiler 流程:录制 → 复现场景 → 火焰图 → 找「渲染次数多 / self time 高」的组件 → 修复 → 复测,前端版 JProfiler。
- 不必要重渲染三大来源:父级连带、props 引用不稳定、Context 整包变化;武器是 memo、useCallback / useMemo、拆 Context(第 37 篇)。
- Web Vitals 达标线:LCP ≤ 2.5s、INP ≤ 200ms(已取代 FID)、CLS ≤ 0.1;Lighthouse 必须测生产包,stats.html 归因 bundle 体积。
- 固化 SOP:先测量、再归因、后优化,一次一处、改完复测。
本章回顾
本篇为性能卷收口:memo、虚拟列表、拆包,全部装进「先测量、再归因、后优化」的流程里,像排查慢接口一样排查慢页面。至此开发、构建、优化都齐了,只差最后一公里——怎么把 dist 交到用户手上:Nginx 怎么配、CDN 怎么挂、CI 怎么跑。下一篇第 42 篇「部署」,为第六卷收官。
Comments · 评论