2945 字
约 9 分钟
21
S14-A06 · 前端性能预算:指标、归因与守门

发布前跑一次 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. 自测

  1. 团队只有一个"总 JS 体积"预算,最容易掩盖哪类成本?
  2. LCP 达标但用户仍抱怨卡,该看哪个指标,它为什么在实验室测不出?
  3. manualChunks 按包名逐个切块,同时破坏了哪两件事?
  4. 阻断式门禁上线三个月后失效,最常见原因是什么?
  5. 同一页面为什么要冷启动与热启动两套预算?
  6. 什么信号说明该缩小预算范围,而不是继续加指标?
S14-A06 · 前端性能预算:指标、归因与守门
https://www.clxhxhhr.top/posts/6150/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。