Web 性能预算:把快变成纪律
性能不是"偶尔优化",是持续保持。性能预算(Performance Budget)给"快"定一个不可逾越的线。本文讲清预算怎么定(JS/图片/CSS 各分多少)、CI 自动检查、超预算怎么办(砍功能/优化/谈判),让团队不再"慢了再优化"。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
做过几年前端的人大概都见过这种循环:项目刚上线,页面快得飞起,LCP 一秒出头;半年后开始有人抱怨"怎么越来越卡";一年后打开 Lighthouse 一看,性能分从 95 掉到 60,老板拍桌子要"搞一次性能优化专项";优化两周,分数拉回 85,皆大欢喜;再过半年,同样的剧情再来一遍。
这个循环的根因不是技术,是管理。性能不是某次冲刺能解决的"项目",而是一个必须每天守的"水位"。你团队缺的不是更牛的优化技巧,而是一条不可逾越的线,让"变慢"在 PR 阶段就被挡住,而不是攒到用户骂娘才被发现。
这条线,叫性能预算(Performance Budget)。
一、性能不守就会退,这是物理规律
先建立一个常识:应用只会越变越慢,不会自动越变越快。
原因很朴素。每一次产品迭代,几乎都在往页面里塞东西:新功能要加 JS 组件,运营要加埋点,增长团队要加 A/B 测试 SDK,设计要加一套字体,广告要加第三方脚本,老板要加一个全屏视频 banner。每一次 PR 单看都"只多了 30KB",没人会为 30KB 拍桌子;但一年 200 个 PR 下来,就是 6MB 的垃圾负重。
更阴险的是,慢是非线性累积的:
- JS 体积增加,不仅是下载慢,更致命的是解析、编译、执行都要花主线程时间,直接拖垮 INP;
- 图片多一张,LCP 候选就被分流,首屏文字也被挤到视口外;
- 第三方脚本一多,主线程长期被占用,INP 直接奔着 500ms 去了;
- 字体加一种,FOUT/FOIT 就更难控制,LCP 的文字渲染时间也跟着飘。
所以"偶尔优化"是个伪命题。你不可能在每半年一次的冲刺里,把过去半年累积的债清干净,就像你不可能靠一年一次断食解决全年暴饮暴食。唯一的办法是让它别累积,这就是预算存在的意义。
二、性能预算是什么:给"快"定一个账本
性能预算就是给页面的关键指标定上限,像家庭账本一样:这个月房贷多少、吃饭多少、娱乐多少,加起来不能超过收入。超了就报警,就砍开支。
它和"性能优化"的区别在于:
- 优化是事后的、被动的、一次性的——已经慢了,想办法快起来;
- 预算是事前的、主动的、持续的——还没慢,先规定不能慢到哪里去。
预算要覆盖的指标分两层。
资源体积层(你能直接控制的):
| 指标 | 含义 | 典型预算上限(Mobile) |
|---|---|---|
| JS bundle | 首屏加载的 JS 总量 | ≤ 170KB gzip |
| 路由 JS | 单个路由按需加载的 JS | ≤ 80KB gzip |
| CSS | 首屏 Critical CSS | ≤ 30KB gzip |
| 图片 | 首屏图片总量 | ≤ 100KB |
| 字体 | Web Font 数量 / 体积 | ≤ 2 种、≤ 50KB/个 |
| 第三方脚本 | 外部域名脚本总量 | ≤ 50KB gzip |
| 总请求体积 | 首屏所有资源 | ≤ 500KB |
用户体验层(用户真正感知的):
| 指标 | 含义 | Good 阈值 | 预算建议 |
|---|---|---|---|
| LCP | 最大内容渲染时间 | ≤ 2.5s | ≤ 2.0s(留余量) |
| INP | 交互响应延迟(2024 替代 FID) | ≤ 200ms | ≤ 150ms |
| CLS | 累积布局偏移 | ≤ 0.1 | ≤ 0.05 |
| TTFB | 首字节时间 | ≤ 0.8s | ≤ 0.6s |
两层都要,而且体积预算是手段,体验预算是目的。体积好测、好卡,放 CI 里跑;体验难测、有噪声,放每周 RUM(真实用户监控)报表里看趋势。
三、预算怎么定才不扯皮
预算定太高没意义,定太低没人能开发。怎么定才靠谱?三条腿站着:目标用户的环境、现有基线、行业基准。
第一条腿:目标用户的设备和网络。
这是最重要的依据,也是最容易被忽视的。你的用户是写字楼里用 M2 MacBook、连公司千兆 wifi 的程序员,还是用三年前千元机、在地铁里刷 4G 的下沉市场用户,预算差出一个数量级都不夸张。
一个实用的做法:在 Lighthouse / WebPageTest 里跑你目标用户的"代表机型 + 代表网络",比如 Moto G Power + Slow 4G(1.6Mbps 下行,150ms RTT)。这种环境下 CPU 是桌面机的 4-6 倍慢,网络是 10 倍慢,JS 解析时间会指数级膨胀。如果你的 LCP 在这个配置下能压到 2.5s 以内,那大概就是合理预算。
第二条腿:你自己的基线。
预算不能脱离现状。一个三年没优化的老项目,首屏 JS 800KB,你一刀切"从今天起 JS 不许超过 170KB",所有人当天就摆烂,因为根本做不到。
正确的做法是渐进式预算:先测量当前 P75(取第 75 百分位,别用平均),定一个"不能比现在更差"的硬线作为短期预算,然后定一个三个月/半年后的目标线,逐步往目标收。比如:
- 当前 P75 JS:520KB gzip
- 短期预算(立即生效):≤ 520KB(只许降不许升)
- 季度目标:≤ 400KB
- 年度目标:≤ 250KB
"不能变差"这一条比"达到某个值"更关键。它把性能退化从"温水煮青蛙"变成"每一步都被挡",光这一条就能省掉团队 80% 的事后优化成本。
第三条腿:行业基准。
HTTP Archive 的 Web Almanac、CrUX 报告里有按行业分类的真实数据,可以拿来当锚点。电商类 P75 LCP 多少、新闻类多少、SaaS 多少,知道大盘在哪,你就知道自己定的预算是"有追求"还是"在摆烂"。
四、分项预算:不要只给一个总数
新手预算的错误是只定一个"JS 总量 ≤ 300KB",然后发现一超就崩盘,团队也搞不清是谁的责任。好的预算是分项的,每一项都对应一个明确的责任人。
JS 总预算 + 路由预算。总预算守全局,路由预算防止"一个页面背全家锅"。比如 Next.js 里,/checkout 这种关键路由单独卡 100KB,/dashboard 可以放宽到 150KB,避免一个路由把全局预算吃光。
首屏 vs 按需。首屏 JS(立即下载执行的)和按需 JS(lazy load 的)要分开算。首屏预算要严,因为每一 KB 都直接拖 LCP 和 INP;按需的可以松一些,因为不影响首屏。
图片预算。按用途分:首屏 LCP 图片单独卡(体积 + 尺寸),其他图片宽松点。强制要求所有图片走 <img> 的 loading="lazy"、srcset/sizes、现代格式(AVIF/WebP),配 CDN 实时压缩。
字体预算。一句话:别用第三种字体。一家公司全站字体(含粗细变体)超过 4-5 个文件,就是预算失控。优先 font-display: swap,子集化中文(中文字体动态子集是必须的),preload 关键字体。
第三方脚本预算。这是最难守也最该守的一条。统计、A/B、客服、支付、广告……每一个第三方都想往你 head 里塞 script,每一个都说"我就 5KB"。结果一个电商首页 head 里 30 个第三方域名、300KB 第三方 JS 是常态。定死一个白名单 + 总量上限(比如 ≤ 50KB),任何新加的第三方都要走性能评审。
五、CI 自动检查:预算不进流水线就是摆设
预算写在 wiki 里没人看,贴在墙上没人理,它必须卡在 PR 上才会有效。一个不进 CI 的预算等于没有预算。下面给三套常用工具的配置,任选其一即可,关键是PR 一超预算,CI 直接红,合不了并。
size-limit(我最推荐,适合 Next.js / Vite / webpack 项目)。配置简单,针对 JS bundle,精确到路由:
// package.json
{
"size-limit": [
{ "path": "dist/static/chunks/main.js", "limit": "80 KB", "name": "Main" },
{ "path": "dist/static/chunks/vendor.js", "limit": "120 KB", "name": "Vendor" },
{ "path": "dist/static/pages/**/*.js", "limit": "50 KB", "name": "Per-route" }
],
"scripts": {
"size": "size-limit",
"ci": "npm run build && npm run size"
}
}
PR 跑 npm run ci,任何一项超限,命令 exit 1,GitHub Actions 直接红。
Lighthouse CI(适合检查体验指标 LCP/CLS/TBT)。体验指标噪声大,不能单次卡死,正确做法是断言中位数 + 和基线比:
# .github/workflows/lhci.yml
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://preview.example.com/
https://preview.example.com/product/123
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
temporaryPublicStorage: true
configPath: ./lighthouserc.json
// lighthouserc.json
{
"ci": {
"assert": {
"preset": "lighthouse:no-pwa",
"assertions": {
"largest-contentful-paint": ["error", { "maxScore": 0.9 }],
"cumulative-layout-shift": ["error", { "maxScore": 0.9 }],
"total-blocking-time": ["error", { "maxScore": 0.9 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 175000 }]
}
}
}
}
bundlesize / bundlewatch。最轻量,只管 gzipped 体积,适合先上车:
{
"files": [
{ "path": "dist/main.js", "maxSize": "80 KB" },
{ "path": "dist/vendor.js", "maxSize": "120 KB" }
]
}
一个实操建议:体验类指标(LCP/INP)别设成"硬 fail",设成"和上周基线比变差超过 10% 才 fail"。因为它们对环境敏感,机器一抖就抖出来。体积类(JS/CSS)可以硬卡,因为它确定、可复现、责任清晰。
六、超预算怎么办:砍、优化、谈判
预算定下来之后,真正的考验来了:一个 PR 把 JS 从 165KB 推到了 180KB,超了,怎么办?
第一反应不应该是"我去优化一下塞回去",而是先问三个问题:
- 这 15KB 是不是真的必要?
- 这 15KB 有没有更便宜的替代?
- 这 15KB 值不值得临时上调预算?
按顺序回答,对应砍、优化、谈判三招。
砍(最便宜):掏一个 unused import 检查(knip / depcheck / webpack-bundle-analyzer),90% 的项目都能立刻砍掉 10-30% 的死代码。再问一遍:这个新功能必须在首屏吗?能不能做功能降级,把次要功能 lazy 进去?富文本编辑器是不是首屏不需要、点编辑按钮再加载?砍功能不是认输,是工程判断。
优化(中等成本):砍不动就优化。常见手段按 ROI 排序:
- 代码分割 / 路由级 lazy:Next.js 动态
import()、Reactlazy(),把非首屏代码挪到按需。立竿见影。 - 换轻量库:
moment→date-fns或dayjs(280KB → 2KB),lodash→lodash-es按需 / 原生,moment-timezone能拆掉就拆掉。这是性价比最高的一招。 - tree-shaking 自查:确认
sideEffects: false、确认 ESM 导入、确认没把整个 UI 库都import * from。 - 预编译 vs 运行时:能用 build time 算的别在 runtime 算,能用 server component 的别在 client component 渲染。
谈判(最贵但有时必须):功能真的有价值,优化也优化不动,那就带着数据去谈判。预算不是天条,它是个共识机制。你要做的是:在 PR 描述里写清楚"这个改动会让首屏 JS +15KB,LCP 预估 +120ms,带来的业务价值是 X",让 owner(技术负责人 / PM)决策是否临时上调预算。
关键不是"不让加",而是"加得明明白白,并且为这次上调配套一个还债计划":比如这次让到 195KB,同时承诺下个迭代把 vendor chunk 拆分,把预算拉回 175KB。只让步,不还债,预算就会慢慢通胀成废纸。
七、把预算做成看板,让全队都看到
预算要起作用,光 CI 红不够,还要让人天天看见。看不见的数字等于不存在。
搭一个看板,可以是 Lighthouse CI Server、SpeedCurve、或自己用 Datadog / Grafana 接 RUM 数据。它要回答四个问题:
- 现在离红线多远?——当前体积 / 体验指标 vs 预算,剩多少余量。
- 谁最近动了红线?——近 30 天体积变化曲线,标注每个跃升对应的 PR。
- 哪条路由最危险?——按路由排序的预算占用表,标红快要爆的。
- 趋势是收还是散?——过去 3 个月是逐渐变好还是逐渐变烂。
把看板链接挂在 Slack 频道顶部、晨会屏上、wiki 首页。当每个工程师每天都能看到"我的路由离预算只剩 8KB"时,他写代码时手就会更稳。可视化的本质是把抽象的"性能"变成具体的"数字",把"团队的事"变成"我的事"。
八、文化:把性能当 feature,把预算当合同
工具和流程都到位了,最后一公里是文化。没有文化支撑的预算,三个月后就形同虚设。
几个具体的、可落地的文化动作:
性能是 feature,不是非功能需求。在需求文档里给它留一格,在上线 checklist 里给它一项,在 OKR 里给它一个数。你团队对性能的态度,本质是老板对性能的态度的镜像。
PR 模板里加一行性能说明。强制要求每个 PR 描述填写:"本改动对性能的影响:首屏 JS +/-X KB / LCP +/-Y ms / 无影响"。哪怕 90% 的人填"无影响",剩下 10% 真填了影响的人,就给评审者提供了一个该不该深究的信号。GitHub 上大厂的开源项目早这么干了。
性能评审制度化。超过一定阈值(比如新增 JS > 20KB,或引入新第三方脚本)的 PR,必须拉性能负责人评审。和 code review 一样,变成流程的一环,而不是"想起来才做"。
给"还债"留时间。每个迭代预留 10-15% 的容量给性能维护,就像运维预留容量给故障一样。不预留,那点债永远还不清,只会越滚越大。
别羞辱,要复盘。某个 PR 超预算了,不要在群里@作者。要复盘的是:为什么 CI 没卡住?是预算定得不合理,还是工具漏了场景?预算体系的问题,比单次超预算更值得改。
小结
性能预算这件事,技术含量不高,难在纪律和坚持。几条要害记牢:
- 慢是累积的,只能防不能治。 与其半年一次专项优化,不如每天用预算把退化挡在 PR 外。
- 预算要分项,不要只给总数。 JS 总量 + 路由预算 + 图片 + 字体 + 第三方,各有各的红线,各有各的责任人。
- 预算不进 CI 就是空文。 size-limit / Lighthouse CI 任选其一,体积硬卡,体验指标比基线。
- 超预算的三招是砍、优化、谈判,不是默默优化塞回去。 临时上调必须配套还债计划,否则预算会通胀成摆设。
- 预算要被天天看见。 看板、PR 模板、性能评审、还债容量,把"性能"从团队的事变成每个人的事。
性能不是一次冲刺的终点,而是一条每天都要守的水位线。预算,就是那条线本身。
Related