发布前跑一次 Lighthouse,92 分。三个月后同样的页面还是 92 分,用户却在说"这站越来越卡"。分数只覆盖一个冷启动路由、一台快机器、一条好网络;真正变重的是首屏之外:一个 AB SDK、一个客服挂件、一版 2x 图、一层代理组件。每次都只"加一点点",没人对总量负责,慢就成了无人认领的事。预算的本质不是设一个数字,是给总量指定所有者、阈值和修的人。
1. 变慢不是一次事故,是十次"就加一点"
| 累积路径 | 典型表现 | 为什么没人拦 |
|---|---|---|
| 依赖膨胀 | 同名包多版本、工具库整包引入 | 没有体积门禁,评审只看代码不看产物 |
| 第三方脚本 | 分析、AB、客服各贴一段 | 需求来自业务方,前端只负责"贴上" |
| 图片字体 | 设计给多大图就用多大 | 没有尺寸与格式约定 |
| 重复实现 | 两套日期库、两个组件库版本 | 没有 bundle 归属,看不出源 |
判据:慢是"没人对总量负责"的结果,不是"某个人写错了代码"。
2. 三种"快",三种指标
| 口径 | 用户感受 | 核心指标 | 取数 | 常见失真 |
|---|---|---|---|---|
| 加载 | 多久看到内容 | LCP(含 TTFB) | field:web-vitals;lab:Lighthouse | 首屏轮播导致 LCP 元素被替换 |
| 交互 | 点了有没有反应 | INP,取 p75 | 只能 field | lab 无真实点击,用 TBT 代理 |
| 渲染 | 内容会不会跳 | CLS | field | 骨架屏与真实内容尺寸不一致 |
三者不能互替:LCP 好而 INP 差是"能看不能点",INP 好而 CLS 差是"能点但一直跳"。只报一个数,等于只承认一种痛苦。
import { onLCP, onINP, onCLS } from 'web-vitals/attribution'; // 字段名按 v4 核对
const send = (m) => navigator.sendBeacon('/__vitals', JSON.stringify({
name: m.name, value: Math.round(m.value), rating: m.rating,
target: m.attribution?.interactionTarget, // 归因才有排查价值
ttfb: Math.round(m.attribution?.timeToFirstByte ?? 0),
}));
[onLCP, onINP, onCLS].forEach((fn) => fn(send, { reportAllChanges: false }));
3. 预算从哪来:竞品基线、用户容忍度,还是拍脑袋
| 来源 | 怎么取 | 后果 |
|---|---|---|
| 竞品基线 | 同类站点 field 的 p75 | 竞品本身超预算,抄来就继承它的债 |
| 用户容忍度 | 人为拉高延迟做 AB,看转化拐点 | 成本高、样本要够、结论滞后 |
| 业务反推 | 从"首屏可用 ≤ N 秒"反推关键路径 | 需建模能力,但最可解释 |
| 拍脑袋 | "2.5 秒是行业标准" | 与业务无关,超了没人认,退化成噪声 |
拍脑袋数字不是原罪,不标注来源的才是。写成 /* src: competitor-baseline */,超了能追溯才有人认账。
{
"field": { "lcp_p75_ms": 2500, "inp_p75_ms": 200, "cls_p75": 0.1 },
"assets": { "entry_js_gzip_kb": 170, "route_js_gzip_kb": 120,
"fonts_kb": 180, "requests_per_view": 45 },
"_meta": { "owner": "frontend-platform", "src": "field-p75" }
}
阈值必须来自自己站点的历史分布。_meta.owner 通常比数字更重要。
4. 分段预算与可控性
| 维度 | 为什么单列 | 可控性 | 谁负责 |
|---|---|---|---|
| 资源体积 | 决定下载与解析时长 | 高 | 业务前端 |
| 请求数 | HTTP/2 下收益变小,连接开销仍在 | 高 | 业务前端 |
| 主线程时间 | 体积小也能卡,卡的是执行 | 中 | 前端 + 依赖选型 |
| 第三方 | 不受自己代码评审控制 | 低 | 提需求方 + 平台 |
| 图片字体 | 常占最大字节数 | 高 | 设计 + 前端 |
可控性低的维度必须单独记账。混进"总 JS 体积",超了只能骂自己。
5. 归因:只看总体积找不到元凶
npx vite build && npx source-map-explorer 'dist/assets/*.js' # 谁在包里
# Performance 录制 → Long Tasks 轨道:展开 >50ms 任务看栈顶
| 视图 | 回答 | 局限 |
|---|---|---|
| source map | 哪个依赖占多少字节 | 静态体积,不含执行耗时 |
| coverage | 首屏多少字节从未执行 | 只统计已解析部分,跨路由要分次采 |
| long task | 卡在哪个任务、栈顶在哪 | 采不到合成器线程与 iframe 内 |
三张图缺一,就会得出"砍掉 lodash 就好了",而真凶是某个 SDK 在主线程同步解析几百 KB JSON。
6. 第三方脚本:成本、准入与摊销
| 准入字段 | 不填的后果 |
|---|---|
| 用途 + owner | 出问题找不到人 |
| 静态体积上限 | 悄悄涨到几百 KB |
| 主线程预算 | 首次交互被吃掉 |
| 加载时机(defer/idle/交互后) | 阻塞首屏 |
| 是否带用户标识 | 缓存策略与合规冲突 |
| 退出条件 | 只进不出 |
const loadOnce = (seen => (src, name) => { // 去重,避免重复注入
if (seen.has(src)) return; seen.add(src);
const s = Object.assign(document.createElement('script'),
{ src, async: true, dataset: { vendor: name } });
document.head.appendChild(s);
})(new Set());
// 用户可能永远不用的挂件,等首次交互再加载
['pointerdown', 'keydown'].forEach((e) =>
addEventListener(e, () => loadOnce('/vendor/chat.js', 'chat'),
{ once: true, passive: true }));
分析类脚本要求尽早加载才不漏事件,与预算直接冲突——取舍写进准入表,别临时决定。
7. 图片与字体:字节大头,最容易被忽略
| 项 | 常见浪费 | 措施 | 边界 |
|---|---|---|---|
| 尺寸 | 2x 图放 1x 容器 | srcset + sizes |
需设计交付原始图 |
| 格式 | PNG 存照片 | AVIF/WebP + 回退 | 老浏览器支持 |
| 时机 | 首屏外图立即加载 | loading="lazy" + 固定宽高 |
是 LCP 元素时别 lazy |
| 字体 | 全字重全字符集 | 子集化 | 中文子集化收益最大 |
| 加载 | FOIT 造成 CLS | swap + 尺寸兜底 |
会跳字,需 metrics 匹配 |
<link rel="preload" href="/fonts/site-sans-latin.woff2"
as="font" type="font/woff2" crossorigin>
8. 代码分割的收益边界
| 粒度 | 收益 | 代价 |
|---|---|---|
| 按路由 | 首屏只留当前路由 | 切换多一次往返 + loading |
| 按重组件(编辑器/图表) | 单页体积明显下降 | 需占位,交互延迟后移 |
| 按 vendor 库 | 缓存命中率高 | 易切出重复实例 |
| 切到组件级 | 理论最优 | 请求数暴涨、优先级调度变差 |
收益来自"首屏不需要",不来自"文件更小";判据是首次交互前是否执行。
// vite.config.ts —— 按变更频率分组,不是按包名一块一块切
output: { manualChunks(id) {
if (!id.includes('node_modules')) return;
if (/react|react-dom|scheduler/.test(id)) return 'vendor-react';
if (/echarts|zrender/.test(id)) return 'vendor-chart';
} }
每包一块会让请求数与缓存粒度双双失控,依赖升级又打散哈希。
9. 缓存与首访:预算要分两套
| 场景 | 关注 | 指标 | 策略 |
|---|---|---|---|
| 首次访问 | 下载与解析 | LCP/FCP 冷启动 | 体积预算主导 |
| 重复访问 | 是否命中 | LCP 热启动、INP | hash + immutable |
| 版本切换 | 失效范围 | 首访退化幅度 | vendor 稳定,HTML 短缓存 |
| 灰度回滚 | 旧 chunk 是否还在 | chunk 404 率 | 保留上一版本产物 |
只有一套总预算,优化会偏向复访(样本多、数字好看),新用户被继续伤害。
10. 守门:阻断、告警,还是只在 PR 里评论
| 方式 | 代价 | 失效点 |
|---|---|---|
| CI 硬阻断 | 阻塞发布,需 owner 批 | 学会"先合并再补"或加 --force |
| 软告警 + 评论 | 几乎为零 | 没人看,阈值被无视 |
| 90% 告警 / 100% 阻断 | 需维护两档 | 长期不校准即失效 |
| 只看周趋势 | 需历史数据 | 慢到没人感知 |
阻断必须配套例外通道。没有例外通道的阻断一定被绕过:跳过 CI、强制合并、把大依赖塞进已有 chunk 躲过 diff。例外要留记录:谁批的、多久回收。
// lighthouserc.json:抖动小的设 error,抖动大的设 warn
{ "ci": { "assert": { "assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }],
"uses-responsive-images": ["error", { "maxLength": 0 }] } } } }
把抖动大的指标设成 error,只会训练团队忽略红色。
11. 线上与实验室不是一套数
| 差异 | 实验室 | 线上 | 处理 |
|---|---|---|---|
| 设备 | 性能好,分数偏高 | 中低端安卓为主 | CPU 4x 节流 |
| 网络 | 固定带宽 | 4G/弱网/跨境 | 节流 + 地域分位 |
| 交互 | 无真实点击 | INP 来自真实操作 | lab 只能用 TBT 代理 |
实验室回答"改动是否变差",线上回答"用户是否难受":前者做门禁,后者定优先级。
12. 退化治理:超了谁修、多久修
| 触发 | 责任方 | 时限 | 动作 |
|---|---|---|---|
| 单次 PR 超 | PR 作者 | 合并前 | 拆包、延后加载、例外审批 |
| 周维度缓涨 | 模块 owner | 一个迭代 | 归因 + 计划 |
| 第三方引起 | 提需求方 | 两周 | 换实现或改交互后加载 |
| 连续两版超 | 平台组 | 立即 | 冻结新增依赖,清账 |
没有"谁修"的预算表等于没有预算。
13. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 本地高分,线上 LCP 差 | 设备/网络/CDN 地域差异 | 按机型与地域看 field 分位 |
| 体积没变但变慢 | 新逻辑占主线程 | 看 Coverage 与 Long Tasks |
| 首屏快,点按钮卡 | INP 高,处理里有同步重活 | 拆任务、主动让出主线程 |
| 复访快,新用户慢 | 只优化了缓存路径 | 建冷启动预算 |
| 阻断后体积仍涨 | 例外滥用、阈值未校准 | 例外设有效期 + 月度校准 |
14. 边界
| 场景 | 不做的理由 | 替代 |
|---|---|---|
| 内部系统 | 设备网络可控,容忍度高 | 只守"首屏可用" |
| 低频页面 | 优化成本高于收益 | 保功能正确,允许慢 |
| 原型/实验页 | 生命周期短 | 不设门禁 |
| 小团队低频发版 | 维护成本高于损失 | 季度体检代替门禁 |
预算是一种注意力分配工具。它消耗的注意力超过它省下的时间,就该缩小范围,而不是加更多指标。
15. 自测
- 团队只有一个"总 JS 体积"预算,最容易掩盖哪类成本?
- LCP 达标但用户仍抱怨卡,该看哪个指标,它为什么在实验室测不出?
manualChunks按包名逐个切块,同时破坏了哪两件事?- 阻断式门禁上线三个月后失效,最常见原因是什么?
- 同一页面为什么要冷启动与热启动两套预算?
- 什么信号说明该缩小预算范围,而不是继续加指标?