性能预算与门禁
性能优化做一次容易,保持住难。性能预算把「快」变成一个可被 CI 拦截的硬指标。
一、体积、请求数与核心指标
预算要拆成两类:资源预算(JS/CSS 总体积、首屏关键资源数量与体积、图片与字体体积、第三方脚本数量)和指标预算(LCP、INP、CLS 的目标值与「需要改进」的阈值)。只定指标不定资源,优化无从下手;只定资源不定指标,优化可能没落到体验上。
还有一类容易被漏掉的是数量预算:首屏请求数、第三方脚本个数、字体文件数。这些单个看着不大,叠加起来才是首屏被拖慢的常态。三类一起定,才能把「快」拆成可度量的条目。
二、把预算接进流水线
落地要区分「实验室数据」和「真实用户数据」。实验室数据在 CI 里跑:固定环境(同样的机型、网络节流档位)、对关键页面跑分、和基线对比,超过阈值就让构建失败。关键是多轮取中位数——单次跑分抖动很大,直接拿单次值当门禁,门禁会变成噪声,团队很快就开始忽略它。
产物体积门禁用 size-limit、bundlesize 一类工具卡压缩后体积;指标门禁用 Lighthouse CI 一类工具对关键路由跑分。两者职责不同,不要互相替代。门禁工具在 CI 里通常以「超阈值即非零退出」的方式工作,构建直接失败:
预算还要分级:首页、商详、结算这类关键路由该更紧,后台管理页可以宽松,不要所有页面一个标准。打包器本身也能卡体积——Webpack 的 performance.maxAssetSize / maxEntrypointSize、Vite 的 build.chunkSizeWarningLimit 都能在构建里直接告警或报错,和 CI 门禁互补。阈值别一上来就定死:先宽松、随技术债还清再逐步收紧,留一点余量,否则门禁会变成「永远红」。
# CI 里跑体积门禁,超阈值即失败
npx size-limit
# Lighthouse CI 思路(工具类别,不绑定具体产品)
ci:
collect:
url: ["http://localhost:3000/", "http://localhost:3000/list"]
settings: { preset: "desktop", throttling: "provided" }
assert:
assertions:
"categories:performance": ["warn", { "minScore": 0.9 }]
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
// 体积门禁思路(压缩后体积上限)
{ "size-limit": [{ "path": "dist/static/js/main-*.js", "limit": "170 KB" }] }
| 预算项 | 类型 | 说明 |
|---|---|---|
| 首屏 JS 体积 | 资源 | 压缩后上限,按路由分 |
| 第三方脚本数 | 数量 | 每个都要登记用途与体积 |
| LCP / INP / CLS | 指标 | 目标值与「需改进」阈值 |
三、第三方脚本的失控
自己写的代码是可控的,第三方脚本不是:埋点、客服、AB 实验、广告像素,每接一个都可能带来几百 KB 与额外的主线程占用。管理办法只有两条:建立准入清单(谁能加、加什么、为什么必须),以及强制异步加载与延迟执行。性能退化追查到最后,通常是「上个季度接的那个脚本」。
异步加载之外,还要给超时降级:第三方脚本加载失败或超时时,不能阻塞首屏渲染,更不能让页面白屏。把「这个脚本挂了会怎样」当成设计项,而不是等到线上出问题才想。
四、超限时的处理流程
超阈值时,先区分是新代码还是存量。新代码应该硬性卡住——合入即失败,逼着作者在提交前处理。存量问题则设递减目标,逐步收敛,而不是一刀切要求立刻达标,否则存量团队会直接去改门禁阈值来「通过」。
定位增量来自哪个依赖,用产物分析配「对比上次构建的体积 diff」最快。如果确实暂时降不下来,可以申请临时豁免,但豁免必须有到期时间——没有到期的豁免会变成永久漏洞。
预算要落成可执行的配置,口头约定守不住:
[
{ "path": "dist/app.*.js", "limit": "180 kB" },
{ "path": "dist/vendor.*.js", "limit": "220 kB" },
{ "path": "dist/*.css", "limit": "60 kB" }
]
五、让门禁不被忽略
门禁长期红着,就会被所有人忽略,等于没有。两个做法:实验室数据与真实用户数据(分位数)分开看,前者用于卡合入、后者用于看趋势;阈值变动要留记录,否则没人知道为什么突然变严或变松。
把性能趋势做成可见的看板,而不是只在失败时才出现,团队才会把它当成日常指标,而不是障碍。
第三方脚本往往不在打包产物里,必须单独设一项才拦得住:
// 统计页面上所有第三方脚本的体积与耗时
const third = performance.getEntriesByType('resource')
.filter((r) => !r.name.startsWith(location.origin))
console.table(third.map((r) => ({
name: r.name.split('/')[2],
kb: +(r.transferSize / 1024).toFixed(1),
ms: +r.duration.toFixed(0),
})))
六、让门禁真正被执行的几个前提
门禁只阻止变慢,存量问题要靠目标递减慢慢收敛,指望设个数字就自动变快是不现实的。
实验室数据达标也不等于用户快——真实用户数据(分位数)和实验室数据要分开看。
阈值更不是越严越好:长期红着的门禁会被所有人忽略,阈值要定在能守住的线上。
预算的作用是让退化变得可见,不是让性能优化自动发生。
把预算卡进 CI 可以直接用 size-limit,它支持按入口与时间维度设限,落地成本很低。