性能优化最怕「凭感觉瞎调」。本章先建立指标与预算体系,再按「加载 → 渲染 → 运行时」三条链路逐个击破,最后落地监控与回归机制。学完本章,你能完成一次「发现问题 → 定位根因 → 优化 → 数据验证」的完整闭环。
性能工作从「能说出数字」开始。
| 指标 | 全称/含义 | 衡量 | 良好目标 |
|---|---|---|---|
| FCP | First Contentful Paint | 第一个内容(文字/图片)出现 | ≤ 1.8s |
| LCP | Largest Contentful Paint | 最大内容(主视觉/标题)渲染 | ≤ 2.5s |
| INP | Interaction to Next Paint | 交互响应(新一代核心指标,替代 FID) | ≤ 200ms |
| CLS | Cumulative Layout Shift | 布局稳定性(页面跳动程度) | ≤ 0.1 |
| TBT | Total Blocking Time | 主线程被长任务阻塞的总时长 | ≤ 200ms |
目标:让首屏资源「更少、更小、更早、更合理缓存」。
// 路由级代码分割(每个路由一个 chunk,进入才加载)
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
// 组件级懒加载(弹窗/图表等大组件,用到才加载)
const Chart = lazy(() => import("./components/BigChart"));
检查工具:构建后用体积分析插件看每个 chunk 的大小,揪出「体积刺客」(比如整个图表库被塞进首屏)。
<img loading="lazy">,视口外图片不加载。srcset 按屏幕宽度选不同尺寸图片,手机不加载桌面大图。font-display: swap 避免文字不可见;只加载需要的字重;中文字体用子集化。// 响应式图片示例
<img
src="img-800w.jpg"
srcset="img-400w.jpg 400w, img-800w.jpg 800w, img-1600w.jpg 1600w"
sizes="(max-width: 600px) 100vw, 800px"
loading="lazy"
>
// preload:提前加载当前页面立即要用的关键资源(首屏大图、字体)
<link rel="preload" href="hero.webp" as="image">
// prefetch:空闲时提前拉取「下一步可能访问」的资源(下一页路由)
<link rel="prefetch" href="/about">
// preconnect:提前建立第三方源连接(CDN、接口域名)
<link rel="preconnect" href="https://api.example.com" crossorigin>
目标:让页面「不卡」——减少无效渲染与主线程占用。
// 1. memo 组件:props 没变不重渲染
const Item = memo(function Item({ data, onSelect }) {
return <li onClick={() => onSelect(data.id)}>{data.name}</li>;
});
// 2. 状态选择器:只订阅自己需要的部分(阶段六 Zustand 选择器)
const price = useStore(s => s.price);
// 3. 列表项抽组件 + key 稳定:列表更新时只 diff 变化的项
渲染 1 万个 DOM 节点必然卡。虚拟滚动的思路:无论列表多长,只渲染可视区 + 缓冲区的几十行,滚动时更新偏移。
// 成熟库:react-window(轻量)或 react-virtualized(功能全)
import { FixedSizeList as List } from "react-window";
<List
height={600} // 可视区高度
itemCount={100000} // 总条数 10 万
itemSize={40} // 每行高度(固定)
width="100%"
>
{({ index, style }) => (
<div style={style}>第 {index} 行</div> // style 由库计算位置
)}
</List>;
卡顿的根源:主线程被长任务占用、内存持续增长。
主线程单次执行超过 50ms 就是长任务(Long Task),期间用户交互无响应。大计算拆成小片,用空闲时间处理:
// 思路:分批处理,每批让出主线程
async function processBigData(items) {
const BATCH = 100;
for (let i = 0; i < items.length; i += BATCH) {
processBatch(items.slice(i, i + BATCH));
await new Promise(r => setTimeout(r, 0)); // 让出主线程给交互
}
}
// 更优雅:requestIdleCallback 空闲时做(低优先级)
requestIdleCallback(() => preloadNextPage());
// 计算密集任务(图片处理、数据清洗、加密)丢给 Worker
const worker = new Worker("./calc-worker.js");
worker.postMessage({ type: "process", data: bigData }); // 发任务
worker.onmessage = (e) => setResult(e.data); // 收结果
注意:Worker 里不能操作 DOM,通过消息通信;只适合 CPU 密集任务(I/O 密集用异步就够了)。
典型泄漏源与排查手段:
| 泄漏源 | 例子 | 排查/修复 |
|---|---|---|
| 定时器未清理 | setInterval 组件卸载后还在跑 | useEffect 清理函数 clearInterval |
| 事件监听未移除 | window 上监听不卸载 | 监听处返回解绑函数,卸载时执行 |
| 闭包持有大对象 | 回调里引用了大数组 | 用后置 null,或避免长期持有 |
| 全局缓存无限增长 | 缓存 Map 只加不删 | 限制缓存大小,LRU 策略 |
排查工具:DevTools Memory 面板「记录快照 → 操作 → 再快照 → 对比」,找持续增长的对象。
一次优化不难,难的是「持续不退化」。
// RUM 收集核心指标(简版)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
report({ name: entry.name, value: entry.value }); // 上报到监控平台
}
}).observe({ type: "largest-contentful-paint", buffered: true });
本章的实战就是「对自己项目动手」。
对自己任一项目:① 用 Lighthouse 跑分记录基线;② 做至少 5 项优化(代码分割、图片、缓存、memo、骨架屏…);③ 优化后再跑分并记录每项的变化;④ 写一份「优化报告」:问题 → 方案 → 数据对比。这份报告就是你面试的实锤素材。
故意在项目里埋两个泄漏(定时器、事件监听),用 Memory 面板的快照对比法找出来并修复。记录完整的排查步骤。
性能题面试必考,务必能「说出数字 + 说出手段」。
答案:FCP(首次内容绘制)≤ 1.8s、LCP(最大内容绘制)≤ 2.5s、INP(交互到下一次绘制,替代 FID)≤ 200ms、CLS(累积布局偏移)≤ 0.1、TBT(总阻塞时间)≤ 200ms。其中 LCP / INP / CLS 为三大核心指标。
答案:性能预算是给关键指标(体积/时间)设硬性上限。体积预算示例:首屏 JS ≤ 200KB(gzip)、总 JS ≤ 400KB;时间预算示例:LCP ≤ 2.5s、INP ≤ 200ms。落地:CI 流水线中集成 Lighthouse CI 或 webpack-bundle-analyzer 等工具,每次 PR 跑基线对比,超预算直接阻止合并。
答案:路由级分割(每个路由独立 chunk,进入才加载,React 用 lazy(() => import(...)) + Suspense)和组件级分割(弹窗、图表等大组件按需加载)。
答案:① 格式:优先 WebP/AVIF,同质量体积减 30%~50%;② 懒加载:loading="lazy";③ 响应式:srcset 按屏幕宽度(sizes)提供多尺寸候选,浏览器自选合适尺寸,避免手机加载桌面大图;④ 装饰图用 CSS/SVG;⑤ 固定宽高防 CLS。srcset 的作用是根据设备像素比和视口宽度,让浏览器自动选择最经济的图片源,兼顾清晰度与流量。
<link rel="preload" as="image" href="hero.webp"> 提前请求,同时 srcset 在 preload 场景需配合 imagesrcset 属性才能让响应式生效,否则 preload 只能拉一张固定图。答案:① preload(当前页面立即要用):高优先级提前加载首屏关键资源(大图、字体);② prefetch(下一步可能用到):空闲时低优先级拉取下一页路由资源;③ preconnect(提前建联):提前完成 DNS + TCP + TLS 握手,减少第三方源(CDN/接口域名)的建连延迟。
答案:原理:无论列表多长,只渲染可视区 + 缓冲区的几十行 DOM,滚动时通过 translateY 动态更新偏移。时机:列表数据量导致 DOM 节点超 ~500~1000 个时(约几千条以上),或渲染单条复杂时。成熟库:react-window(轻量,固定高)、@tanstack/react-virtual(支持动态高,推荐新项目使用)。
答案:主线程单次执行 > 50ms 即为长任务,期间用户交互无响应。拆分方式:① 分批处理,每批用 setTimeout(fn, 0) 让出主线程;② requestIdleCallback 在浏览器空闲时执行低优先级任务(预加载、埋点上报等)。requestIdleCallback 的作用是把非紧急任务推迟到帧之间的空闲时段执行,不打断用户交互与渲染。
scheduler.yield()(Chrome 115+),语义更清晰且优先级可配置。requestIdleCallback 有 50ms 默认超时(防不被调用),紧急拆分不能用。任务拆分粒度建议每批 ≤ 10ms,给回约 16ms 渲染预算——这样才能保证 60fps。答案:适合 CPU 密集且无 DOM 依赖的任务:图片处理/编解码、大数据排序/过滤/清洗、加密解密、复杂数学计算、JSON 大解析。不能:操作 DOM、访问 window 对象、直接访问 React 状态。通信靠 postMessage 序列化传输(结构化克隆),大数据量用 Transferable 可零拷贝转账。
async/await 就够了(不占主线程),丢 Worker 反而因消息序列化更慢。经验法则:耗时 > 100ms 且 CPU 密集时,Worker 收益才明显。答案:① 定时器未清理(setInterval 组件卸载后还在跑)→ useEffect 返回清理函数;② 事件监听未移除(window/document 监听)→ 返回解绑函数;③ 闭包长期持有大对象(回调引用大数组)→ 用后置 null 或避免长期持有;④ 全局缓存无限增长(Map/Set 只加不删)→ 设容量上限、LRU 淘汰。排查:DevTools Memory 面板「快照 1 → 操作 → 快照 2 → Comparison」,找持续增长的对象。
addEventListener / setInterval / 第三方 store,却忘了 return 清理函数。推荐 ESLint 插件 eslint-plugin-react-hooks 强制检查。另外注意:即使组件卸载,只要定时器回调闭包引用了组件的 state/refs,整棵 Fiber 树都无法 GC。答案:Lab(实验室监控):固定设备、固定网络跑 Lighthouse 等工具,稳定可复现,适合捕捉「构建引入的回归」,卡阈值门禁;RUM(真实用户监控):通过 PerformanceObserver 收集线上真实用户的指标,代表真实体验(弱网/低端机/缓存命中率)。两者都要是因为:Lab 验「有没有引入问题」,RUM 验「用户真实感受到没有」——Lab 高分但 RUM 差,说明样本偏差。