首页  |  学习总览  |  ← 上一章:状态管理 阶段七 · 第 7 章,共 10 章
阶段七 · 预计 70 小时

性能优化实战

性能优化最怕「凭感觉瞎调」。本章先建立指标与预算体系,再按「加载 → 渲染 → 运行时」三条链路逐个击破,最后落地监控与回归机制。学完本章,你能完成一次「发现问题 → 定位根因 → 优化 → 数据验证」的完整闭环。

1先建立指标:没有度量就没有优化

性能工作从「能说出数字」开始。

1.1 核心 Web 指标(背下来)

指标全称/含义衡量良好目标
FCPFirst Contentful Paint第一个内容(文字/图片)出现≤ 1.8s
LCPLargest Contentful Paint最大内容(主视觉/标题)渲染≤ 2.5s
INPInteraction to Next Paint交互响应(新一代核心指标,替代 FID)≤ 200ms
CLSCumulative Layout Shift布局稳定性(页面跳动程度)≤ 0.1
TBTTotal Blocking Time主线程被长任务阻塞的总时长≤ 200ms
💡 优化的优先级建议 首屏(FCP/LCP)→ 交互(INP)→ 稳定性(CLS)。先解决「用户第一眼和第一次点击」,收益最大。

1.2 性能预算(Performance Budget)

  • 体积预算:首屏 JS ≤ 200KB(gzip 后)、总 JS ≤ 400KB。
  • 时间预算:LCP ≤ 2.5s、INP ≤ 200ms。
  • 落地:CI 里用体积分析工具对比每次构建,超预算直接构建失败——性能问题在合并前被拦住。
2加载链路优化

目标:让首屏资源「更少、更小、更早、更合理缓存」。

2.1 代码分割与懒加载

// 路由级代码分割(每个路由一个 chunk,进入才加载)
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));

// 组件级懒加载(弹窗/图表等大组件,用到才加载)
const Chart = lazy(() => import("./components/BigChart"));

检查工具:构建后用体积分析插件看每个 chunk 的大小,揪出「体积刺客」(比如整个图表库被塞进首屏)。

2.2 资源优化

  • 图片:优先 WebP/AVIF(同质量体积小 30%~50%);装饰图用 CSS/SVG;图标用字体图标或雪碧图。
  • 懒加载图片:<img loading="lazy">,视口外图片不加载。
  • 响应式图片:srcset 按屏幕宽度选不同尺寸图片,手机不加载桌面大图。
  • 字体:font-display: swap 避免文字不可见;只加载需要的字重;中文字体用子集化。
  • 依赖体检:定期用体积分析工具找大依赖,能换轻量的就换(如日期库 dayjs 替代 moment)。
// 响应式图片示例
<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"
>

2.3 预加载策略(让浏览器提前干活)

// 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>

2.4 缓存体系(阶段三的落地)

  • 静态资源:文件名带哈希 + 长缓存(一年),内容变则 URL 变自动拉新。
  • HTML:no-cache 每次协商,保证入口最新。
  • 接口:GET 类数据用请求缓存库缓存;敏感数据绝不进 CDN。
  • 离线兜底(进阶):Service Worker 缓存壳子,弱网秒开。
3渲染链路优化

目标:让页面「不卡」——减少无效渲染与主线程占用。

3.1 减少无效渲染

// 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 变化的项

3.2 大列表:虚拟滚动

渲染 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>;

3.3 骨架屏与占位

  • 数据加载中用骨架屏(灰色块模拟布局),比转圈体验好得多——用户「先看到结构,内容到了再填充」。
  • 图片加载中给固定宽高(防止 CLS)或模糊占位(LQIP)。
  • 配合 Suspense + 流式渲染,实现「边下载边显示」。
4运行时与内存优化

卡顿的根源:主线程被长任务占用、内存持续增长。

4.1 长任务拆分

主线程单次执行超过 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());

4.2 Web Worker:把重活挪出主线程

// 计算密集任务(图片处理、数据清洗、加密)丢给 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 密集用异步就够了)。

4.3 内存泄漏排查

典型泄漏源与排查手段:

泄漏源例子排查/修复
定时器未清理setInterval 组件卸载后还在跑useEffect 清理函数 clearInterval
事件监听未移除window 上监听不卸载监听处返回解绑函数,卸载时执行
闭包持有大对象回调里引用了大数组用后置 null,或避免长期持有
全局缓存无限增长缓存 Map 只加不删限制缓存大小,LRU 策略

排查工具:DevTools Memory 面板「记录快照 → 操作 → 再快照 → 对比」,找持续增长的对象。

5监控与治理:让性能不退化

一次优化不难,难的是「持续不退化」。

