11

性能优化

2,508 行约 116 分钟89 个代码块63 张表格更新于 2026-11-03

11 · 性能优化(详解版)

一句话定位:性能不是「某个配置项」,而是「用户等多久、卡不卡、晃不晃」的可测量结果。优化的第一步永远是测量,最后一步永远是防止回退。

读完本篇你应该能做到

  1. 用 Core Web Vitals 描述体验,并解释每个指标背后的浏览器行为;
  2. 独立完成一次「测量 → 归因 → 优化 → 验证 → 防回退」的闭环;
  3. 定位长任务、渲染抖动、内存泄漏、图片与字体的具体问题,并给出量化收益;
  4. 建立团队级性能预算与 RUM 归因机制,让性能不随迭代退化。

0. 领域导论

0.1 这个领域解决什么问题

现象根因对应指标/手段
首屏大图 5 秒才出现图片未压缩、未预加载、被 JS 阻塞LCP
点击按钮 600ms 才响应主线程被长任务占住INP
页面加载时内容「跳一下」图片/广告位没有预留尺寸CLS
滚动列表卡成 PPT万级 DOM + 每帧重排虚拟列表、合成层
用久了越来越卡,最后崩溃监听/定时器/闭包未释放内存
上线后性能悄悄退化没有预算与监控治理机制
老板问「为什么要做性能」没有和业务指标关联RUM + 实验

性能问题的特殊性:它不会报错。功能测试全绿、监控没有异常,用户依然会流失。所以性能必须从「个人经验」变成「可观测 + 有门禁」的工程问题。

三个常见误解,先破掉:

  1. 「性能就是加载快」——只对了一半。加载只影响第一次印象,交互卡顿影响的是「用起来顺不顺」,后者对留存的影响往往更大。
  2. 「优化就是压缩、懒加载、上 CDN」——这些是手段,不是方法。没有归因的优化,经常在优化错误的那一段。
  3. 「Lighthouse 100 分就没事」——实验室分数是模拟环境的单点结果,真实用户的设备、网络、版本分布才是决策依据。

0.2 一个类比

把网页加载想成搬家:

  • 网络是货车:货越多、路越远,到得越晚。
  • 主线程是一个搬运工:他既要卸货(解析 HTML)、又要组装家具(执行 JS)、还要摆位置(样式、布局、绘制)。他一次只能干一件事,你让他搬一个 300ms 的大柜子,期间门铃响了也开不了(输入无响应)。
  • 合成线程是另一个只负责「把摆好的家具挪动视角」的人:所以滚动可以很顺,只要搬运工没堵住门。
  • 内存是仓库:借了不还,仓库满了就崩。
  • GPU 是电梯:能批量搬运,但电梯口(层数)有限,堆太多箱子反而更慢。

理解「一个搬运工」这个模型,你就明白为什么少执行 JS 比「优化 JS 写法」更有效,也明白为什么 transform 动画能在主线程卡住时依然流畅。

三条直接推论:

推论 1:任何「让搬运工多干活」的优化,收益都有限;
推论 2:任何「把活挪到别的线程」的优化,收益上限很高;
推论 3:任何「减少总工作量」的优化,收益最稳。

0.3 学习地图

11 性能优化
  ├─ 前置:05 浏览器(渲染管线)、08 渲染架构、10 网络缓存
  ├─ 紧接:18 可观测性(RUM 与告警)
  ├─ 交叉:02 CSS(布局与合成)、06 框架(重渲染)
  └─ 长期:17 架构(性能治理体系)

与本套其他篇章的分工:

篇章负责回答本篇如何引用
05 浏览器一帧内部发生了什么本篇只谈「在哪一阶段花的时间」
09 工程化怎么把包变小本篇谈「体积对 LCP 的影响」
10 网络缓存与协议本篇谈「TTFB 与资源发现」
18 可观测监控体系怎么建本篇谈「性能指标怎么采、怎么归因」

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会看 Lighthouse,做图片/字体/缓存优化单页性能改善能解释指标含义与阈值
L2 进阶能定位重排重绘、长任务、重渲染性能自查报告能说清瓶颈在哪一层,并给出前后数据
L3 高级能读火焰图、做归因、写采集性能平台/专项能量化收益,能解释指标算法
L4 专家建预算、机制与组织协同性能治理体系能对业务指标负责,能防回退

升级判定:L2 的标志是「能复现并定位」,L3 的标志是「能解释为什么,并给出可验证的方案」,L4 的标志是「别人不按你的方案做,性能就会退化——而你能拦住」。


1. L1 基础:先学会看指标

1.1 Core Web Vitals 三个核心指标

1.1.1 定义与阈值

指标全称测什么好需改进差
LCPLargest Contentful Paint视口内最大内容元素何时绘制完成≤ 2.5s2.5–4s> 4s
INPInteraction to Next Paint交互到下一帧绘制的延迟≤ 200ms200–500ms> 500ms
CLSCumulative Layout Shift布局偏移累计分数≤ 0.10.1–0.25> 0.25

三个指标分别对应三种体验:

LCP:等得久不久   ← 加载体验
INP:点了有没有反应 ← 交互体验
CLS:页面稳不稳   ← 视觉稳定性

阈值是 P75 口径:官方要求的「好」,是指75% 的访问达到阈值,而不是平均值。这一条决定了你不能只看均值,必须看分位数。

1.1.2 每个指标背后测量的浏览器行为

指标浏览器在测什么什么时候停止记录常见误判
LCP视口内「最大内容元素」的绘制时间首次输入、页面隐藏、滚动出视口以为等于「整页加载完」
INP每次交互的「输入延迟 + 处理 + 呈现」页面生命周期内持续记录,取高分位以为只测第一次点击
CLS非用户触发的布局偏移累计分数页面隐藏以为只统计首屏

LCP 的元素候选:<img>、<image>(SVG 内)、<video> 的 poster、带 url() 背景的块、包含文本的块级元素。选择规则是「可见面积最大」,且会忽略全屏铺底的纯色/低信息量图片。

INP 的三段:

输入延迟(input delay):事件发生 → 事件回调开始执行
处理时间(processing):事件回调执行 + 框架更新
呈现延迟(presentation):更新完成 → 下一帧真正绘制

三段对应三种不同的修法:输入延迟 → 减少主线程占用;处理时间 → 减少 JS 计算;呈现延迟 → 减少样式/布局/绘制。

CLS 的计分规则(简化):

单次偏移分数 = 影响比例 × 距离比例
影响比例:偏移元素占视口面积的比例
距离比例:偏移距离占视口高度的比例
CLS = 所有「非用户输入导致」的偏移分数之和(按会话窗口取最大)

所以「元素在视口里占得越大、移动得越远,扣分越多」。这解释了一个常见现象:底部广告位推一下内容,比小图标抖动扣分严重得多。

1.1.3 辅助指标与它们的位置

指标含义用途局限
TTFB首字节时间判断服务端/网络/缓存不含前端渲染
FCP首次内容绘制判断「有没有东西出来」可能是骨架屏
TBT主线程阻塞总时长Lab 环境衡量 INP 的代理不反映真实交互
SI(Speed Index)视觉完成速度历史指标已不推荐作为核心
TTI可交互时间传统指标计算复杂、对现代框架意义下降
/**
 * TBT 的计算口径:所有长任务超出 50ms 的部分求和。
 * @param {PerformanceEntry[]} longTasks longtask 观测结果
 * @returns {number} 毫秒
 */
function calculateTBT(longTasks) {
  return longTasks.reduce((sum, task) => sum + Math.max(0, task.duration - 50), 0);
}

1.1.4 三个指标的关系与常见误用

TTFB ─► FCP ─► LCP ─► 可交互
  │      │      │
  │      │      └─ 受图片/字体/主线程影响
  │      └──────── 受 CSS/字体/JS 阻塞影响
  └─────────────── 受服务端/网络/CDN 影响

四组容易混淆的关系:

说法事实
「LCP 好就等于加载完」LCP 只统计最大元素,页面可能还有大量内容未完成
「FCP 快就体验好」骨架屏能让 FCP 很快,但用户仍要等真实内容
「TBT 高就是 INP 差」TBT 只统计长任务,输入延迟还可能来自事件排队与呈现延迟
「CLS 是首屏指标」它贯穿整个页面生命周期,滚动加载也可能造成偏移

1.1.5 常见坑与验证

坑后果修复
只看平均值掩盖长尾用户的糟糕体验看 P75/P95 并按维度分桶
用 load 事件代替 LCP严重高估或低估用 PerformanceObserver 采 LCP
把实验室分数当 KPI优化了分数没优化体验Lab 找原因,Field 定优先级
只看首页详情页、列表页往往更差按页面类型建立基线

怎么验证:

// 控制台快速查看本次访问的 LCP 候选
performance.getEntriesByType('largest-contentful-paint').forEach((e) => {
  console.log(e.startTime, e.element, e.url, e.size);
});

1.2 三个工具,三种视角

1.2.1 Lighthouse:快速体检

# 桌面预设
npx lighthouse https://example.com --view --preset=desktop

# 移动端(默认,模拟 4x CPU 降速 + 慢 4G)
npx lighthouse https://example.com --view --throttling-method=simulate
能力说明
冷启动测量每次新建无缓存会话,便于横向对比
审计建议直接给出「减少未使用 JS」这类可执行项
预算与 CI可用 Lighthouse CI 做门禁
局限单次采样、模拟网络、无真实用户分布

1.2.2 Performance 面板:找原因

看四件事:

  1. 帧率条:红色块 = 掉帧,定位到具体时间点;
  2. Main 轨道:长任务与调用栈,找 Self Time 最大的函数;
  3. Timings:LCP/FCP/DCL 标记在时间轴上的位置;
  4. Bottom-Up / Call Tree:按耗时排序,排除框架噪声。
读火焰图的顺序:
找最宽的 Task → 展开看子任务 → 找 Self Time 最大的函数
→ 看它属于「脚本 / 样式 / 布局 / 绘制 / GC」哪一类

1.2.3 RUM / CrUX:定优先级

数据源覆盖时效用途
CrUX真实 Chrome 用户(需足够流量)28 天滚动对外口径、竞品对比
自建 RUM全量用户(含 WebView)分钟级归因、告警、版本对比

RUM 的价值在于分桶:

按页面类型:列表 / 详情 / 表单 / 支付
按设备:低端 Android / 中端 / iOS
按网络:4G / 5G / WiFi / 弱网
按版本:v1.2.3 / v1.2.4
按地域:华北 / 华南 / 海外

1.2.4 三者怎么配合

1. RUM 发现异常:某页面 LCP P75 从 2.1s 涨到 3.4s
2. 分桶定位:只有低端 Android + 4G 明显恶化
3. Lab 复现:Performance 录制同样的设备/网络条件
4. 找到原因:新增的第三方脚本阻塞了主线程 400ms
5. 修复后灰度:对比新版本与旧版本的 P75
6. 固化:把该脚本的加载策略写进规范/门禁

1.2.5 常见坑

坑后果修复
用 Lab 数据证明「优化有效」与真实体验脱节用 RUM 或 A/B 验证
只跑一次 Lighthouse结果波动被当成结论多次运行取中位数
忽略缓存与二次访问高估真实体验区分首访/回访
用平均值对比版本样本结构变化导致误判分桶 + 置信区间

