给博客定一个性能预算:首屏 150KB

给自己的博客定了条硬规矩:首屏关键资源不超过 150KB。用这条预算倒推出来的优化清单,比任何泛泛的“性能优化建议”都管用。
我那个博客最早上线时,首屏加载要拉接近 8MB 的资源,其中绝大多数是我随手丢进去的未压缩图片和一个全家桶式的 UI 框架。那时候不觉得有什么,自家宽带快,打开也就两三秒。直到有次在手机上用流量访问自己写的文章,转圈转了快十秒才出来,我才意识到问题:性能不是某一次"慢得离谱"的事故,而是日积月累的"温水煮青蛙"。你每加一张图、每引一个 SDK,首屏就悄悄胖一点,等哪天真的卡了,已经积重难返。
后来我给自己定了一条硬性规矩:首屏关键路径的总传输体积,预算上限是 150KB(不含用户主动触发才加载的内容)。这篇想讲清楚,为什么是 150KB 而不是别的数字、这 150KB 怎么分配、怎么测量、以及超了之后按什么流程砍。定预算这件事,本质是把"性能"从一个模糊的体感,变成一个可以写进 CI、可以自动拦截的硬指标。
为什么定预算,而不是"尽量快"
没有预算的性能优化,很像没有预算的家庭开支:每次都说"就加这么一点",月底一看超支严重。Web 性能尤其容易劣化,因为它没有"报错"这一说,多加一个 200KB 的字体、多引一个分析脚本,页面照样能打开,只是慢一点。没人会在 code review 里因为"首屏多了 50KB"而拦下你的 PR,因为根本没有那条红线存在。
预算的价值,就是画下这条红线。它把主观的"有点慢"变成客观的"超了 12KB,CI 不让你合"。我见过太多团队把性能优化当成季度冲刺,集中两周狂砍一遍,然后半年后又胖回来。根本原因是没有持续约束,优化是一次性项目,而劣化是持续发生的过程。预算让约束常态化,每一次提交都在预算内,劣化就无处藏身。
那为什么是 150KB,不是 100KB 或 500KB?坦白说没有物理定律规定这个数字,它来自一个经验性的权衡:在中等网络(比如 4G 非理想条件)下,150KB 的关键路径配合合理压缩,首屏可交互时间能控制在一个让人舒服的区间;再往上加,边际体验收益快速递减,而风险陡增。对个人博客这种内容型站点,150KB 足够撑起清晰的排版、一点交互和一张首图,又不至于沦为臃肿的 SPA。它更像是一个"纪律起点",你可以根据自己的实际网络目标和内容形态上下浮动,但一定要有一个数字,而不是"越小越好"这种无法执行的口号。
150KB 怎么分配
预算定了,下一步是切分。把首屏关键路径按角色拆开,我给自己定的分配是:
- HTML 文档本身:≤ 15KB。博客文章页的 HTML 就是文字加少量结构,这个数字很宽裕,超了多半是模板里塞了太多内联东西。
- CSS:≤ 35KB(gzip 后)。用 Tailwind 就走 JIT 只打包用到的类,别引完整版;手写 CSS 就注意别堆一堆用不上的重置和动画。
- JavaScript:≤ 40KB(gzip 后)。这是最该抠的部分。Vue3 走按需引入、用
defineAsyncComponent拆路由,首屏只留真正必要的逻辑,其余按需加载。 - 关键图片(首屏那一张):≤ 60KB。用现代格式 + 合理尺寸,下面专门讲。
加起来 150KB,且每一项都有独立上限。为什么分项限而不是只限总和?因为只限总和会诱导你"用图片的额度去补 JS 的超支",结果页面交互越来越重、图越来越糊,整体观感反而差。分项限额迫使每一项各守本分。预算约束的是结构,不只是总量。
// vite 构建后,用脚本检查各 chunk 体积,超阈值就非零退出
import { readFileSync, readdirSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
const BUDGET = { html: 15, css: 35, js: 40, hero: 60 }; // KB,gzip 后
const dist = 'dist/assets';
let failed = false;
for (const f of readdirSync(dist)) {
const raw = readFileSync(`${dist}/${f}`);
const kb = gzipSync(raw).length / 1024;
const kind = f.endsWith('.css') ? 'css' : f.endsWith('.js') ? 'js' : 'other';
if (BUDGET[kind] && kb > BUDGET[kind]) {
console.error(`超预算: ${f} = ${kb.toFixed(1)}KB > ${BUDGET[kind]}KB`);
failed = true;
}
}
process.exit(failed ? 1 : 0);怎么测量:别靠体感,靠工具链
定预算的前提是能量得准。我日常依赖三层测量,覆盖不同粒度。
第一层是浏览器 DevTools 的 Network 面板。勾上"Disable cache"和"Slow 4G"节流,刷新页面,看首屏关键请求的传输大小(注意看"transferred"而非"resources",前者才是真路上跑的字节)。这是最直观的现场感,也最容易发现"某个不起眼请求其实很大"。
第二层是 Lighthouse。它给性能打分,但我要的不是那个分,而是它列出的" Opportunities":比如"用现代格式提供图片可省 300KB"、"移除未使用的 JS 可省 80KB"。这些就是砍体积的待办清单。不过我从不把 Lighthouse 分数当 KPI,它受运行环境影响波动大,预算才是我的硬指标,Lighthouse 只是诊断工具。
第三层是构建产物的体积扫描,也就是上一节的脚本,接进 CI 做自动断言。前两层是"人看",第三层是"机器守门",三者缺一不可。只有人看、没有机器守,超支会漏过;只有机器守、没人看,你不知道该从哪里砍。
# GitHub Actions 里的体积断言步骤
- name: Build
run: npm run build
- name: Check bundle budget
run: node scripts/check-budget.mjs
- name: Lighthouse (仅报告,不阻断)
run: npx lighthouse http://localhost:4173 --only-categories=performance --output=json这里我把预算检查设为阻断、Lighthouse 设为仅报告,是一种有意的取舍:预算是必须遵守的契约,Lighthouse 是参考信息。如果把 Lighthouse 分数也设成阻断,团队会因为环境波动频繁红流水线,最后要么被无视要么被绕过,反而坏了规矩。
图片是最大头:8MB 里 90% 都是它
回头看我那 8MB 的初版博客,图片占了七兆多。解决图片,是解决性能问题的九成功夫。三件事叠加,效果立竿见影。
第一,尺寸适配。我之前把相机直出的 4000×3000 原图直接丢上网页,而首屏图实际显示区域只有 800 像素宽。浏览器下载的是原图,显示时再缩小,纯浪费。正确做法是按显示尺寸出图,用 srcset 给不同屏幕密度提供不同分辨率:
<img
src="/img/hero-800.webp"
srcset="/img/hero-400.webp 400w, /img/hero-800.webp 800w, /img/hero-1200.webp 1200w"
sizes="(max-width: 600px) 400px, 800px"
alt="文章首图"
loading="lazy"
/>第二,格式压缩。同样一张图,PNG 转 WebP 通常能小一半以上,AVIF 更小。现代浏览器普遍支持 WebP,AVIF 兼容性稍弱但下降空间更大。我现在的流程是构建时把图片统一转 WebP/AVIF 双格式,HTML 里用 <picture> 让新浏览器吃更小的 AVIF,老浏览器回落 WebP。
<picture>
<source srcset="/img/hero-800.avif" type="image/avif" />
<source srcset="/img/hero-800.webp" type="image/webp" />
<img src="/img/hero-800.jpg" alt="文章首图" loading="lazy" />
</picture>第三,懒加载。首屏以下、以及非首图的内容图,一律 loading="lazy",浏览器只在快滚到时才下载。首图本身不要懒加载,它就在视口里,懒加载反而延迟显示。区分"首屏关键图"和"其余图",是图片策略的核心判断。
字体与第三方脚本的克制
除了图片,最容易偷偷膨胀的是字体和第三方脚本,而且它们的代价不止体积,还有性能之外的隐患。
字体方面,我最初引了整套 Google Fonts 的思源黑体,光字重就四个,woff2 加起来大几百 KB,还多了几次外部 DNS + 连接 + 下载。后来我做减法:只保留正文一个字重、标题用系统字体栈兜底;如果必须用 Web 字体,就用 font-display: swap 让文字先以系统字体显示、字体到了再换,避免"文字 invisible 直到字体加载完"的空白期。更进一步,我用 subset 把字体只截取用到的字符子集(中文做全子集不现实,但英文/数字标题完全可以),体积能再砍一截。
/* 字体先以系统栈显示,避免阻塞首屏文字 */
body {
font-family: "Source Han Sans", system-ui, -apple-system, sans-serif;
}
@font-face {
font-family: "Source Han Sans";
src: url("/fonts/sans-subset.woff2") format("woff2");
font-display: swap;
}第三方脚本是另一个重灾区:分析工具、评论系统、分享按钮、各种 widget,每个都说"就一小段"。加起来轻松几百 KB,还阻塞主线程、拖慢可交互时间,更别提隐私和数据外发的问题。我的原则是默认零第三方脚本,真的需要(比如我保留了一个极简的、自己托管的访问计数)就自己实现一个轻量版,绝不引全家桶。外部脚本能不引就不引,引了就 async 或 defer 加载,绝不让它挡住首屏解析。
超预算时的处理流程:先测量,再砍,进黑名单
预算被 CI 拦下时,别急着乱砍,按固定流程走,避免越砍越乱。
第一步,先测量,定位是谁超了。跑构建产物扫描,看哪一项、哪一个文件超了。十有八九是某个新引的库把 JS 顶爆,或某张新图没走压缩。定位比动手重要,盲目删代码容易误伤功能。
第二步,再砍,按性价比排序。优先级通常是:图片没优化 → 引了完整版库而非按需 → 某个功能其实是可选的、可以懒加载或删除 → 框架本身过大。我会先用打包分析的 rollup-plugin-visualizer 看依赖图,找到那个占大头却只用了其中一小部分的函数,换成按需引入或自己手写几行替代。
// vite 配置里生成依赖可视化报告,定位体积大户
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [visualizer({ filename: 'dist/stats.html', gzipSize: true })],
};第三步,进黑名单。那些反复引起超预算、又确实没必要长期保留的东西,写进项目的 PERF_BLACKLIST.md:比如"禁止引入 moment.js,用 day.js 或原生 Intl"、"禁止引完整 Tailwind,必须 purge"、"新增图片必须先过压缩流水线"。黑名单是预算制度的沉淀,把每次砍出来的经验固化成规则,后面的人就不会再踩。
这套流程跑顺之后,性能优化就从"季度救火"变成了"日常无感":每次提交都在线内,半年下来首屏体积纹丝不动,而功能一直在长。这就是预算最迷人的地方:它不要求你某天突然变厉害,只要求你别每天偷偷变胖。
真实经历:从 8MB 到几百 KB
把前面这些落到我自己的博客上,时间线是这样的。初版(Vue3 + 一个 UI 库 + 原图直出)首屏 8MB 出头,手机流量下十秒才出。第一步我做了图片流水线:所有图片构建时转 WebP、按尺寸出多份、非首图懒加载,这一刀直接砍到 1.2MB 左右,体感从"转圈十秒"变成"一两秒",收益最大。
第二步处理 JS。我发现引的 UI 库只用了其中三个组件,却把整个库打包进来了。改成按需引入 + 路由级 defineAsyncComponent 拆包,首屏 JS 从 600 多 KB 降到 40KB 量级(gzip 后)。第三步是字体,砍掉多余字重、加 font-display: swap、英文标题做子集,又省下几百 KB。
最后我把预算脚本和黑名单接进 CI,此后任何让首屏超 150KB 的提交都会被挡下。现在稳定态的首屏关键路径在 120–140KB 之间浮动,留了一点缓冲。最让我满意的不只是数字变好看了,而是"性能"这件事终于从我的待办清单里消失,它不再需要我专门花时间,因为系统在替我守着。对个人开发者来说,这种"设好规矩就不用再操心"的解放感,比省下的那几 MB 更值钱。
缓存与协议层:预算之外,还有"复用"这一刀
预算管的是"每一次传输最多多少字节",但用户第二次访问时,根本不该再传一遍。很多人只盯预算数字,却忘了 HTTP 缓存和协议层能直接把"首屏实际下载量"打到接近零。预算是下限的纪律,缓存是上限的福利,两者要一起用,缺一都会让你白忙活。
第一件事是设对缓存策略。我的博客把所有带 hash 的静态资源(JS、CSS、图片)标成 Cache-Control: public, max-age=31536000, immutable,因为它们的文件名带内容哈希,内容变文件名就变,浏览器可以放心缓存一年。HTML 文档本身则标成 no-cache 或很短的 max-age,保证你更新文章后用户能拿到新页面,但里面的资源还能复用旧缓存。这一来,老读者复诊同一篇文章,首屏几乎只在拉一个几 KB 的 HTML,其余全走本地缓存,预算的 150KB 对他而言只是"首次访问的代价",回访几乎零成本。
第二件事是压缩算法别只满足 gzip。上面脚本测的是 gzip,但现代 CDN 普遍支持 Brotli,同等内容通常还能再小 15%–25%,尤其对 CSS 和 JS 这种文本资源。我把构建产物同时留 gzip 和 br 两份,服务器按请求头里的 Accept-Encoding 选发,既照顾老客户端又不浪费新浏览器的带宽。注意别在预算脚本里只看 gzip 就以为到顶了,Brotli 下你其实还有余量可以"花"在更好的交互上,别让一个保守的压缩假设白白限制了你的功能。
# 我常用的 curl 检查:服务端到底发的是 gzip 还是 br
curl -sI -H "Accept-Encoding: br, gzip" https://tiana.example.com/assets/index.js \
| grep -i "content-encoding"第三件事是 HTTP/2 和 103 Early Hints。预算把单次首屏压到 150KB,但如果这 150KB 要排队、要等 HTML 回来才知道要拉什么,延迟还是高。我开了 HTTP/2 多路复用,让 JS、CSS、首批图片并行下载;更关键的是用 103 Early Hints,在 HTML 还没生成完就先告诉浏览器"你要的 CSS 和 JS 是这个 URL,先去拉",把关键资源的下载和 HTML 的生成重叠起来。预算治的是"体积",Early Hints 治的是"顺序",两者叠加,老读者和弱网下读者的体感差距会非常明显,有时候比单纯砍体积还管用。
一句话:预算管"第一次别太胖",缓存和协议管"第二次几乎不胖"。只做前者,你省了首访用户的流量;两者都做,你连回访用户的耐心也一起省了。
预算的边界:什么时候这套做法会拧巴
我得诚实说,150KB 预算不是宇宙真理,它在我的场景里成立,换到别的场景可能拧巴。把它当成"纪律起点"而非"铁律",先想清楚你的场景和我的有什么不同,别拿着我的数字去压所有人的需求。
边界一:如果你的站点是重度交互的应用,不是内容博客。我这套分配里 JS 只有 40KB,因为博客首屏几乎不需要逻辑。但一个在线表格、一个设计工具、一个实时协作的白板,首屏就必须带大量 JS 才能动起来。硬卡 150KB 会把功能砍残。正确做法是按"关键路径要干多少活"重新定预算,甚至把预算从"首屏体积"改成"可交互时间":体积只是手段,能做事才是目的,别本末倒置。
边界二:内容本身就该是富媒体。我那篇讲摄影的文章,首图就是主角,强行压到 60KB 会糊到看不清光圈和景深。这种时候我会对单篇文章放行更大的图片预算,但用 srcset 让小屏拿小图、大屏才拿大图,并且把超出部分明确标记为"内容必需"而非"技术浪费"。预算要留一个"内容豁免"通道,否则它会变成压制表达的枷锁,逼着你在"达标"和"把图讲清楚"之间二选一。
边界三:团队规模与成熟度。我一个人维护博客,预算和黑名单写进 CI 就够了。但大团队里,一个全局硬预算会卡住别人的合理需求,需要把预算分层:核心首屏严守,非首屏的资源走"软预算"加定期审查。我见过团队把预算设成绝对红线后,开发者为了不红流水线,偷偷把逻辑塞进 Web Worker 或延迟到首屏之后,结果问题没消失,只是藏起来了,预算变成了猫鼠游戏,红线名存实亡。
边界四:测量本身有误差。gzip 后体积、DevTools 的 transferred、Lighthouse 的模拟,三者口径并不完全一致。如果你的预算阈值卡得太贴边(比如 149KB 和 150KB 反复横跳),CI 会像抽风一样红。我的经验是留 10%–15% 缓冲带,把硬红线设在 150KB,但把"告警线"设在 130KB,超过告警线不阻断,只提醒作者"你快到了",给持续优化留余地,也避免阈值附近的无谓红流水线。懂得边界,比懂得规则更重要。规则让你不犯错,边界让你知道规则什么时候该让位给判断。
常见问答:关于性能预算的几个真实疑问
写这篇时,几个朋友读了草稿提了问题,我觉得挺典型,统一答在这里,省得以后重复解释。
问:我不用框架、纯手写 HTML,还需要预算吗?
答:更需要。没有框架帮你打包,你更容易随手粘一段 jQuery、一个 Google Analytics 全家桶进去,积少成多更隐蔽。预算的价值和用什么技术栈无关,只和"你有没有一条红线"有关。纯静态站我反而建议把阈值定更狠,比如 80KB,因为你能做到的余地更大,省下的也都是实打实的用户流量。
问:预算该按 gzip 后还是原始体积算?
答:按 gzip(或 Brotli)后算。用户真在路上跑的是压缩后的字节,原始体积再大,压完小就行。但有个坑:别只看原始体积自我安慰,有些库原始小、压缩后占比大(比如已经高度随机化的内容压不动),所以务必在 CI 里测压缩后体积,这正是前面脚本做的事,别偷懒只看过打包前的源码大小。
问:图片预算 60KB 是不是太抠了?
答:对首屏单张而言很合理,但要看显示尺寸。一张全宽 banner 和一张缩略图该有不同预算。我实际是按"显示区域面积"反推的:每屏像素乘合理压缩率约等于目标字节。60KB 对应的是 800px 宽、质量适中的 WebP。你若常用大图,把这个数字调成 100KB 也行,关键是"每一项都有数",而不是"总体差不多":分项有数才能逼着每一项各守本分。
问:CI 拦下超预算的提交,但 deadline 到了必须上线怎么办?
答:两条路。临时路:给预算脚本加一个环境变量开关,比如 PERF_BUDGET_OVERRIDE=1 允许本次跳过,但必须在 PR 描述里写明原因和下次偿还计划,把"破例"变成"有记录的债",而不是无痕绕过。长期路:复盘是不是预算本身定错了,如果是,就改预算并同步更新黑名单,让例外变成新规则。我最反对的是静默绕过,那等于预算名存实亡,下次就没人当真了。
问:移动端和桌面端要两套预算吗?
答:不必。现代做法是用 srcset/picture 让同一份 HTML 在不同设备拿不同资源,预算针对"最重那一路"(通常是中端移动设备)设。如果你有大量桌面专属的大图,那才值得单独设桌面预算。绝大多数个人站点,一套面向移动弱网的预算就够覆盖两端了,分两套反而增加维护负担。
真正的考场:移动端弱网而不是实验室
预算和脚本都只是"设计时的约束",真正检验它的是真实用户的网络。我犯过的一个错误是:只在办公室的宽带和 DevTools 的 Slow 4G 节流下测,觉得 150KB 稳如老狗,结果一位读者在地铁里用 4G 反馈"你的文章偶尔还是转圈"。我这才意识到,实验室的 Slow 4G 是稳定限速,而真实弱网是抖动、丢包、信号时断时续,同样的 150KB,在抖动网络下首屏可能要分好几轮握手才能传完,体感比稳定限速差得多。
后来我做两件事补这个盲区。第一件是把"首屏关键请求数"也当成指标一起卡:请求数越多,弱网下建立连接的次数越多,抖动时被打断的概率越高。我尽量把首屏资源收敛到三四个文件以内,JS、CSS 各自合并,减少"要连好几次才能凑齐"的脆弱性。预算管的是"单个文件多大",而请求数管的是"要连几次",两者在弱网下是相乘的关系。
第二件是拿真机测,而不是只在桌面浏览器里节流。我在旧手机上装了调试版,坐地铁、进电梯、走到信号死角,实打实刷新自己的文章看转圈多久。这种测试很难自动化,但它能戳破实验室数据给你的虚假安全感。我甚至养成了习惯:每次大改发布前,用流量(不开 WiFi)在自己手机上打开一次,确认首屏真的在两三秒内出来,这比任何 CI 报告都更直接。
预算是书桌上的规矩,弱网才是户外的考场。你在 CI 里把它守得再严,只要没在真实网络里亲眼看过一次首屏,就别说自己"性能过关了"。
我还有一个小习惯顺带说一下:把首屏体积和首屏请求数这两个数字,连同每次发布的日期,随手记在一个随手的笔记里。不为了画图,只是隔几个月翻一眼,看它是不是有缓慢爬升的趋势。预算守住了"单次提交不超线",但架不住半年里每次都贴着 148KB 上线、悄悄逼近红线。这种"温水煮青蛙"式的缓慢膨胀,脚本拦不住,只有人隔段时间回头看一眼才看得见。这也是为什么我开头说,性能是日积月累的功课,不是某次冲刺能一劳永逸的。