性能优化最怕「凭感觉瞎调」。本章先建立指标与预算体系,再按「加载 → 渲染 → 运行时」三条链路逐个击破,最后落地监控与回归机制。学完本章,你能完成一次「发现问题 → 定位根因 → 优化 → 数据验证」的完整闭环。
性能工作从「能说出数字」开始。
| 指标 | 全称/含义 | 衡量 | 良好目标 |
|---|---|---|---|
| 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 面板的快照对比法找出来并修复。记录完整的排查步骤。
性能题面试必考,务必能「说出数字 + 说出手段」。