1.3 图片:投入产出比最高的优化

1.3.1 格式与压缩

格式适用相对 JPEG 体积注意
JPEG照片、复杂图像基准不支持透明
PNG图标、截图、需要透明大用 TinyPNG/Squoosh 压
WebP通用替代小 25%–35%兼容性已很好
AVIF高质量照片小 40%–50%编码慢,需 <picture> 降级
SVG图标、线稿极小注意内联 XSS 与体积
<picture>
  <source type="image/avif" srcset="hero-800.avif 800w, hero-1600.avif 1600w" sizes="100vw" />
  <source type="image/webp" srcset="hero-800.webp 800w, hero-1600.webp 1600w" sizes="100vw" />
  <img src="hero-800.jpg" width="1600" height="900" alt="首页主视觉" fetchpriority="high" />
</picture>

1.3.2 响应式尺寸:别让手机下载桌面大图

<img
  src="card-400.webp"
  srcset="card-400.webp 400w, card-800.webp 800w, card-1200.webp 1200w"
  sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 400px"
  width="400"
  height="300"
  alt="商品图"
  loading="lazy"
  decoding="async"
/>

sizes 的含义是「图片在布局中会占多宽」,浏览器据此结合 DPR 选择候选:

实际需要宽度 = sizes 计算值 × devicePixelRatio

三个常见错误:

  1. sizes 写死 100vw,但卡片实际只占 50% → 下载双倍尺寸;
  2. 只给 1x 图,Retina 屏拉伸模糊;
  3. 用同一个 URL 做不同尺寸,CDN 缓存命中率下降。

1.3.3 加载优先级:三种工具的差别

手段作用时机适合误用后果
fetchpriority="high"浏览器决定优先级时首屏主图、LCP 元素抢占 CSS/字体带宽
<link rel="preload" as="image">HTML 解析前由 JS/CSS 才能发现的图与 <img> 重复下载
loading="lazy"接近视口才请求首屏以下的图首屏图 LCP 变慢
decoding="async"解码不阻塞主线程非首屏大图首屏图可能延迟呈现
<!-- 首屏主图:早发现 + 高优先级 -->
<link rel="preload" as="image" href="/hero-1200.webp" imagesrcset="/hero-800.webp 800w, /hero-1200.webp 1200w" imagesizes="100vw" />

1.3.4 布局稳定:宽高比是 CLS 的解药

/* 现代写法:用 aspect-ratio 预留空间,图片加载后不跳动 */
.thumb {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: #f2f2f2; /* 占位色,避免空白 */
}
<!-- 传统写法:width/height 属性让浏览器提前算出比例 -->
<img src="a.webp" width="800" height="600" alt="" />

1.3.5 解码成本常被忽略

一张 4000×3000 的图即使压缩到 200KB,解码后仍占约 48MB 内存(4000 × 3000 × 4 字节)。移动端解码大图会造成:

  • 主线程卡顿(尤其 decoding="sync");
  • 内存峰值上升,低端机可能 OOM;
  • 滚动时反复解码/回收。

对策:按展示尺寸出图,不要靠 CSS 缩小大图;长列表用 loading="lazy" 控制同时解码数量。

1.3.6 常见坑与验证

坑后果修复
首屏图 loading="lazy"LCP 显著变慢首屏图去掉 lazy,加 fetchpriority="high"
用 CSS 背景承载 LCP 图发现晚,无法 preload改用 <img> 或加 preload
无宽高CLS 抖动加 width/height 或 aspect-ratio
上传 4K 原图流量与解码成本翻倍CDN 裁剪 + 多尺寸
所有图都 preload带宽竞争只 preload 首屏 1–2 张

怎么验证:

// 列出页面中实际下载的图片及其尺寸、传输大小、解码耗时
performance.getEntriesByType('resource')
  .filter((e) => e.initiatorType === 'img')
  .map((e) => ({ url: e.name, transfer: e.transferSize, duration: e.duration }))
  .sort((a, b) => b.transfer - a.transfer);

1.4 字体:容易忽视的 LCP/CLS 来源

1.4.1 字体为什么阻塞渲染

浏览器解析 CSS → 发现 @font-face → 下载字体
  ├─ 未设置 font-display:文字最多「空白」约 3s(block period)
  └─ 设置 swap:立刻用系统字体渲染,字体到达后替换

字体属于「渲染阻塞资源」的一种:浏览器不希望先显示错误字体再切换(会造成 CLS),所以默认会等一小段时间。

1.4.2 font-display 的五档

值行为适合
auto浏览器决定(通常等同 block)不推荐
block短暂空白,超时后用后备字体品牌字体、必须显示
swap立即用后备字体,到达后替换正文(推荐)
fallback极短空白 + 短切换窗口折中
optional网络慢就直接不用自定义字体性能优先
@font-face {
  font-family: 'Brand';
  src: url('/fonts/brand.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+4E00-9FFF; /* 只声明中文字符范围 */
}

1.4.3 子集化:中文字体的必选项

一个完整中文字体通常 3–10MB。子集化后可以降到几十 KB:

# 用 fonttools 按页面实际用到的字符做子集
pyftsubset brand.ttf \
  --text-file=used-chars.txt \
  --flavor=woff2 \
  --output-file=brand-subset.woff2

更工程化的做法:

  1. 按字符集拆包:中文常用 3500 字 + 扩展包,用 unicode-range 分段;
  2. 按页面提取:构建时扫描页面文案,生成按路由的子集;
  3. 回退策略:子集缺字时回退到系统字体,避免豆腐块。
/* 分段加载:不同 unicode-range 可以并行下载,缺字自动回退 */
@font-face {
  font-family: 'Brand';
  src: url('/fonts/brand-cjk-basic.woff2') format('woff2');
  unicode-range: U+4E00-9FFF, U+3000-303F;
  font-display: swap;
}

1.4.4 preload 与 CLS 的取舍

<link
  rel="preload"
  href="/fonts/brand-subset.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
  • as="font" 必须配 crossorigin,否则会重复下载(字体请求是 CORS 模式);
  • 只 preload 首屏必用的字体,正文与标题各一个就够;
  • swap 会带来一次「字体切换」,如果两套字体度量差异大,会造成 CLS。可用 size-adjust、ascent-override 校准后备字体。
/* 让后备字体的行高与自定义字体接近,减少切换抖动 */
@font-face {
  font-family: 'Brand Fallback';
  src: local('PingFang SC');
  size-adjust: 96%;
  ascent-override: 88%;
  descent-override: 12%;
}

1.4.5 常见坑

坑后果修复
中文全量字体首屏多等数秒子集化 + 分段
preload 忘 crossorigin下载两次补 crossorigin
图标字体代替 SVG阻塞渲染、可访问性差用内联 SVG
字体切换造成抖动CLS 上升校准后备字体度量
引 5 个字重体积 ×5只保留实际用到的字重

怎么验证:Network 面板按 Font 过滤,看是否有重复请求与体积;Performance 面板看 FCP 前是否有字体等待;document.fonts.status 可判断字体是否加载完成。

1.5 资源加载顺序

1.5.1 什么会阻塞解析

资源是否阻塞解析说明
<script src>是下载并执行完才继续解析
<script defer>否解析完后按顺序执行
<script async>否下载完立即执行,顺序不保证
<link rel="stylesheet">阻塞渲染不阻塞解析,但阻止首次绘制
<img>否异步加载,但占位不当会 CLS
@import阻塞串行下载,尽量避免

1.5.2 脚本的四种加载方式

<!-- 1) 阻塞:立即下载并执行 -->
<script src="/critical.js"></script>

<!-- 2) defer:不阻塞解析,DOM 解析完后按顺序执行 -->
<script defer src="/app.js"></script>

<!-- 3) async:下载完立即执行,顺序不保证 -->
<script async src="/analytics.js"></script>

<!-- 4) module:默认 defer,支持 import -->
<script type="module" src="/main.js"></script>
属性阻塞解析执行顺序适合
无是文档顺序极少数必须同步的脚本
defer否文档顺序应用主脚本
async否下载完成顺序独立第三方脚本
type="module"否默认 defer现代应用

1.5.3 四个「预」指令的区别

指令做什么什么时候用误用后果
dns-prefetch提前解析域名第三方域名几乎无害
preconnectDNS + TCP + TLS关键第三方源占用连接数
prefetch空闲时下载「下一页可能用」的资源大概率跳转的页面抢带宽
preload当前页高优先级下载首屏关键资源未使用会浪费
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="dns-prefetch" href="https://analytics.example.com" />
<link rel="preload" href="/app.css" as="style" />
<link rel="prefetch" href="/next-page.js" />

1.5.4 优先级不是绝对的

浏览器有自己的优先级表(Highest/High/Medium/Low/Lowest),fetchpriority 只能在其框架内调整。实际表现还受:

  • 连接数与 HTTP/2 多路复用;
  • 带宽与 RTT;
  • 是否被 Service Worker 拦截;
  • 是否在 preload 后又被 <img> 引用(可能重复)。
HTML 解析
  → 发现 CSS(Highest)
  → 发现字体(High,preload 后)
  → 发现首屏图(High,fetchpriority 后)
  → 发现 async 脚本(Low)
  → 发现首屏以下的图(Low,lazy)

1.5.5 常见坑

坑后果修复
所有脚本都不加 defer解析被阻塞主脚本用 defer/module
第三方脚本同步加载白屏数秒async + 超时降级
preload 了但没用控制台警告 + 浪费带宽只 preload 首屏关键资源
CSS 里 @import 嵌套串行请求合并或用 <link>
用 JS 插入首屏图发现太晚服务端直出或 preload

怎么验证:Network 面板看瀑布图的依赖链与优先级列(Priority);Performance 面板看「资源加载」与「首次绘制」之间的空档。

1.6 减少「看不见的等待」

问题表现处理
重定向链3 次 301 才到目标直接链接最终地址
串行请求先拿配置再拿数据并行或内联配置
阻塞第三方统计脚本卡住渲染async + 超时 + 失败降级
空等接口白屏直到数据返回骨架屏 + 流式/分段渲染
静态资源串行字体等 CSS、图等字体关键资源前置声明
冷启动首次访问慢、二次很快预热 CDN、边缘缓存
/**
 * 第三方脚本降级:超时未加载就跳过,不阻塞业务。
 * @param {string} src 脚本地址
 * @param {number} timeout 超时毫秒
 */
function loadScriptWithTimeout(src, timeout = 3000) {
  return new Promise((resolve) => {
    const el = document.createElement('script');
    let settled = false;

    const done = (ok) => {
      if (settled) return;
      settled = true;
      resolve(ok);
    };

    el.src = src;
    el.async = true;
    el.onload = () => done(true);
    el.onerror = () => done(false);
    setTimeout(() => done(false), timeout);
    document.head.appendChild(el);
  });
}

1.7 骨架屏与感知性能

/**
 * 骨架屏不改变真实指标,但显著改善「感知等待」。
 * 关键:结构与最终内容一致,避免切换时跳动(CLS)。
 */
export function CardSkeleton() {
  return (
    <div className="card" aria-busy="true" aria-live="polite">
      <div className="skeleton skeleton--title" />
      <div className="skeleton skeleton--line" />
      <div className="skeleton skeleton--line skeleton--short" />
    </div>
  );
}
手段对真实指标对感知注意
骨架屏无明显提升结构要一致,避免布局跳动
进度条无缓解焦虑进度要「真实地前进」,否则更急躁
乐观更新提升 INP 观感明显失败要能回滚并提示
图片占位色降低 CLS中等颜色取自图片主色
分块渲染改善 FCP/LCP明显要注意顺序与可访问性