5.1 三层监控体系

  • 实验室监控(Lab):Lighthouse CI 每次构建跑分,卡阈值。
  • 真实用户监控(RUM):线上用 PerformanceObserver 收集真实用户的核心指标,上报看板。真实环境才代表真实体验。
  • 回归对比:每次发版对比核心指标,劣化立即告警回滚。
// RUM 收集核心指标(简版)
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    report({ name: entry.name, value: entry.value });  // 上报到监控平台
  }
}).observe({ type: "largest-contentful-paint", buffered: true });
💡 性能优化的正确姿势(总结) ① 先量化(指标 + 预算);② 再定位(DevTools / 监控定位瓶颈在加载、渲染还是运行时);③ 用数据验证(优化前后对比);④ 上机制防退化(CI 门禁 + RUM 监控)。永远别「为了优化而优化」——没有数据支撑的优化都是玄学。
6实战项目(通关必做)

本章的实战就是「对自己项目动手」。

项目一:性能优化闭环

对自己任一项目:① 用 Lighthouse 跑分记录基线;② 做至少 5 项优化(代码分割、图片、缓存、memo、骨架屏…);③ 优化后再跑分并记录每项的变化;④ 写一份「优化报告」:问题 → 方案 → 数据对比。这份报告就是你面试的实锤素材。

项目二:内存泄漏排查实战

故意在项目里埋两个泄漏(定时器、事件监听),用 Memory 面板的快照对比法找出来并修复。记录完整的排查步骤。

7通关自检题

性能题面试必考,务必能「说出数字 + 说出手段」。

1. 核心 Web 指标有哪些?各自良好阈值是多少?

答案:FCP(首次内容绘制)≤ 1.8s、LCP(最大内容绘制)≤ 2.5s、INP(交互到下一次绘制,替代 FID)≤ 200ms、CLS(累积布局偏移)≤ 0.1、TBT(总阻塞时间)≤ 200ms。其中 LCP / INP / CLS 为三大核心指标。

深度:为什么记住阈值不够
面试官真正想考察的是「优先级意识」——先攻首屏(FCP/LCP 影响跳出率),再攻交互(INP 影响留存),最后稳定(CLS 影响体感)。TBT 虽非用户直接感知指标,但与 INP 高度相关,是实验室定位交互问题的重要代理指标。

2. 什么是性能预算?怎么落地到 CI?

答案:性能预算是给关键指标(体积/时间)设硬性上限。体积预算示例:首屏 JS ≤ 200KB(gzip)、总 JS ≤ 400KB;时间预算示例:LCP ≤ 2.5s、INP ≤ 200ms。落地:CI 流水线中集成 Lighthouse CI 或 webpack-bundle-analyzer 等工具,每次 PR 跑基线对比,超预算直接阻止合并。

深度:预算不是拍脑袋
预算应基于「当前最差可接受体验」设定,然后逐步收紧。没有预算的性能优化是主观的,有预算才能把性能变成工程治理问题而非个人自觉。同时注意区分 Lab 预算和 RUM 预算,RUM 要考虑长尾用户(慢网/低端机),通常更宽松。

3. 代码分割的两种方式?怎么做?

答案:路由级分割(每个路由独立 chunk,进入才加载,React 用 lazy(() => import(...)) + Suspense)和组件级分割(弹窗、图表等大组件按需加载)。

深度:别「为了分割而分割」
分割是有代价的——每个 chunk 有网络往返开销。关键是排查「谁在首屏被加载了却用不到」:用 coverage 工具或 bundle analyzer 找体积刺客(图表库、编辑器大王)。一个 20KB 的首屏必需库优于拆成 5 个 4KB 的 chunk,网络往返可能更贵。

4. 图片优化有哪些手段?srcset 是干什么的?

答案:① 格式:优先 WebP/AVIF,同质量体积减 30%~50%;② 懒加载:loading="lazy";③ 响应式:srcset 按屏幕宽度(sizes)提供多尺寸候选,浏览器自选合适尺寸,避免手机加载桌面大图;④ 装饰图用 CSS/SVG;⑤ 固定宽高防 CLS。srcset 的作用是根据设备像素比和视口宽度,让浏览器自动选择最经济的图片源,兼顾清晰度与流量。

深度:配合 preload 提前 LCP 图
如果 LCP 元素就是 hero 大图,应 <link rel="preload" as="image" href="hero.webp"> 提前请求,同时 srcset 在 preload 场景需配合 imagesrcset 属性才能让响应式生效,否则 preload 只能拉一张固定图。

5. preload / prefetch / preconnect 的区别?

答案:① preload(当前页面立即要用):高优先级提前加载首屏关键资源(大图、字体);② prefetch(下一步可能用到):空闲时低优先级拉取下一页路由资源;③ preconnect(提前建联):提前完成 DNS + TCP + TLS 握手,减少第三方源(CDN/接口域名)的建连延迟。

