11 · 性能优化(详解版)
一句话定位:性能不是「某个配置项」,而是「用户等多久、卡不卡、晃不晃」的可测量结果。优化的第一步永远是测量,最后一步永远是防止回退。
读完本篇你应该能做到
- 用 Core Web Vitals 描述体验,并解释每个指标背后的浏览器行为;
- 独立完成一次「测量 → 归因 → 优化 → 验证 → 防回退」的闭环;
- 定位长任务、渲染抖动、内存泄漏、图片与字体的具体问题,并给出量化收益;
- 建立团队级性能预算与 RUM 归因机制,让性能不随迭代退化。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 对应指标/手段 |
|---|---|---|
| 首屏大图 5 秒才出现 | 图片未压缩、未预加载、被 JS 阻塞 | LCP |
| 点击按钮 600ms 才响应 | 主线程被长任务占住 | INP |
| 页面加载时内容「跳一下」 | 图片/广告位没有预留尺寸 | CLS |
| 滚动列表卡成 PPT | 万级 DOM + 每帧重排 | 虚拟列表、合成层 |
| 用久了越来越卡,最后崩溃 | 监听/定时器/闭包未释放 | 内存 |
| 上线后性能悄悄退化 | 没有预算与监控 | 治理机制 |
| 老板问「为什么要做性能」 | 没有和业务指标关联 | RUM + 实验 |
性能问题的特殊性:它不会报错。功能测试全绿、监控没有异常,用户依然会流失。所以性能必须从「个人经验」变成「可观测 + 有门禁」的工程问题。
三个常见误解,先破掉:
- 「性能就是加载快」——只对了一半。加载只影响第一次印象,交互卡顿影响的是「用起来顺不顺」,后者对留存的影响往往更大。
- 「优化就是压缩、懒加载、上 CDN」——这些是手段,不是方法。没有归因的优化,经常在优化错误的那一段。
- 「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 定义与阈值
| 指标 | 全称 | 测什么 | 好 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 视口内最大内容元素何时绘制完成 | ≤ 2.5s | 2.5–4s | > 4s |
| INP | Interaction to Next Paint | 交互到下一帧绘制的延迟 | ≤ 200ms | 200–500ms | > 500ms |
| CLS | Cumulative Layout Shift | 布局偏移累计分数 | ≤ 0.1 | 0.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 面板:找原因
看四件事:
- 帧率条:红色块 = 掉帧,定位到具体时间点;
- Main 轨道:长任务与调用栈,找
Self Time最大的函数; - Timings:LCP/FCP/DCL 标记在时间轴上的位置;
- 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
三个常见错误:
sizes写死100vw,但卡片实际只占 50% → 下载双倍尺寸;- 只给 1x 图,Retina 屏拉伸模糊;
- 用同一个 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
更工程化的做法:
- 按字符集拆包:中文常用 3500 字 + 扩展包,用
unicode-range分段; - 按页面提取:构建时扫描页面文案,生成按路由的子集;
- 回退策略:子集缺字时回退到系统字体,避免豆腐块。
/* 分段加载:不同 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 | 提前解析域名 | 第三方域名 | 几乎无害 |
preconnect | DNS + 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 + 超时 |
| 感知 | 骨架屏改善等待感,但不能替代真实优化 |
自检题
- LCP 和
load事件有什么区别?——LCP 是视口内最大内容元素的渲染时间,load是所有资源加载完成;load晚不代表 LCP 晚,反之亦然。用load代替 LCP 会得出错误结论。 - 为什么首屏大图不能加
loading="lazy"?——懒加载要等布局与视口计算后才发起请求,直接推迟 LCP;首屏主图应 eager 并加fetchpriority="high",必要时用 preload。 - 中文站点为什么必须做字体子集化?——中文字体动辄数 MB,子集化后可降到几十 KB,减少阻塞与流量;配合
unicode-range分段与swap,既快又不缺字。 - 为什么
preload字体要加crossorigin?——字体请求以 CORS 模式发起,属性不匹配会导致浏览器认为是两个不同请求,从而重复下载。 - 骨架屏能改善 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/grid | Layout 时长 |
| 绘制 | 大面积阴影/模糊/渐变 | 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 / top | Layout → Paint → Composite | 最高 |
color / background / box-shadow | Paint → Composite | 中 |
transform / opacity | Composite(已提升层) | 最低 |
filter / backdrop-filter | Paint(通常)+ 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 |
| 阴影/模糊过多 | 绘制成本高 | 减少模糊半径、用预渲染图 |
怎么验证:
- Performance 面板录制交互,看是否有连续
Layout/Paint; - DevTools → More tools → Rendering → 勾选
Paint flashing,看哪些区域在重绘; - 勾选
Layer borders,看合成层数量; - 用
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;
}
}
不定高列表的三个难点:
- 首屏位置未知:未测量前用估算高度,测量后补偿滚动位置,否则会「跳」;
- 滚动锚定:在顶部插入内容时,浏览器会尝试保持可视内容位置,可能与你计算的偏移冲突;
- 快速滚动:未渲染的项无法测量,需要占位与渐进测量。
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 失效的四个常见原因:
- props 引用每次新建:内联对象、内联函数、
style={{...}}; - 父组件把 state 传下去:父组件更新必然带动子组件;
- context 变化:消费该 context 的组件都会重渲染,
memo拦不住; - 自定义比较函数漏字段:看起来「没变」,实际用了旧值。
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 的读法:
- 看「火焰图」里哪次提交最宽;
- 看「Ranked」按耗时排序;
- 勾选「Record why each component rendered」,看渲染原因;
- 对比
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 }
]
}
| 预算项 | 指标 | 阈值示例 | 门禁位置 |
|---|---|---|---|
| 首屏 JS | gzip 体积 | 200KB | CI |
| 单 chunk | gzip 体积 | 250KB | CI |
| 首屏图片 | 总体积 | 500KB | CI |
| LCP | P75 | 2.5s | RUM 告警 |
| INP | P75 | 200ms | RUM 告警 |
| CLS | P75 | 0.1 | RUM 告警 |
| 长任务 | 每次发布新增 > 200ms 的任务数 | 0 | Lab + 人工评审 |
# 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,稳定引用是前提 |
| 预算 | 没有门禁的性能优化会回退 |
自检题
- 什么是强制同步布局?怎么避免?——在写入样式后立即读取布局属性(或读写交替),迫使浏览器马上布局;应批量读、批量写,或用 rAF 把读写分到不同帧。
passive: true解决了什么问题?——告诉浏览器监听器不会preventDefault,滚动可以立即开始,不被 JS 阻塞;代价是不能在回调里阻止默认行为。- 为什么
memo加了还是不生效?——props 引用每次变化(对象、数组、内联函数)、context 更新、自身 state 更新,或比较函数漏字段。 - 虚拟列表为什么能提升性能?——DOM 数量从「万级」降到「几十个」,减少布局、绘制与内存开销;计算与数据量仍然存在,所以数据预处理也要优化。
- 为什么
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)
五步读法:
- 找最宽的块:它是总耗时,不一定是原因;
- 向下展开:找子块中最宽的;
- 看 Self Time:真正花在自己身上的时间,排除子调用;
- 看颜色:黄 = JS,紫 = 样式/布局,绿 = 绘制,灰 = 其他;
- 对照代码:点击块看源码位置,确认是哪个函数。
常见形态与结论:
| 形态 | 结论 |
|---|---|
| 一个大黄块 + 长调用栈 | 单次计算过重 → 拆分/缓存/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 | 早 | 按需 | 高(架构改造) |
水合成本的来源:
- 组件树越大,执行越多;
- 状态序列化体积(
__INITIAL_STATE__); - 水合期间主线程被占,输入被延迟(INP 变差);
- 水合不一致导致的重渲染。
优化手段:
| 手段 | 收益 | 代价 |
|---|---|---|
| 减少客户端组件 | 直接减少水合量 | 需要拆分边界 |
| 选择性水合 | 优先交互区域 | 框架支持度不一 |
| 渐进式水合 | 可见时再水合 | 首屏交互可能延迟 |
| 减少状态序列化 | 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 节点 + 引用链 |
| 归因 | 看分布与长尾,单变量验证 |
自检题
- LCP 慢,如何判断是「资源太大」还是「主线程太忙」?——先看 LCP 元素的请求起点与下载时长;若资源很快但渲染晚,且存在长任务或字体阻塞,就是主线程问题。可临时移除某段 JS 做对照。
- 一个页面切换后内存持续上涨,怎么定位?——反复进出页面看趋势 → 三次堆快照对比 → 找 Detached DOM 与增长对象 → 沿 Retainers 查引用链,重点看监听、定时器、Observer 与全局缓存。
- 为什么优化平均值可能毫无意义?——性能是长尾分布,平均值会被快用户稀释;应看 p75/p95,并按机型、网络、地域分桶,否则优化的是「已经很快的人」。
- INP 比 TBT 更能反映什么?——TBT 只统计长任务,INP 还包含输入延迟与呈现延迟;用户感知的「点了没反应」可能来自渲染阻塞而不是长任务。
- 为什么
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 自检
- 如何向老板证明「性能投入」的 ROI?——用实验或灰度数据把性能换算成转化率、跳出率与客单价,给出区间与置信度,再对比人力与基础设施成本;避免用「行业报告」当证据。
- 团队 20 个页面,如何建立可持续的性能治理机制?——统一采集与看板、性能预算进 CI、发布卡点、季度审计与页面 owner,把「无性能回退」写进 DoD。
- 低端机用户占比 15%,怎么做差异化策略?——按设备能力分桶设置预算,降级动效、图片分辨率与预加载,优先保证可交互与核心任务,并单独监控低端机指标。
- 性能平台上线后没人用,先查什么?——先查「是否回答了他们的具体问题」(哪个页面、哪个阶段、哪段代码),再查数据延迟与接入成本;多数平台死在「数据全但没有结论」。
- 如何在「性能」与「交付速度」之间做取舍?——设「不可退让项」(首屏预算、错误率、CLS)与「可协商项」;用数据说明劣化的业务代价,而不是用原则争论。
5. 综合实战:LCP 从 4.5s 降到 1.8s
背景:内容详情页,首屏大图 + 富文本 + 推荐位,移动端 4G,日 PV 80 万。
5.1 第一步:分段测量
| 阶段 | P75 耗时 | 占比 | 数据来源 |
|---|---|---|---|
| TTFB | 900ms | 20% | RUM |
| 资源加载延迟 | 1200ms | 27% | Performance 瀑布图 |
| 资源加载时长 | 1800ms | 40% | Network |
| 元素渲染延迟 | 600ms | 13% | 火焰图 + 长任务 |
判断:最大头是「资源加载时长」,但「资源加载延迟」也异常高——说明图片发现得太晚。
5.2 第二步:逐项优化
| 问题 | 措施 | 收益 |
|---|---|---|
| TTFB 900ms | CDN 缓存 + 边缘渲染 | -600ms |
| 大图 1.8MB PNG | AVIF + 响应式 srcset | -1.2s |
| 图片发现太晚 | <link rel="preload"> + fetchpriority="high" | -400ms |
| 推荐位阻塞主线程 | 移到 Suspense / 懒加载 | -300ms |
| 字体 3MB | 子集化 + swap | -500ms |
| 长任务 480ms | 拆分为 rAF 分片 + 移 Worker | INP 改善 |
<!-- 关键改动:让浏览器在解析 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 P75 | 4.5s | 1.8s | -60% |
| CLS P75 | 0.18 | 0.02 | -89% |
| INP P75 | 320ms | 160ms | -50% |
| 首屏传输 | 3.1MB | 780KB | -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 }]
}
}
}
}
三条长期规则:
- 图片体积进 CI 预算,超过 500KB 直接失败;
- LCP 进 RUM 看板,按页面告警到 owner;
- 新增大图必须走图片 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 元素与请求起点。
- 要点:瓶颈可能在 TTFB 或发现时间(图片由 JS 插入、在 CSS 背景里、被
- 再追问:
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),定位具体元素;常见来源是图片无尺寸、字体切换、动态插入内容(广告/公告)。
- 要点:用 RUM 的
- 追问:字体加了
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,团队正在密集发版。你如何在一周内定位并恢复?
评估点:是否先止血、是否有归因方法、是否控制变量。
参考方案:
- 先看分桶:是全局退化还是特定机型/版本/地域;用版本维度定位到具体发布。
- 对比产物:拉取退化前后的构建产物,比较首屏 JS 体积、chunk 数量、第三方脚本。
- Lab 复现:用相同设备与网络录制 Performance,看 LCP 元素的四段耗时。
- 止血:若确认是某次发布引入,先回滚或关闭对应开关;不要边查边继续发版。
- 验证:灰度对比 P75,确认恢复后再继续其他优化。
- 复盘:把「新增第三方脚本必须评审」「首屏预算 CI」补上,避免复发。
加分:先止血再定位;用版本维度而不是「最近改了什么」猜;指出大促前应冻结高风险变更。
减分:直接全量回滚所有版本;不做分桶就改代码;把原因归为「流量大了」。
题 2 · 20 个业务页面,性能长期在「优化—退化」之间循环,如何建立可持续机制?
评估点:机制设计、组织落地、指标口径。
参考方案:
- 建基线:统一采集口径(页面模板、P75、分机型),产出每个页面的基线看板。
- 定预算:按页面类型设体积与指标阈值,进入 CI。
- 落 owner:每个页面有团队负责人,劣化告警直达。
- 卡发布:预算失败不允许合并;大需求做性能评审。
- 季度专项:按「劣化幅度 × 流量」排序,集中治理 Top 10。
- 正向激励:把性能改善与业务收益关联,公开分享案例,而不是只做惩罚。
加分:指出「没有 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》(经典但仍有参考价值)