感知性能的边界:骨架屏不能掩盖「接口慢」。如果接口需要 5 秒,再好的骨架屏也只是让用户多等 5 秒。真实优化与感知优化要同时做,但优先级是先真实。

1.8 L1 小结与自检

主题关键结论
指标LCP 看加载、INP 看响应、CLS 看稳定,全部看 P75
工具Lab 找原因,Field 定优先级
图片格式 + 尺寸 + 优先级 + 宽高 + 解码成本
字体swap + 子集化 + preload(带 crossorigin)
脚本默认 defer,第三方 async + 超时
感知骨架屏改善等待感,但不能替代真实优化

自检题

  1. LCP 和 load 事件有什么区别?——LCP 是视口内最大内容元素的渲染时间,load 是所有资源加载完成;load 晚不代表 LCP 晚,反之亦然。用 load 代替 LCP 会得出错误结论。
  2. 为什么首屏大图不能加 loading="lazy"?——懒加载要等布局与视口计算后才发起请求,直接推迟 LCP;首屏主图应 eager 并加 fetchpriority="high",必要时用 preload。
  3. 中文站点为什么必须做字体子集化?——中文字体动辄数 MB,子集化后可降到几十 KB,减少阻塞与流量;配合 unicode-range 分段与 swap,既快又不缺字。
  4. 为什么 preload 字体要加 crossorigin?——字体请求以 CORS 模式发起,属性不匹配会导致浏览器认为是两个不同请求,从而重复下载。
  5. 骨架屏能改善 LCP 吗?——不能。它改变的是用户对等待的感受;LCP 仍然记录真实内容元素的绘制时间。骨架屏甚至可能成为 LCP 元素,让指标「看起来更好」但体验没变。

2. L2 进阶:渲染、长任务与重渲染

2.1 一次交互的完整开销

2.1.1 一帧的时间预算

一帧(60Hz)= 16.7ms
├─ 输入处理(事件回调)        ≤ 4ms
├─ JS 执行(状态更新、渲染)   ≤ 8ms
├─ 样式计算 + 布局             ≤ 3ms
├─ 绘制 + 合成                 ≤ 2ms
└─ 缓冲                        ≈ 2ms

120Hz 屏幕的预算减半(8.3ms)。这解释了一个常见现象:同一份代码在 60Hz 手机上勉强流畅,在高刷屏上更容易掉帧——不是设备更差,而是预算更小。

2.1.2 事件到上屏的链路

用户点击
  → 浏览器合成线程接收输入
  → 主线程派发事件(pointerdown → mousedown → click)
  → 事件处理(你的 JS)
  → 状态更新 → 框架调度 → 重渲染(JS)
  → 样式计算(Recalculate Style)
  → 布局(Layout)
  → 绘制(Paint,生成绘制指令)
  → 分层与合成(Composite)
  → GPU 上屏

每个箭头都可能成为瓶颈:

阶段变慢的原因观察方式
事件派发主线程正忙于长任务Experience 区的输入延迟
事件处理同步计算、大量 DOM 操作火焰图里的事件回调
框架调度一次更新触发整棵树渲染React Profiler
样式计算选择器复杂、DOM 规模大Recalculate Style
布局读写交替、复杂 flex/gridLayout 时长
绘制大面积阴影/模糊/渐变Paint 时长
合成层数过多、层面积过大Layers 面板

2.1.3 掉帧的三种典型形态

形态 A:单次长任务
  ▁▁▁▁▁████████████▁▁▁▁     ← 一次 300ms 的 JS,直接丢 18 帧
  修法:拆分任务 / 移 Worker

形态 B:每帧都超预算
  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓        ← 每秒都在 20ms,持续掉帧
  修法:减少每帧工作量(虚拟化、简化样式)

形态 C:偶发 GC
  ▁▁▁▁▁▁▁▁▁██▁▁▁▁▁▁▁▁▁▁     ← 内存分配过多,GC 停顿
  修法:减少临时对象、对象池、避免大数组频繁重建

2.1.4 常见坑

坑后果修复
把「掉帧」都归因于框架优化错方向先看是 JS、样式还是合成
只在高性能机测试低端机问题被掩盖用 4x/6x CPU 降速模拟
认为 60fps 是上限高刷屏体验浪费动画尽量交给合成线程
在事件回调里同步读布局强制同步布局读写分离

2.2 重排、重绘、合成

2.2.1 三棵树与五个阶段

DOM 树 + CSSOM 树
      ↓ 合并
   渲染树(Render Tree)
      ↓ 计算每个盒子的位置与大小
   布局(Layout / Reflow)
      ↓ 生成绘制指令
   绘制(Paint)
      ↓ 分图层、光栅化、合成
   合成(Composite) → 屏幕
改动触发的阶段相对成本
width / height / margin / topLayout → Paint → Composite最高
color / background / box-shadowPaint → Composite中
transform / opacityComposite(已提升层)最低
filter / backdrop-filterPaint(通常)+ Composite高
visibility / display见下—

display: none 会触发布局(元素脱离文档流),visibility: hidden 只触发绘制。

2.2.2 强制同步布局(layout thrashing)

浏览器会把样式与布局的修改攒起来,等到需要时批量执行。但只要你在写之后立刻读布局属性,浏览器就必须马上执行布局,否则读到的值不对。

// ❌ 读-写-读-写交替:每次写之后的读都强制重新布局
for (const el of items) {
  el.style.width = `${el.offsetWidth + 10}px`; // 写 → 读 → 写 → 读
}

// ✅ 先批量读,再批量写
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => {
  el.style.width = `${widths[i] + 10}px`;
});

会强制布局的读属性(常见):

offsetTop / offsetLeft / offsetWidth / offsetHeight
scrollTop / scrollLeft / scrollWidth / scrollHeight
clientTop / clientLeft / clientWidth / clientHeight
getComputedStyle()
getBoundingClientRect()
range.getBoundingClientRect()
element.focus()(可能触发滚动与布局)

识别方法:Performance 面板会出现紫色的 Layout 块,且它的调用栈里带着 offsetWidth 之类的读取;在 Chrome DevTools 的「Rendering → Layout Shift Regions」也能辅助观察。

2.2.3 合成层与 GPU

/* 提升为独立合成层:动画由合成线程驱动 */
.animated {
  will-change: transform;
  transform: translateZ(0);
}
手段效果代价
transform: translate3d(0,0,0)提升层显存、层爆炸
will-change提前提升长期挂载会一直占资源
opacity 动画可合成需要独立层才有意义
3D 变换提升层同上

层不是越多越好:

  • 每层都要独立显存(层的面积 × 4 字节 × 缩放);
  • 层数过多会让合成线程本身变慢;
  • 移动端显存紧张,层爆炸可能直接崩溃。

正确用法是「交互前加、结束后移除」:

/**
 * 只在交互期间提升图层,结束后释放。
 * @param el 目标元素
 */
function boostLayerDuringInteraction(el) {
  el.style.willChange = 'transform';
  const done = () => {
    el.style.willChange = 'auto';
    el.removeEventListener('transitionend', done);
  };
  el.addEventListener('transitionend', done);
}

2.2.4 常见坑与验证

坑后果修复
用 top/left 做动画每帧布局改 transform
用 height 做展开动画每帧布局 + 绘制用 transform: scaleY 或 clip-path(注意内容失真)
滥用 will-change显存暴涨交互时加、结束移除
在大列表上改 class大面积重绘只改必要的元素,或用 content-visibility
阴影/模糊过多绘制成本高减少模糊半径、用预渲染图

怎么验证:

  1. Performance 面板录制交互,看是否有连续 Layout/Paint;
  2. DevTools → More tools → Rendering → 勾选 Paint flashing,看哪些区域在重绘;
  3. 勾选 Layer borders,看合成层数量;
  4. 用 PerformanceObserver({ type: 'long-animation-frame' }) 看每帧的脚本、样式、布局占比(Chrome 123+)。

2.3 长任务:INP 的头号杀手

2.3.1 什么算长任务

长任务(Long Task):主线程连续占用 > 50ms 的任务
  → 期间无法处理输入、无法渲染新帧
  → 用户感受:点了没反应、滚动卡住、输入延迟

注意:50ms 是「可能影响交互」的警戒线,不是「一定卡」的界线。一个 60ms 的任务如果正好在输入之前完成,用户无感;一个 45ms 但连续出现 10 次的任务,会让页面持续掉帧。

2.3.2 采集:Long Task 与 LoAF

/**
 * 采集长任务,并尽量定位到来源脚本。
 * attribution 在部分浏览器不可用,需要做空值保护。
 */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const source = entry.attribution?.[0];
    report('long-task', {
      duration: entry.duration,
      start: entry.startTime,
      container: source?.containerType,
      src: source?.containerSrc,
      name: source?.containerName,
    });
  }
}).observe({ type: 'longtask', buffered: true });
/**
 * LoAF:更细的长帧归因,能看到脚本、样式、布局各占多少。
 * Chrome 123+ 可用,其他浏览器降级为 longtask。
 */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    report('loaf', {
      duration: entry.duration,
      blocking: entry.blockingDuration,
      scripts: entry.scripts?.map((s) => ({
        src: s.sourceURL,
        duration: s.duration,
        invoker: s.invoker,
      })),
      styleAndLayout: entry.styleAndLayoutStart
        ? entry.startTime + entry.duration - entry.styleAndLayoutStart
        : 0,
    });
  }
}).observe({ type: 'long-animation-frame', buffered: true });

2.3.3 拆分长任务的四种手段

/**
 * 1) 主动让出主线程:把大循环切成小块,每块之后让出。
 * 优先用 scheduler.yield(Chrome 129+),否则退化为宏任务。
 */
async function yieldToMain() {
  if (globalThis.scheduler?.yield) return globalThis.scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

/**
 * 分批处理:每批处理完都让出,保证输入有机会被响应。
 * @param items 待处理数据
 * @param batchSize 每批数量
 * @param work 单条处理函数
 */
async function processInChunks(items, batchSize, work) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    for (const item of batch) work(item);
    await yieldToMain();
  }
}
/**
 * 2) 空闲时执行:适合「不急但要做」的预计算。
 * 注意 timeRemaining 要给下一帧留余量,不要贪心。
 */
function runWhenIdle(queue) {
  const schedule = window.requestIdleCallback ?? ((cb) => setTimeout(() => cb({ timeRemaining: () => 8 }), 16));

  const step = (deadline) => {
    while (deadline.timeRemaining() > 4 && queue.length) {
      const task = queue.shift();
      task();
    }
    if (queue.length) schedule(step);
  };

  schedule(step);
}
/**
 * 3) 移到 Worker:适合纯计算(解析、加密、图像处理)。
 * 注意结构化克隆的成本,大数据优先用 Transferable。
 */
const worker = new Worker(new URL('./heavy.worker.js', import.meta.url), { type: 'module' });

worker.postMessage({ buffer }, [buffer]); // 第二个参数转移所有权,零拷贝
worker.onmessage = (e) => updateUI(e.data);
/**
 * 4) 不渲染那么多:虚拟化、分页、折叠。
 * 这是唯一能「消除」而不是「分摊」成本的手段。
 */