深度:预取实测收益
preconnect 对第三方源效果最稳定——每次新域名访问节省 100~150ms 握手时间。preload 对首屏渲染加速明显(不要超过 6~8 个关键资源)。prefetch 依赖预测命中率,常见做法是鼠标 hover 路由链接时动态注入 prefetch link,命中时几乎秒切页面。

6. 虚拟滚动的原理?什么时候该用?

答案:原理:无论列表多长,只渲染可视区 + 缓冲区的几十行 DOM,滚动时通过 translateY 动态更新偏移。时机:列表数据量导致 DOM 节点超 ~500~1000 个时(约几千条以上),或渲染单条复杂时。成熟库:react-window(轻量,固定高)、@tanstack/react-virtual(支持动态高,推荐新项目使用)。

深度:虚拟滚动的坑
① 动态行高需先测量再渲染,react-virtual 用 estimate + 测量校正;② 无法原生支持 Ctrl+F 浏览器内搜索,需自行处理;③ SEO/首屏需服务端渲染或预渲染首屏;④ 快速滚动可能白屏——加 oversized buffer 或骨架屏兜底。

7. 长任务是什么?怎么拆?requestIdleCallback 干什么?

答案:主线程单次执行 > 50ms 即为长任务,期间用户交互无响应。拆分方式:① 分批处理,每批用 setTimeout(fn, 0) 让出主线程;② requestIdleCallback 在浏览器空闲时执行低优先级任务(预加载、埋点上报等)。requestIdleCallback 的作用是把非紧急任务推迟到帧之间的空闲时段执行,不打断用户交互与渲染。

深度:setTimeout(0) vs rIC vs yield
现代浏览器支持 scheduler.yield()(Chrome 115+),语义更清晰且优先级可配置。requestIdleCallback 有 50ms 默认超时(防不被调用),紧急拆分不能用。任务拆分粒度建议每批 ≤ 10ms,给回约 16ms 渲染预算——这样才能保证 60fps。

8. Web Worker 适合什么任务?不能做什么?

答案:适合 CPU 密集且无 DOM 依赖的任务:图片处理/编解码、大数据排序/过滤/清洗、加密解密、复杂数学计算、JSON 大解析。不能:操作 DOM、访问 window 对象、直接访问 React 状态。通信靠 postMessage 序列化传输(结构化克隆),大数据量用 Transferable 可零拷贝转账。

深度:不是所有重活都该给 Worker
Worker 有「创建开销 + 通信序列化开销」。I/O 密集的异步任务直接用 async/await 就够了(不占主线程),丢 Worker 反而因消息序列化更慢。经验法则:耗时 > 100ms 且 CPU 密集时,Worker 收益才明显。

9. 内存泄漏的四种常见来源?怎么排查?

答案:① 定时器未清理(setInterval 组件卸载后还在跑)→ useEffect 返回清理函数;② 事件监听未移除(window/document 监听)→ 返回解绑函数;③ 闭包长期持有大对象(回调引用大数组)→ 用后置 null 或避免长期持有;④ 全局缓存无限增长(Map/Set 只加不删)→ 设容量上限、LRU 淘汰。排查:DevTools Memory 面板「快照 1 → 操作 → 快照 2 → Comparison」,找持续增长的对象。

深度:React 场景最常见泄漏
React 使用者最易踩的:在 useEffect 里订阅 addEventListener / setInterval / 第三方 store,却忘了 return 清理函数。推荐 ESLint 插件 eslint-plugin-react-hooks 强制检查。另外注意:即使组件卸载,只要定时器回调闭包引用了组件的 state/refs,整棵 Fiber 树都无法 GC。

10. Lab 和 RUM 的区别?为什么都要?

答案:Lab(实验室监控):固定设备、固定网络跑 Lighthouse 等工具,稳定可复现,适合捕捉「构建引入的回归」,卡阈值门禁;RUM(真实用户监控):通过 PerformanceObserver 收集线上真实用户的指标,代表真实体验(弱网/低端机/缓存命中率)。两者都要是因为:Lab 验「有没有引入问题」,RUM 验「用户真实感受到没有」——Lab 高分但 RUM 差,说明样本偏差。

深度:RUM 数据分析建议
RUM 应看 P75(75 分位)而非平均值——平均被长尾慢用户拉高会掩盖多数用户的真实问题。告警建议用「P75 劣化 > 10% + 样本数 > 阈值」组合触发,避免小样本误报。CrChrome User Experience Report(CrUX)提供公开站点对标数据,可用来设定合理目标。
✅ 全部通关? 性能体系已建立。下一阶段《测试与代码质量》,把你的代码从「能跑」升级到「经得起长期演进」。