2.3.4 四种让出方式的对比

方式恢复优先级延迟兼容性适合
await scheduler.yield()高(当前任务优先恢复)低Chrome 129+交互后继续处理
scheduler.postTask()可指定可指定较新需要优先级控制
setTimeout(0)低(排到宏任务末尾)4ms+(嵌套后更久)全兼容兜底
MessageChannel中低全兼容高频让出

为什么不用 Promise.resolve().then():微任务不会让出主线程,渲染与输入仍然被阻塞,等于没让。

2.3.5 常见坑

坑后果修复
用 setTimeout 嵌套做切片嵌套 5 层后被强制 4ms 延迟用 MessageChannel 或 scheduler.yield
切片粒度太小往返调度开销超过收益每片 5–10ms 工作量
拆分后仍同步更新大量 DOM拆分无效同时虚拟化/批量更新
Worker 传输大对象克隆开销抵消收益用 Transferable 或分块
只优化 TBT真实 INP 仍差兼顾输入延迟与呈现延迟

2.4 虚拟列表:1 万条数据的正确姿势

2.4.1 原理

外层容器(固定高度,overflow: auto)
└─ 占位层(高度 = 总条数 × 行高)  ← 撑开滚动条
   └─ 可见区(绝对定位,只渲染 start ~ end)

核心公式:

startIndex = floor(scrollTop / itemHeight) - overscan
visibleCount = ceil(containerHeight / itemHeight) + overscan * 2
endIndex = min(total, startIndex + visibleCount)
offsetY = startIndex * itemHeight

2.4.2 定高实现(完整可运行)

import { useCallback, useMemo, useRef, useState } from 'react';

interface VirtualListProps<T> {
  items: T[];
  itemHeight: number;
  height: number;
  overscan?: number;
  renderItem: (item: T, index: number) => React.ReactNode;
}

/**
 * 定高虚拟列表:只渲染可视区 + 缓冲区。
 * 关键点:滚动状态用 rAF 合并,避免每个 scroll 事件都触发渲染。
 */
export function VirtualList<T>({
  items,
  itemHeight,
  height,
  overscan = 4,
  renderItem,
}: VirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);
  const ticking = useRef(false);

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    const next = e.currentTarget.scrollTop;
    if (ticking.current) return;
    ticking.current = true;
    requestAnimationFrame(() => {
      setScrollTop(next);
      ticking.current = false;
    });
  }, []);

  const { start, end, offsetY } = useMemo(() => {
    const first = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
    const count = Math.ceil(height / itemHeight) + overscan * 2;
    return {
      start: first,
      end: Math.min(items.length, first + count),
      offsetY: first * itemHeight,
    };
  }, [scrollTop, itemHeight, height, overscan, items.length]);

  return (
    <div style={{ height, overflowY: 'auto' }} onScroll={handleScroll}>
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {items.slice(start, end).map((item, i) => (
            <div key={start + i} style={{ height: itemHeight }}>
              {renderItem(item, start + i)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

2.4.3 不定高:测量 + 位置缓存 + 二分查找

/**
 * 不定高列表的核心:维护每项高度与累计偏移,用二分查找定位起始项。
 */
export class HeightCache {
  private heights: number[] = [];
  private offsets: number[] = [0];

  /** 记录某项高度,返回是否发生变化 */
  set(index: number, height: number): boolean {
    if (this.heights[index] === height) return false;
    this.heights[index] = height;
    this.rebuild();
    return true;
  }

  /** 重建累计偏移(条目多时可用 Fenwick 树优化) */
  private rebuild(): void {
    this.offsets = [0];
    for (let i = 0; i < this.heights.length; i += 1) {
      this.offsets[i + 1] = this.offsets[i] + (this.heights[i] ?? 0);
    }
  }

  /** 二分查找:给定滚动位置,返回对应索引 */
  indexAt(scrollTop: number): number {
    let lo = 0;
    let hi = this.offsets.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.offsets[mid] < scrollTop) lo = mid + 1;
      else hi = mid;
    }
    return Math.max(0, lo - 1);
  }

  offsetOf(index: number): number {
    return this.offsets[index] ?? 0;
  }
}

不定高列表的三个难点:

  1. 首屏位置未知:未测量前用估算高度,测量后补偿滚动位置,否则会「跳」;
  2. 滚动锚定:在顶部插入内容时,浏览器会尝试保持可视内容位置,可能与你计算的偏移冲突;
  3. 快速滚动:未渲染的项无法测量,需要占位与渐进测量。

2.4.4 可访问性与交互细节

问题处理
读屏只看到可视项用 aria-setsize / aria-posinset 标明总数与位置
键盘 Tab 到不可见项提供「跳到第 N 项」或使用 roving tabindex
滚动位置恢复记录 scrollTop 与锚点项,返回时恢复
空态/加载态保留容器高度,避免跳动
嵌套滚动明确滚动容器,避免双滚动条

2.4.5 常见坑

坑后果修复
key 用数组下标滚动时内容错位用稳定 id
每帧 setState 多次渲染抖动rAF 合并,或用 ref 直接改样式
总高度用估算值滚动条长度跳变测量后更新总高度
渲染项含图片/图表滚动时解码/重绘卡顿占位 + 懒渲染
无障碍被忽略读屏用户无法使用补 aria-* 与键盘支持

2.5 高频事件的正确节流方式

/**
 * 用 rAF 合并到一帧:滚动/拖拽/缩放等每帧最多算一次。
 * 比 setTimeout 节流更贴近渲染节奏。
 */
function createFrameThrottle(fn) {
  let ticking = false;
  let lastArgs;

  return (...args) => {
    lastArgs = args;
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      fn(...lastArgs);
      ticking = false;
    });
  };
}

const onScroll = createFrameThrottle(() => {
  // 读取位置并更新
});

window.addEventListener('scroll', onScroll, { passive: true });
方式特点适合
rAF 合并与渲染帧对齐,最多每帧一次视觉更新
防抖(debounce)停止后才执行搜索联想、校验
节流(throttle)固定间隔执行埋点、上报
passive: true不阻塞滚动所有滚动/触摸监听

passive 的意义:浏览器默认不知道你会不会 preventDefault,必须等你执行完才能滚动。声明 passive: true 后,滚动可以立刻开始。对于 touchstart / touchmove / wheel,现代浏览器在 window、document、body 上的监听默认就是 passive,但在其他元素上不是。

注意:passive: true 与 preventDefault() 不能共存。需要阻止默认行为时(如自定义下拉刷新),要显式设置 passive: false,并且只加在必要元素上。

2.6 框架层的重渲染优化

2.6.1 先分清「渲染次数」与「单次渲染成本」

问题表现手段
渲染次数过多Profiler 里很多短提交memo、状态下移、订阅拆分
单次渲染过重一次提交几百 ms虚拟化、拆分组件、减少计算
两者都有又频繁又重先减少范围,再减少次数

优化顺序:先测(Profiler)→ 再定位(哪个组件、为什么渲染)→ 最后才加 memo。直接加 memo 经常是「增加了比较成本,没减少渲染」。

2.6.2 memo / useMemo / useCallback 的真实作用

import { memo, useCallback, useMemo, useState } from 'react';

interface RowProps {
  item: { id: string; label: string };
  onSelect: (id: string) => void;
}

/** memo 只做浅比较:props 引用变了就会重渲染 */
const Row = memo(function Row({ item, onSelect }: RowProps) {
  return <button onClick={() => onSelect(item.id)}>{item.label}</button>;
});

export function Table({ items }: { items: RowProps['item'][] }) {
  const [selected, setSelected] = useState<string | null>(null);

  // ✅ 稳定引用:依赖为空,函数只创建一次
  const handleSelect = useCallback((id: string) => setSelected(id), []);

  // ✅ 只在 items 变化时重新计算
  const sorted = useMemo(() => [...items].sort((a, b) => a.label.localeCompare(b.label)), [items]);

  return (
    <div>
      {sorted.map((item) => (
        <Row key={item.id} item={item} onSelect={handleSelect} />
      ))}
      <p>已选:{selected}</p>
    </div>
  );
}

memo 失效的四个常见原因:

  1. props 引用每次新建:内联对象、内联函数、style={{...}};
  2. 父组件把 state 传下去:父组件更新必然带动子组件;
  3. context 变化:消费该 context 的组件都会重渲染,memo 拦不住;
  4. 自定义比较函数漏字段:看起来「没变」,实际用了旧值。

2.6.3 状态下移与订阅拆分

// ❌ 输入框的每次按键都让整个列表重渲染
function Page() {
  const [keyword, setKeyword] = useState('');
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ExpensiveList />
    </>
  );
}

// ✅ 状态下移:把 state 放进只依赖它的子组件
function SearchBox() {
  const [keyword, setKeyword] = useState('');
  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

function Page() {
  return (
    <>
      <SearchBox />
      <ExpensiveList />
    </>
  );
}
// ✅ 细粒度订阅:只有用到的字段变化才重渲染
import { useSyncExternalStore } from 'react';

function useStoreSelector<T>(store, selector: (s: any) => T): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getInitialState?.() ?? store.getState())
  );
}

2.6.4 用 Profiler 定位

import { Profiler } from 'react';

/**
 * 记录每次提交的耗时与原因,只在开发/预发开启。
 * 生产环境可用 Performance API 采样。
 */
<Profiler
  id="OrderTable"
  onRender={(id, phase, actualDuration, baseDuration) => {
    if (actualDuration > 16) {
      console.warn('[perf]', id, phase, { actualDuration, baseDuration });
    }
  }}
>
  <OrderTable />
</Profiler>

React DevTools Profiler 的读法:

  1. 看「火焰图」里哪次提交最宽;
  2. 看「Ranked」按耗时排序;
  3. 勾选「Record why each component rendered」,看渲染原因;
  4. 对比 actualDuration 与 baseDuration:差距大说明有浪费。

2.6.5 常见坑

坑后果修复
所有组件都包 memo比较成本上升,代码噪音只给高频重渲染的组件
useCallback 依赖写错闭包拿到旧值用 lint 检查依赖
context value 每次新对象所有消费者重渲染useMemo 包 value 或拆分 context
列表项用 index 做 key插入/删除时错位用稳定 id
在渲染函数里做重计算每帧都算useMemo 或移到模块级

2.7 关键 CSS 与首屏样式

<!-- 首屏关键样式内联,其余异步加载 -->
<style>
  /* 只放首屏可见区域的样式:布局骨架、字体、首屏组件 */
  .header { height: 56px; }
  .hero { aspect-ratio: 16 / 9; }
</style>
<link rel="stylesheet" href="/app.css" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/app.css" /></noscript>
做法收益代价
内联关键 CSS消除「白屏等样式」不可缓存、HTML 变大
异步加载非关键 CSS减少阻塞可能出现无样式闪烁(FOUC)
提取每个路由的 CSS首屏更小构建复杂度上升
移除未使用 CSS体积下降需要工具与人工确认

关键 CSS 的体积建议控制在 14KB 以内(约一个 TCP 初始拥塞窗口),否则会多一个 RTT。

2.8 性能预算:把「别变慢」变成规则

{
  "budgets": [
    { "resourceType": "script", "budget": 200 },
    { "resourceType": "image", "budget": 500 },
    { "resourceType": "total", "budget": 1000 }
  ]
}
预算项指标阈值示例门禁位置
首屏 JSgzip 体积200KBCI
单 chunkgzip 体积250KBCI
首屏图片总体积500KBCI
LCPP752.5sRUM 告警
INPP75200msRUM 告警
CLSP750.1RUM 告警
长任务每次发布新增 > 200ms 的任务数0Lab + 人工评审
# size-limit:把体积写进 CI,超出直接失败
npx size-limit
// .size-limit.js
module.exports = [
  { name: '首屏入口', path: 'dist/assets/index-*.js', limit: '200 kB' },
  { name: '样式', path: 'dist/assets/index-*.css', limit: '60 kB' },
];

预算怎么定(没有历史数据时):

1. 测当前 P75 作为基线
2. 目标 = 基线 × 0.8(先争取 20% 改善)
3. 参考竞品与设备能力
4. 每季度复核,随业务调整

2.9 L2 小结与自检

主题关键结论
渲染改几何最贵,改 transform 最便宜
长任务>50ms 阻塞输入,拆分或移出主线程
列表虚拟化是万级数据的唯一解
事件rAF 合并 + passive
重渲染先测量再 memo,稳定引用是前提
预算没有门禁的性能优化会回退

自检题

  1. 什么是强制同步布局?怎么避免?——在写入样式后立即读取布局属性(或读写交替),迫使浏览器马上布局;应批量读、批量写,或用 rAF 把读写分到不同帧。
  2. passive: true 解决了什么问题?——告诉浏览器监听器不会 preventDefault,滚动可以立即开始,不被 JS 阻塞;代价是不能在回调里阻止默认行为。
  3. 为什么 memo 加了还是不生效?——props 引用每次变化(对象、数组、内联函数)、context 更新、自身 state 更新,或比较函数漏字段。
  4. 虚拟列表为什么能提升性能?——DOM 数量从「万级」降到「几十个」,减少布局、绘制与内存开销;计算与数据量仍然存在,所以数据预处理也要优化。
  5. 为什么 setTimeout(fn, 0) 连续嵌套会变慢?——浏览器对嵌套定时器有最小延迟限制(通常 4ms 起),层数越多越慢;高频让出应使用 MessageChannel 或 scheduler.yield。

3. L3 高级:采集、归因与深水区

3.1 指标是怎么算出来的

3.1.1 LCP:元素判定与停止条件

/**
 * LCP 采集:只在页面隐藏前上报,避免上报到中间值。
 * 注意:LCP 在首次输入后会停止更新,所以要在交互前完成采集。
 */
let lcpValue = 0;
let lcpElement = null;

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  lcpValue = last.startTime;
  lcpElement = last.element ?? null;
}).observe({ type: 'largest-contentful-paint', buffered: true });

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && lcpValue) {
    report('LCP', { value: lcpValue, tag: lcpElement?.tagName, url: lcpElement?.currentSrc });
  }
});

元素判定的细节:

规则说明
候选类型img、image(SVG)、video poster、带 url() 的背景、含文本的块级元素
面积计算可见区域内元素的实际渲染面积(会被裁剪)
忽略项opacity: 0、覆盖全屏且低信息量的图、background-image 的渐变(无 url)
更新更大的元素出现会替换上一候选

为什么「换成 WebP 但 LCP 没变」:可能 LCP 元素根本不是那张图,或者瓶颈在发现时间/主线程,而不是字节数。先确认元素,再优化。

3.1.2 INP:交互的分段与取分位

/**
 * 简化版 INP 采集:记录每次交互的耗时,取高分位。
 * 真实实现要考虑:同一交互的多个事件合并、键盘/指针分类、忽略滚动。
 */
const interactions = [];

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 40) continue; // 低于阈值的不影响 INP
    interactions.push(entry.duration);
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 40 });

/**
 * 取 P98 近似「最差交互」,与官方口径接近。
 * @returns {number} 毫秒
 */
function getWorstInteraction() {
  if (!interactions.length) return 0;
  const sorted = [...interactions].sort((a, b) => a - b);
  return sorted[Math.floor(sorted.length * 0.98)] ?? sorted[sorted.length - 1];
}

INP 的三个分段如何拆:

分段计算方式优化方向
输入延迟处理开始时间 - 事件时间戳减少主线程占用、避免长任务
处理时间事件回调结束 - 处理开始减少 JS 计算、拆分状态更新
呈现延迟下一帧绘制 - 回调结束减少样式/布局、避免同步布局

常见误判:INP 差但 Performance 里找不到长任务 → 可能是「呈现延迟」,即回调很快但随后的渲染很重(大面积重排、复杂动画、水合未完成)。

3.1.3 CLS:会话窗口与偏移来源

/**
 * CLS 会话窗口:把间隔 < 1s 的偏移聚成一组,组最长 5s,
 * 取所有组里分数最大的一组作为 CLS。
 */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue; // 用户操作导致的偏移不计入
    report('layout-shift', {
      value: entry.value,
      sources: entry.sources?.map((s) => ({
        tag: s.node?.nodeName,
        previousRect: s.previousRect,
        currentRect: s.currentRect,
      })),
    });
  }
}).observe({ type: 'layout-shift', buffered: true });
偏移来源典型场景修复
图片/视频无尺寸加载后撑开width/height 或 aspect-ratio
字体切换度量不同size-adjust、swap 校准
动态插入内容广告、公告、错误提示预留占位或浮层
骨架屏与真实内容结构不一致切换时跳动保持结构一致
列表项高度变化展开/折叠动画用 transform,或预留高度

3.2 火焰图怎么读

主线程
├─ Task (320ms)                 ← 一个长任务
│  ├─ Function Call (120ms)     ← 你的业务逻辑
│  │  ├─ parseData (80ms)
│  │  └─ setState (40ms)
│  ├─ Recalculate Style (40ms)
│  ├─ Layout (80ms)
│  ├─ Paint (60ms)
│  └─ GC (20ms)
└─ Task (16ms)

五步读法:

  1. 找最宽的块:它是总耗时,不一定是原因;
  2. 向下展开:找子块中最宽的;
  3. 看 Self Time:真正花在自己身上的时间,排除子调用;
  4. 看颜色:黄 = JS,紫 = 样式/布局,绿 = 绘制,灰 = 其他;
  5. 对照代码:点击块看源码位置,确认是哪个函数。

常见形态与结论:

形态结论
一个大黄块 + 长调用栈单次计算过重 → 拆分/缓存/Worker
大量小黄块渲染次数过多或事件高频 → 合并/节流
紫色块反复出现强制同步布局或复杂选择器
绿色块很大绘制区域大、阴影/模糊多
灰色 GC 频繁内存分配过多 → 减少临时对象
空白 + 长条可能是网络等待或同步 IPC

3.3 主线程调度的现代手段

API作用兼容性使用注意
scheduler.yield()让出后优先恢复当前任务Chrome 129+需降级
scheduler.postTask()按优先级调度任务较新需要 polyfill
isInputPending()查询是否有待处理输入实验性不能作为唯一判据
requestIdleCallback空闲执行广泛(Safari 16.4+)低优先级任务可能被饿死
MessageChannel宏任务让出全兼容无优先级语义
/**
 * 兼容的让出:优先 scheduler.yield,其次 postTask,最后 MessageChannel。
 * @returns {Promise<void>}
 */
function yieldToMainCompat() {
  if (globalThis.scheduler?.yield) return globalThis.scheduler.yield();
  if (globalThis.scheduler?.postTask) {
    return globalThis.scheduler.postTask(() => {}, { priority: 'user-visible' });
  }
  return new Promise((resolve) => {
    const channel = new MessageChannel();
    channel.port1.onmessage = () => resolve();
    channel.port2.postMessage(null);
  });
}

调度策略选择:

用户输入相关 → 最高优先级,立即处理,不做重计算
视觉更新   → 在下一帧前完成(rAF)
数据上报   → 后台优先级(background)
预计算     → 空闲时(idle)

3.4 内存泄漏排查

3.4.1 五类泄漏源

泄漏源症状修法
未移除事件监听页面切换后内存不降removeEventListener / AbortController
定时器未清理后台持续增长cleanup 里 clearInterval
闭包持有大对象堆快照里 DOM 被引用缩小闭包作用域、置空引用
全局缓存无上限越用越大LRU / WeakMap
Detached DOM节点已移除但仍被引用断引用、避免在闭包里存节点
/**
 * 用 AbortController 统一清理监听,避免遗漏。
 * 组件卸载、路由切换时调用 abort 即可。
 */
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal, passive: true });

// 清理
controller.abort();

3.4.2 三步定位法

1. 看趋势:Performance Monitor(或 RUM 内存指标)
   反复进出同一页面,内存应回到基线;持续上升即泄漏

2. 抓快照:Memory 面板 → Heap snapshot
   操作前拍一次、操作后拍一次、再操作后拍一次
   对比三次的「对象数增长」

3. 找引用链:选增长的对象 → Retainers
   看是谁持有它(闭包、事件监听、全局 Map、Detached DOM)

Detached DOM 的识别:堆快照里搜索 Detached,或在过滤器输入 Detached HTMLElement。它表示「DOM 已从文档树移除,但 JS 仍持有引用」。

一个隐蔽的泄漏:ResizeObserver / IntersectionObserver 未 disconnect,回调闭包持有组件实例。与事件监听一样,需要在卸载时清理。

3.5 水合与 SSR 的性能账

TTFB(服务端渲染)
  + HTML 传输
  + JS 下载(可能很大)
  + 水合(组件树执行 + 事件绑定)
  = 可交互时间
方案首屏内容可交互成本
CSR晚晚低(服务端)
SSR早晚(等 JS + 水合)中
流式 SSR更早逐步中高
RSC / Islands早按需高(架构改造)

水合成本的来源:

  1. 组件树越大,执行越多;
  2. 状态序列化体积(__INITIAL_STATE__);
  3. 水合期间主线程被占,输入被延迟(INP 变差);
  4. 水合不一致导致的重渲染。

优化手段:

手段收益代价
减少客户端组件直接减少水合量需要拆分边界
选择性水合优先交互区域框架支持度不一
渐进式水合可见时再水合首屏交互可能延迟
减少状态序列化HTML 更小需要改造数据流
流式渲染 + Suspense首屏更早复杂度上升

3.6 图片与视频的深水区

3.6.1 图片的三层成本

传输成本:字节数(格式、压缩、尺寸)
解码成本:像素数(宽 × 高 × 4 字节)
绘制成本:绘制区域与效果(滤镜、阴影、透明度)

一张 200KB 的 4K 图,解码后可能占 30MB+ 内存。按展示尺寸出图是唯一有效的解法。

3.6.2 视频与动画

技术收益注意
preload="none" + poster减少初始流量首次播放有等待
playsinline + muted移动端可自动播放需用户手势才能带声音
content-visibility: auto跳过屏外渲染配合 contain-intrinsic-size
视频替代 GIF体积大幅下降需转码与兼容性
requestVideoFrameCallback精确处理视频帧兼容性有限
/* 屏外内容延迟渲染:长列表/长文的首屏提速利器 */
.section {
  content-visibility: auto;
  contain-intrinsic-size: 600px; /* 占位,避免滚动条跳动 */
}

content-visibility 的代价:跳过渲染的内容无法被搜索、锚点跳转可能不准、IntersectionObserver 行为需要验证。适合「长文档、长列表、折叠面板」。

3.6.3 图片懒加载的正确边界

<!-- 首屏:eager + 高优先级 -->
<img src="hero.webp" fetchpriority="high" width="1200" height="600" alt="" />

<!-- 首屏以下:lazy + 异步解码 -->
<img src="card.webp" loading="lazy" decoding="async" width="400" height="300" alt="" />

<!-- 轮播第二张之后:lazy,但第一张要 eager -->

不要 lazy 的场景:首屏主图、LCP 元素、首屏轮播的第一张、需要立即参与布局计算的图。

3.7 归因方法:从「慢」到「为什么慢」

Step 1 确认指标异常
  LCP P75 4.2s(基线 2.3s),持续 3 天

Step 2 分维度下钻
  页面类型 / 机型 / 网络 / 地域 / 版本 / 是否首访

Step 3 找长尾
  是整体退化,还是某 10% 用户特别慢

Step 4 定位阶段
  TTFB?资源发现?资源下载?渲染阻塞?水合?

Step 5 Lab 复现
  模拟同样的设备与网络,录制 Performance

Step 6 假设 → 实验 → 验证
  改一个变量,灰度对比,确认因果

3.7.1 归因的三个反模式

反模式问题正确做法
看平均值被快用户稀释看 P75/P95 + 分布直方图
同时改多个变量无法判断因果单变量 + 灰度对照
用 Lab 结论代替线上验证模拟环境不代表真实RUM 或 A/B 验证

3.7.2 一个真实的归因案例

现象:LCP P75 从 2.1s 涨到 3.6s,全站
分桶:仅 Android WebView + 4G 明显,iOS 无变化
Lab 复现:WebView 中新增的 JS SDK 执行 620ms
根因:SDK 在 DOMContentLoaded 时同步初始化,阻塞首屏
修复:延迟到 idle 初始化 + 超时降级
结果:LCP 回到 2.2s,INP 改善 80ms

关键:如果只看「全站 P75」,会误以为需要全量优化;分桶后才发现是单一环境的问题。

3.8 L3 小结与自检

主题关键结论
采集PerformanceObserver + 分阶段归因
LCP四阶段拆解,先看元素与发现时间
INP三段拆分,区分输入延迟与呈现延迟
CLS会话窗口 + 偏移来源定位
火焰图找最宽块与 Self Time
调度主动让出,保住输入响应
内存快照对比 + detached 节点 + 引用链
归因看分布与长尾,单变量验证

自检题

  1. LCP 慢,如何判断是「资源太大」还是「主线程太忙」?——先看 LCP 元素的请求起点与下载时长;若资源很快但渲染晚,且存在长任务或字体阻塞,就是主线程问题。可临时移除某段 JS 做对照。
  2. 一个页面切换后内存持续上涨,怎么定位?——反复进出页面看趋势 → 三次堆快照对比 → 找 Detached DOM 与增长对象 → 沿 Retainers 查引用链,重点看监听、定时器、Observer 与全局缓存。
  3. 为什么优化平均值可能毫无意义?——性能是长尾分布,平均值会被快用户稀释;应看 p75/p95,并按机型、网络、地域分桶,否则优化的是「已经很快的人」。
  4. INP 比 TBT 更能反映什么?——TBT 只统计长任务,INP 还包含输入延迟与呈现延迟;用户感知的「点了没反应」可能来自渲染阻塞而不是长任务。
  5. 为什么 content-visibility 能提升首屏?——它让屏外内容跳过布局与绘制,减少首帧工作量;代价是搜索、锚点与可访问性行为需要验证,且要配 contain-intrinsic-size 防止滚动条跳动。

4. L4 专家:体系、机制与业务价值

4.1 RUM 体系设计

4.1.1 五层架构

采集层:PerformanceObserver(LCP/INP/CLS/LoAF/长任务)+ 自定义埋点
   ↓ 采样、脱敏、补充上下文(页面、版本、机型、网络)
传输层:sendBeacon / fetch keepalive(页面卸载也能发)
   ↓
存储层:按 session 聚合,保留分位数与分布(不能只存均值)
   ↓
分析层:页面 × 版本 × 机型 × 网络 × 地域 多维下钻
   ↓
告警层:P75 超阈值 / 周同比劣化 → 通知页面 owner

4.1.2 采集:轻、准、可归因

/**
 * 一次访问的指标容器:页面隐藏时统一上报。
 * 关键:LCP 在首次输入后会停止更新,所以要在交互前读走。
 */
const metrics = {
  lcp: 0,
  cls: 0,
  inp: [],
  longTasks: 0,
  ttfb: 0,
  page: location.pathname,
  version: window.__APP_VERSION__,
};

new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  if (last) metrics.lcp = last.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) metrics.cls += entry.value;
  }
}).observe({ type: 'layout-shift', buffered: true });

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration >= 40) metrics.inp.push(entry.duration);
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 40 });

new PerformanceObserver((list) => {
  metrics.longTasks += list.getEntries().length;
}).observe({ type: 'longtask', buffered: true });

/**
 * 上报:sendBeacon 在卸载时也不会被取消。
 * 数据量要控制,避免超过浏览器队列上限。
 */
function flush() {
  const nav = performance.getEntriesByType('navigation')[0];
  metrics.ttfb = nav?.responseStart ?? 0;
  const payload = JSON.stringify({
    ...metrics,
    inp: metrics.inp.sort((a, b) => a - b)[Math.floor(metrics.inp.length * 0.98)] ?? 0,
  });
  navigator.sendBeacon('/rum/perf', new Blob([payload], { type: 'application/json' }));
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

4.1.3 采样与成本控制

数据类型采样策略原因
核心指标(LCP/INP/CLS)全量或 10%–20%轻量,需要分位数
长任务明细1%–5%数据大,用于定性分析
资源瀑布1%体量大,按需开启
错误与崩溃全量必须完整

成本公式:

日上报量 = DAU × 人均 PV × 采样率 × 单条体积

一个 300 字节的 payload、100 万 PV、20% 采样 ≈ 每天 60MB,可接受;如果带上完整资源列表(几 KB),就会变成几 GB。

4.1.4 指标口径必须统一

口径项必须写清楚
分位数是否按 session 聚合后取 P75,还是按 PV 取
页面维度用路由模板(/order/:id)而不是完整 URL
设备分桶用什么判据(UA、内存、CPU 核数)
首访/回访是否区分缓存状态
交互范围INP 是否包含键盘、是否忽略滚动

口径不一致会导致「同一个页面两个团队报出两个数」,进而失去信任。

4.2 性能与业务指标关联

4.2.1 三条证据链

证据类型做法强度
观察性按性能分桶看业务指标差异弱(有混杂因素)
准实验灰度/分批发布,对比前后中
随机实验同一功能随机分到快/慢版本强
/**
 * 性能分桶:按 LCP 把用户分成快/慢两组,比较业务指标。
 * 注意混杂:慢用户可能本来设备就差、转化就低,
 * 所以只能作为线索,不能直接当因果。
 */
function bucketByLCP(lcp) {
  if (lcp <= 2500) return 'fast';
  if (lcp <= 4000) return 'medium';
  return 'slow';
}

4.2.2 换算成钱

示例(假设,需用自己的数据校准):
  日活 100 万,下单转化 3%,客单价 100 元
  LCP 从 3.5s 降到 2.0s,实验显示转化率 +0.15 个百分点

  日增订单 = 100 万 × 0.15% = 1500 单
  日增 GMV = 1500 × 100 = 15 万元
  年化    ≈ 5475 万元

  投入:2 人 × 1 季度 ≈ 60 人天

注意:这些数字必须来自自己的实验,不能照搬行业报告。汇报时给区间与置信度,而不是单点。

4.3 治理机制

4.3.1 五道防线

机制做法防什么
预算门禁CI 超预算失败体积回退
性能评审大需求评估性能影响架构性退化
灰度对比发布前后指标对比线上劣化
归因看板按页面/团队下钻无人负责
季度专项集中治理 Top N长尾积压

4.3.2 把指标落到人

页面级:每个页面有 owner(团队),LCP/INP/CLS 进该团队的看板
发布级:每次发布自动生成「性能对比卡」,劣化需要说明
需求级:DoD 增加「无性能回退」一项
季度级:Top 10 劣化页面进入专项,有明确目标与截止时间

最常见的失败:指标挂在「前端团队」这个整体上,结果是「大家都关心、没人负责」。

4.3.3 防回退的三条硬规则

1. 首屏 JS 体积超预算 → CI 失败(不允许「先合再优化」)
2. 新增第三方脚本必须走评审(列出体积、执行时机、降级方案)
3. 图片必须走 CDN 与 srcset(禁止直接引用设计稿原图)

4.4 长尾治理

P75 达标 ≠ 体验好。
P99 用户可能是低端机 + 弱网 + 老版本,占比小但流失率高。

4.4.1 分桶降级策略

用户画像判据(示例)降级手段
低端机navigator.hardwareConcurrency <= 4 或内存小关闭动效、降低图片质量、减少预取
弱网navigator.connection.effectiveType 为 2g/3g更小首屏包、优先文本、更长超时
老版本版本号低于阈值强制更新或功能降级
海外地域就近接入、CDN 预热
/**
 * 能力与网络探测:用于决定是否降级,不要用于「判断用户好坏」。
 * 这些 API 并非所有浏览器都支持,缺失时按「不降级」处理。
 */
export function getRuntimeProfile() {
  const conn = navigator.connection;
  const memory = navigator.deviceMemory;
  const cores = navigator.hardwareConcurrency;

  return {
    slowNetwork: ['slow-2g', '2g', '3g'].includes(conn?.effectiveType ?? ''),
    lowMemory: typeof memory === 'number' && memory <= 2,
    lowCPU: typeof cores === 'number' && cores <= 4,
  };
}

4.4.2 长尾的两个误区

误区事实
「长尾用户少,不用管」他们往往是最容易流失、最需要照顾的用户
「降级就是砍功能」应该砍「体验增强」,保「核心任务」

4.5 成本与收益

投入一次性成本持续成本收益判断
图片全量转 AVIF转码与存储编码算力LCP 降 0.2–0.5s通常值得
全站 SSR服务器翻倍运维复杂首屏快 0.5–1s看 SEO 与转化
自研性能平台2 人 × 3 月1 人维护归因效率提升规模够大才做
预取全部路由带宽浪费缓存污染感知快只预取高概率路径
全球多区域部署高高TTFB 降 300ms+有海外用户才做

判断原则:优先做「一次性投入、持续收益、无运维负担」的优化(图片、字体、缓存),最后做「持续投入、持续维护」的平台。

4.6 L4 自检

  1. 如何向老板证明「性能投入」的 ROI?——用实验或灰度数据把性能换算成转化率、跳出率与客单价,给出区间与置信度,再对比人力与基础设施成本;避免用「行业报告」当证据。
  2. 团队 20 个页面,如何建立可持续的性能治理机制?——统一采集与看板、性能预算进 CI、发布卡点、季度审计与页面 owner,把「无性能回退」写进 DoD。
  3. 低端机用户占比 15%,怎么做差异化策略?——按设备能力分桶设置预算,降级动效、图片分辨率与预加载,优先保证可交互与核心任务,并单独监控低端机指标。
  4. 性能平台上线后没人用,先查什么?——先查「是否回答了他们的具体问题」(哪个页面、哪个阶段、哪段代码),再查数据延迟与接入成本;多数平台死在「数据全但没有结论」。
  5. 如何在「性能」与「交付速度」之间做取舍?——设「不可退让项」(首屏预算、错误率、CLS)与「可协商项」;用数据说明劣化的业务代价,而不是用原则争论。

5. 综合实战:LCP 从 4.5s 降到 1.8s

背景:内容详情页,首屏大图 + 富文本 + 推荐位,移动端 4G,日 PV 80 万。

5.1 第一步:分段测量

阶段P75 耗时占比数据来源
TTFB900ms20%RUM
资源加载延迟1200ms27%Performance 瀑布图
资源加载时长1800ms40%Network
元素渲染延迟600ms13%火焰图 + 长任务

判断:最大头是「资源加载时长」,但「资源加载延迟」也异常高——说明图片发现得太晚。

5.2 第二步:逐项优化

问题措施收益
TTFB 900msCDN 缓存 + 边缘渲染-600ms
大图 1.8MB PNGAVIF + 响应式 srcset-1.2s
图片发现太晚<link rel="preload"> + fetchpriority="high"-400ms
推荐位阻塞主线程移到 Suspense / 懒加载-300ms
字体 3MB子集化 + swap-500ms
长任务 480ms拆分为 rAF 分片 + 移 WorkerINP 改善
<!-- 关键改动:让浏览器在解析 HTML 时就发现首屏图 -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="100vw"
  fetchpriority="high"
/>
/**
 * 推荐位延后:不参与首屏关键路径,避免抢占主线程。
 * 用 Suspense 边界包裹,加载失败不影响主内容。
 */
const Recommendation = lazy(() => import('./Recommendation'));

export function DetailPage() {
  return (
    <main>
      <Hero />
      <Article />
      <Suspense fallback={<RecommendationSkeleton />}>
        <Recommendation />
      </Suspense>
    </main>
  );
}

5.3 第三步:验证

指标优化前优化后变化
LCP P754.5s1.8s-60%
CLS P750.180.02-89%
INP P75320ms160ms-50%
首屏传输3.1MB780KB-75%
跳出率42%38%-4pp

验证方法:

  • 灰度 10% 用户两周,对比同页面的分位数;
  • 排除活动与大促影响(分日对比);
  • 用同一批机型的 Lab 数据做交叉验证。

5.4 第四步:防回退

# CI 配置:体积与指标双门禁
- name: 首屏体积预算
  run: npx size-limit

- name: Lighthouse 预算
  run: npx lhci autorun --config=.lighthouserc.json
{
  "ci": {
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-byte-weight": ["warn", { "maxNumericValue": 1000000 }]
      }
    }
  }
}

三条长期规则:

  1. 图片体积进 CI 预算,超过 500KB 直接失败;
  2. LCP 进 RUM 看板,按页面告警到 owner;
  3. 新增大图必须走图片 CDN 与 srcset,评审时检查。

5.5 复盘:哪些手段真正有效

手段实际收益评价
图片格式与尺寸最大(-1.2s)投入产出比最高
提前发现(preload)大(-400ms)成本极低
CDN/边缘缓存大(-600ms)依赖基础设施
字体子集化中(-500ms)一次性工作
代码拆分中需要持续维护
动效优化小(对 LCP)主要改善 INP/流畅度

结论:先做「发现与传输」,再做「执行与渲染」。很多人一上来就优化 JS,但 LCP 的大头往往在资源侧。


6. 常见误区对照表

误区事实
「Lighthouse 100 分就没问题」Lab 分数基于模拟环境,不代表真实用户分布;要看 RUM 的 P75
「优化平均值就够了」性能是长尾分布,平均值会被快用户稀释;长尾用户才是流失主力
「首屏图片加 lazy 更好」会显著拖慢 LCP;首屏图应 eager + fetchpriority="high"
「动画都用 JS 更可控」CSS transform/opacity 可走合成线程,主线程卡住也不掉帧
「memo 一定提升性能」props 依赖不稳定时只增加比较成本;先测再优化
「虚拟列表能解决一切卡顿」只解决 DOM 规模问题;单次计算重、图片解码重仍需拆分
「压测一次就够」性能随迭代持续退化,必须有门禁与看板
「性能是前端自己的事」后端、网关、CDN、产品(图片尺寸、第三方脚本)都影响
「上 HTTP/2 就不用合并资源」合并仍可减少调度与头部开销,要看具体场景权衡
「内存泄漏只影响长时间使用」移动端可能直接 OOM 崩溃,也会拖慢 GC
「骨架屏能改善 LCP」它改善的是感知等待;骨架屏甚至可能成为 LCP 元素
「字体 swap 一定更好」会带来字体切换抖动;需校准后备字体度量,否则 CLS 上升
「预加载越多越快」抢占关键资源带宽,未使用的 preload 还会触发警告
「TTFB 快前端就没问题」TTFB 只覆盖首字节,渲染阻塞与水合仍可能很慢
「INP 差就是 JS 太慢」也可能是呈现延迟(样式/布局重)或输入延迟(主线程被占)
「优化一次就永久有效」没有预算与告警,通常两周就回退

7. 面试高频题(附答题思路)

Q1:Core Web Vitals 有哪些?怎么优化 LCP? 定义三指标与阈值(P75 口径)→ LCP 四阶段(TTFB/发现/下载/渲染)→ 对应手段(CDN、preload、图片格式、减少主线程)→ 用 RUM 验证。

Q2:INP 怎么优化? 拆三段(输入延迟/处理/呈现)→ 减少长任务(拆分、让出、Worker)→ 减少重渲染与同步布局 → 用 LoAF 定位具体脚本。

Q3:如何排查页面卡顿? Performance 录制 → 找长任务 → 看 Self Time → 区分 JS/样式/布局/绘制/GC → 拆分或移出主线程 → 用降速模拟验证。

Q4:虚拟列表原理? 可视区计算 + 占位高度 + 绝对定位/位移 → 不定高用测量 + 偏移缓存 + 二分查找 → 注意稳定 key、rAF 合并滚动、可访问性。

Q5:内存泄漏怎么查? Performance Monitor 看趋势 → 三次堆快照对比 → Detached DOM → Retainers 引用链 → 常见泄漏源(监听、定时器、Observer、全局缓存、闭包)。

Q6:如何建立性能监控? PerformanceObserver 采集 → 采样与脱敏 → sendBeacon 上报 → 分位数聚合 → 多维下钻 → 告警与门禁 → 落到页面 owner。

Q7:性能预算怎么定? 以当前 P75 为基线,目标取 0.8 倍;参考竞品与设备能力;体积预算按入口/路由拆分;落到 CI 与发布门禁,每季度复核。

Q8:SSR 页面 LCP 慢,可能是什么原因? TTFB 高(无缓存/后端慢)、HTML 过大、关键资源发现晚、水合阻塞主线程、图片未优化、字体阻塞。

Q9:为什么 LCP 和 INP 经常此消彼长? 减少首屏 JS 能改善 LCP,但可能延后交互逻辑;预加载能改善 LCP,但可能抢占带宽影响后续交互。需要按业务优先级权衡,用实验验证。

Q10:如何证明一次性能优化的收益? 单变量改动 + 灰度/随机分组 + 分位数对比 + 排除活动与样本结构变化;同时看护栏指标(错误率、转化率),给出置信区间。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
指标理解是否知道指标怎么算能拆 LCP 四段、INP 三段、CLS 会话窗口「LCP 就是首屏时间」
归因方法从现象到代码路径用火焰图、LoAF、分桶定位直接背优化清单
优化取舍是否算投入产出优先治理占比最大的那一段「全部都做」
线上验证实验室与真实用户区分 Lab 与 RUM 的用途,会用实验只跑一次 Lighthouse
治理机制能否防止回退预算 + 门禁 + 看板 + owner一次性优化就结束
长尾意识是否看分布分机型/网络/地域下钻只看均值

8.2 追问链

链 1 · LCP 四段 → 优化优先级 → 常见误判

  • 面试官:LCP 慢,怎么拆?
    • 要点:TTFB(服务端与网络)→ 资源加载延迟(发现时间)→ 资源加载时长(字节数)→ 元素渲染延迟(主线程、字体、懒加载)。用 Performance 面板的 LCP 标记确认元素与时间点。
  • 追问:为什么把图片换成 WebP 后 LCP 没变?
    • 要点:瓶颈可能在 TTFB 或发现时间(图片由 JS 插入、在 CSS 背景里、被 loading="lazy" 延后),而不是字节数;先确认 LCP 元素与请求起点。
  • 再追问:fetchpriority="high" 什么时候反而有害?
    • 要点:当它抢占真正关键资源(CSS、字体)的带宽时;低端网络下优先级只是建议,仍受连接数与拥塞控制影响。只给首屏主图使用。

链 2 · INP 三段 → 长任务 → 交互优化

  • 面试官:点击后 600ms 才看到变化,怎么定位?
    • 要点:拆输入延迟、处理时间、呈现延迟三段。输入延迟看主线程是否被长任务占用;处理时间看事件回调与状态更新;呈现延迟看样式、布局、绘制是否过重。
  • 追问:为什么把逻辑移到 setTimeout 后还是卡?
    • 要点:setTimeout 只是延后执行,并没有减少总工作量;如果任务仍超过 50ms,输入照样被阻塞。应拆分、让出或移 Worker。
  • 再追问:scheduler.yield() 与 setTimeout(0) 有什么差别?
    • 要点:yield 让出后当前任务优先恢复,延迟低;setTimeout 排到宏任务末尾,且嵌套后受 4ms 最小延迟限制,高频让出会明显变慢。

链 3 · 渲染管线 → 合成层 → 动画属性

  • 面试官:从改一个 class 到屏幕变化,中间发生了什么?
    • 要点:样式计算 → 布局 → 绘制 → 分层与合成。改 width 触发前三步;改 background 跳过布局;改 transform/opacity 可只走合成。
  • 追问:为什么 transform 动画更流畅?
    • 要点:元素被提升为合成层后,动画由合成线程驱动,主线程被长任务阻塞时仍能推进;代价是显存与层管理开销。
  • 再追问:will-change 为什么不能常驻?
    • 要点:提前创建层并长期占用显存,元素多时移动端会 OOM 或掉帧;应在交互前加、结束后移除,并用 Layers 面板确认层数。

链 4 · CLS → 偏移来源 → 字体与图片

  • 面试官:CLS 0.25,怎么查?
    • 要点:用 RUM 的 layout-shift 采集 sources(node、前后 rect),定位具体元素;常见来源是图片无尺寸、字体切换、动态插入内容(广告/公告)。
  • 追问:字体加了 swap,为什么 CLS 反而变高?
    • 要点:swap 会先用后备字体渲染再替换,两套字体度量不同就会跳动;用 size-adjust、ascent-override 校准,或选择度量接近的后备字体。
  • 再追问:广告位怎么处理才不影响 CLS?
    • 要点:预留固定高度或最小高度;未填充时保持占位而不是塌陷;用动画(transform)而不是改变高度。

链 5 · RUM → 口径 → 归因与实验

  • 面试官:你们怎么衡量性能?为什么用 P75 而不是均值?
    • 要点:P75 是官方口径,反映「大多数用户」的体验;均值会被快用户稀释,掩盖长尾。还需分页面、机型、网络、版本下钻。
  • 追问:两个版本 P75 差 100ms,能说明优化有效吗?
    • 要点:要看样本量、置信区间与样本结构(机型/地域是否变化);最好用灰度或随机分组,排除活动与发布日影响。
  • 再追问:怎么证明性能改善带来了业务收益?
    • 要点:随机实验(同一功能快/慢版本)最强;准实验(灰度前后对比)次之;观察性分桶只能作为线索。汇报给区间与置信度。

8.3 手写题

题 1 · 轻量 web-vitals 采集器

/**
 * 采集 LCP / CLS / INP 并在页面隐藏时上报。
 * 说明:这是教学实现,生产建议直接用 web-vitals 库(处理了大量边界)。
 * @param {(data: object) => void} onReport 上报回调
 * @returns {() => object} 返回「立即读取当前值」的函数,便于测试
 */
export function createVitalsCollector(onReport) {
  const state = {
    lcp: 0,
    lcpElement: null,
    cls: 0,
    clsWindow: { value: 0, start: 0, last: 0 },
    interactions: [],
    reported: false,
  };

  /** CLS 会话窗口:间隔 < 1s 的偏移并入同组,组最长 5s,取最大值 */
  const addShift = (entry) => {
    if (entry.hadRecentInput) return;
    const w = state.clsWindow;
    const gap = entry.startTime - w.last;
    const inWindow = w.start && gap < 1000 && entry.startTime - w.start < 5000;

    if (inWindow) {
      w.value += entry.value;
      w.last = entry.startTime;
    } else {
      w.value = entry.value;
      w.start = entry.startTime;
      w.last = entry.startTime;
    }
    state.cls = Math.max(state.cls, w.value);
  };

  const observers = [];

  const observe = (type, cb, options = {}) => {
    try {
      const po = new PerformanceObserver((list) => cb(list.getEntries()));
      po.observe({ type, buffered: true, ...options });
      observers.push(po);
    } catch {
      // 浏览器不支持该 entry 类型,忽略
    }
  };

  observe('largest-contentful-paint', (entries) => {
    const last = entries.at(-1);
    if (last) {
      state.lcp = last.startTime;
      state.lcpElement = last.element ?? null;
    }
  });

  observe('layout-shift', (entries) => entries.forEach(addShift));

  observe(
    'event',
    (entries) => {
      for (const e of entries) {
        if (e.duration >= 40) state.interactions.push(e.duration);
      }
    },
    { durationThreshold: 40 }
  );

  /** 读取当前指标:INP 取 P98 近似 */
  const read = () => {
    const sorted = [...state.interactions].sort((a, b) => a - b);
    const inp = sorted.length ? sorted[Math.floor(sorted.length * 0.98)] : 0;
    return {
      lcp: Math.round(state.lcp),
      cls: Number(state.cls.toFixed(3)),
      inp: Math.round(inp),
      lcpTag: state.lcpElement?.tagName ?? null,
    };
  };

  const flush = () => {
    if (state.reported) return;
    state.reported = true;
    onReport({ ...read(), page: location.pathname });
    observers.forEach((o) => o.disconnect());
  };

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });

  return read;
}

追问:

  • 为什么 LCP 要在 visibilitychange 时读,而不是 load 时?→ load 时 LCP 可能还在更新(更大的元素尚未绘制),且 load 不代表用户离开。
  • 为什么 INP 取 P98 而不是最大值?→ 官方口径忽略极端离群值,取高分位;最大值容易被单次异常噪声主导。
  • 为什么上报后要 disconnect?→ 避免页面被 BFCache 恢复后重复观测与重复上报。

题 2 · 可中断的分片调度器

/**
 * 分片执行任务队列:每片不超过 timeSlice 毫秒,片间让出主线程。
 * 支持 cancel,适合大列表预处理、导入解析等场景。
 * @param {Array<() => void>} tasks 任务列表
 * @param {{ timeSlice?: number, onProgress?: (done: number, total: number) => void }} [options]
 */
export function createChunkedRunner(tasks, { timeSlice = 8, onProgress } = {}) {
  let cursor = 0;
  let cancelled = false;

  /**
   * 让出主线程:优先 scheduler.yield,其次 MessageChannel。
   * 不用 setTimeout,避免嵌套 4ms 延迟叠加。
   */
  const yieldToMain = () => {
    if (globalThis.scheduler?.yield) return globalThis.scheduler.yield();
    return new Promise((resolve) => {
      const channel = new MessageChannel();
      channel.port1.onmessage = () => resolve();
      channel.port2.postMessage(null);
    });
  };

  /** 启动执行,返回 Promise 以便等待完成 */
  const run = async () => {
    let sliceStart = performance.now();

    while (cursor < tasks.length) {
      if (cancelled) return { cancelled: true, done: cursor };
      tasks[cursor]();
      cursor += 1;

      if (performance.now() - sliceStart >= timeSlice) {
        onProgress?.(cursor, tasks.length);
        await yieldToMain();
        sliceStart = performance.now();
      }
    }

    onProgress?.(cursor, tasks.length);
    return { cancelled: false, done: cursor };
  };

  return {
    run,
    cancel() {
      cancelled = true;
    },
  };
}

追问:

  • 为什么用 performance.now() 而不是 Date.now()?→ 单调时钟,不受系统时间调整影响,精度更高。
  • 分片后 INP 一定改善吗?→ 不一定。如果每片仍然更新大量 DOM,呈现延迟仍然高;需要配合批量更新或虚拟化。
  • 如何支持优先级?→ 用两个队列(高/低),每片先清高优先级;或直接用 scheduler.postTask 的优先级。

题 3 · 产物体积预算脚本

#!/usr/bin/env node
/**
 * 简易体积预算:扫描 dist,按类型汇总 gzip 体积,超阈值退出码 1。
 * 用法:node scripts/check-budget.mjs
 */
import { readdir, readFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import path from 'node:path';

const DIST = 'dist/assets';
const BUDGET = { js: 200 * 1024, css: 60 * 1024, img: 500 * 1024 };

/** 递归列出文件 */
async function walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  const files = [];
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) files.push(...(await walk(full)));
    else files.push(full);
  }
  return files;
}

const files = await walk(DIST);
const totals = { js: 0, css: 0, img: 0 };

for (const file of files) {
  const ext = path.extname(file).slice(1);
  const key = ext === 'js' || ext === 'mjs' ? 'js' : ext === 'css' ? 'css' : ['png', 'jpg', 'webp', 'avif'].includes(ext) ? 'img' : null;
  if (!key) continue;
  const buf = await readFile(file);
  totals[key] += gzipSync(buf).length;
}

let failed = false;
for (const [key, size] of Object.entries(totals)) {
  const limit = BUDGET[key];
  const ok = size <= limit;
  if (!ok) failed = true;
  console.log(`${key}: ${(size / 1024).toFixed(1)}KB / ${(limit / 1024).toFixed(0)}KB ${ok ? 'OK' : 'FAIL'}`);
}

process.exit(failed ? 1 : 0);

追问:

  • 为什么用 gzip 体积而不是原始体积?→ 传输成本由压缩后决定;但要注意服务端可能用 Brotli,口径要统一。
  • 为什么按类型而不是按文件?→ 单文件阈值会误伤(拆得越碎越容易通过),类型总量更接近真实首屏成本。
  • 怎么定位到具体是哪个改动导致超预算?→ 在 CI 里输出 Top 10 文件,并与基线分支对比增量。

8.4 场景设计题

题 1 · 大促前一周,核心页面 LCP 从 2.2s 退化到 3.8s,团队正在密集发版。你如何在一周内定位并恢复?

评估点:是否先止血、是否有归因方法、是否控制变量。

参考方案:

  1. 先看分桶:是全局退化还是特定机型/版本/地域;用版本维度定位到具体发布。
  2. 对比产物:拉取退化前后的构建产物,比较首屏 JS 体积、chunk 数量、第三方脚本。
  3. Lab 复现:用相同设备与网络录制 Performance,看 LCP 元素的四段耗时。
  4. 止血:若确认是某次发布引入,先回滚或关闭对应开关;不要边查边继续发版。
  5. 验证:灰度对比 P75,确认恢复后再继续其他优化。
  6. 复盘:把「新增第三方脚本必须评审」「首屏预算 CI」补上,避免复发。

加分:先止血再定位;用版本维度而不是「最近改了什么」猜;指出大促前应冻结高风险变更。

减分:直接全量回滚所有版本;不做分桶就改代码;把原因归为「流量大了」。

题 2 · 20 个业务页面,性能长期在「优化—退化」之间循环,如何建立可持续机制?

评估点:机制设计、组织落地、指标口径。

参考方案:

  1. 建基线:统一采集口径(页面模板、P75、分机型),产出每个页面的基线看板。
  2. 定预算:按页面类型设体积与指标阈值,进入 CI。
  3. 落 owner:每个页面有团队负责人,劣化告警直达。
  4. 卡发布:预算失败不允许合并;大需求做性能评审。
  5. 季度专项:按「劣化幅度 × 流量」排序,集中治理 Top 10。
  6. 正向激励:把性能改善与业务收益关联,公开分享案例,而不是只做惩罚。

加分:指出「没有 owner 就没有治理」;指出指标要少而稳定;指出要防止「刷分数」。

减分:只发规范;用平均值考核;把所有页面套同一个阈值。

8.5 最容易答错的点

常见说法纠正
「LCP 就是首屏渲染完成的时间」LCP 只统计视口内最大内容元素的绘制时间,不等于整页完成
「Lighthouse 分数高就是快」模拟环境单点结果,真实体验要看 RUM 的分位数
「优化平均值就行」长尾分布下均值会掩盖问题,应看 P75/P95 并分桶
「图片加 lazy 总是更好」首屏图加 lazy 会显著拖慢 LCP
「INP 差就是 JS 执行慢」还可能是输入延迟(主线程被占)或呈现延迟(渲染重)
「TBT 等于 INP」TBT 只统计长任务,INP 还包含输入排队与呈现
「memo 一定更快」依赖不稳定时只增加比较成本;先测再优化
「虚拟列表解决所有卡顿」只解决 DOM 规模问题,计算与解码仍可能卡
「字体 swap 一定更好」会带来切换抖动,需校准后备字体度量
「HTTP/2 之后不用合并资源」仍需权衡请求调度与头部开销,不能一概而论
「预加载越多越快」会抢占关键资源带宽,未使用还会告警
「优化一次就永久有效」没有预算与告警,通常很快回退

9. 延伸资源

  • web.dev 的 Core Web Vitals 指南与 web-vitals 库源码
  • Chrome 文档:Largest Contentful Paint、Interaction to Next Paint、Layout Instability API
  • Long Animation Frames(LoAF)规范与 Chrome 博客
  • 《High Performance Browser Networking》(网络与协议基础)
  • Chrome DevTools Performance / Memory / Layers 官方教程
  • Lighthouse CI、size-limit、bundlesize 文档
  • HTTP Archive 的 Web Almanac(性能章节,行业分布数据)
  • React 官方文档:Profiler、memo、并发特性与性能章节
  • 《Web Performance in Action》《Even Faster Web Sites》(经典但仍有参考价值)