前端性能优化实战:让首屏加载从 3 秒降到 0.8 秒
前端性能优化实战:让首屏加载从 3 秒降到 0.8 秒
今年年初,我接手了一个已经上线两年的内容型站点。产品同事把一份用户访谈丢给我,里面出现频率最高的一句话是:“点开之后要愣一下,好像没反应,我就退出去看别的了。” 我去真机上测了一遍,中端安卓机、4G 限速、冷启动,LCP 3.1 秒。那一刻我意识到,这不是“能不能再快一点”的优化题,而是一道转化率题——用户在首屏出现之前就走了。
三个月之后,同样一台机器、同样的网络,LCP 稳定在 0.8 秒左右,INP 从 320 毫秒降到 110 毫秒,首屏传输的 JavaScript 从 1.8MB 降到 210KB。这篇文章我想把整个过程摊开讲,不是为了炫耀几个数字,而是想说明一个观点:首屏优化的胜负,八成取决于你愿不愿意做减法,而不是你掌握了多少奇技淫巧。
一、先别急着优化:把“3 秒”拆成可以下手的碎片
我见过太多团队在性能问题上的第一反应是——换个构建工具、升级框架、买更贵的 CDN。这些都是成本很高、收益随机的事情。我做的第一件事反而是什么都不改,只加测量。
“首屏加载完成”这句话本身就很含糊,所以我先给它下定义:用户看到主要内容(LCP)、页面能响应点击(INP)、并且布局不再跳动(CLS)。这三个指标之外,我还加了一个业务侧的自定义指标——首屏可交互时间,因为对我们这种以阅读和点击为主的页面来说,“能点”比“好看完”更重要。
测量工具分两层。Lighthouse 给的是实验室数据,它有用,但只在你的机器上、你的网络下成立,很容易自我感觉良好。真正说话的是 RUM:用 PerformanceObserver 在真实用户的浏览器里采集 LCP、INP、CLS 和长任务,按机型档位、网络类型、入口来源分组,然后看 P75。我特别想强调这一点:平均值是性能监控里最会骗人的统计量。 平均值 1.2 秒的背后,可能是 70% 的用户 0.6 秒、30% 的用户 4 秒。而恰恰是那 30% 在流失。
拿到基线之后,问题的形状就清楚了。我们的首屏冷启动看起来是这样:TTFB 480 毫秒,FCP 1.6 秒,LCP 3.1 秒,首屏需要传输 78 个请求、1.8MB 的 JS(Brotli 后)、2.4MB 的图片,外加三个字体文件 480KB。看完这份清单我就明白了:瓶颈不在“代码写得慢”,而在“送得太多、太早、太重”。
于是整个优化被我拆成四刀:让 JavaScript 按需到场、把关键渲染路径压到极限、干掉图片和字体的隐形税、让主线程喘口气。 下面逐条说。
二、第一刀:让 JavaScript 按需到场
首屏最大的浪费,是把用户此刻用不到的代码也一起下载、解析、执行了。我们做的第一件事是路由级代码分割:把首页必需的最小闭包留在主 chunk 里,其他路由、弹窗、编辑器、图表全部改成动态 import()。这一步不需要改任何业务逻辑,收益却立竿见影。
第二件事是做依赖审计。我用产物分析工具看打包结果,发现有三个“体积吸血鬼”:一个是完整引入的 moment(将近 290KB),其实只用来格式化几个日期,换成 dayjs 之后几乎可以忽略;一个是 lodash 全量引入,改成按需引入;还有一个最典型——一个只在后台设置页使用的图表库,被首页误引了,占了首屏体积的三分之一。这类问题几乎每个项目都有,而且往往不是技术问题,是没人去看产物。
第三件事是清理 polyfill。我们原本用 browserslist 的保守配置,打进了一大堆现代浏览器根本不需要的垫片。把目标环境提高、polyfill 改成按使用情况注入之后,产物体积又降了一档,配合 esbuild 压缩和 Brotli,效果比手工“优化代码”明显得多。这里有个容易踩的坑:看体积一定要看传输后的大小,而不是源码行数。 一段看起来冗长的配置代码,压缩后可能只有几 KB。
几刀下去,首屏 JS 从 1.8MB 降到 210KB。这个数字比我预期的好,原因也很朴素:绝大多数首屏代码,本来就不该在首屏加载。
三、第二刀:把关键渲染路径压到极限
JS 少了之后,下一个瓶颈是 HTML 和 CSS 的到达速度。
我做了三件事。第一,把关键 CSS 内联进 <head>(抽出来大约 8KB),其余样式异步加载。这样浏览器拿到 HTML 就能立刻画出首屏骨架,不用等一个额外的往返。第二,把首屏改成服务端渲染 + 静态生成,直接输出完整 HTML,同时严格控制客户端水合的范围——我们没有做全页水合,而是让交互组件各自“认领”自己的岛屿。全页水合在低端机上的代价极大,它会把主线程占满好几百毫秒,用户点什么都没反应。第三,把 TTFB 从 480 毫秒压到 90 毫秒:静态 HTML 放到边缘节点缓存,配合 stale-while-revalidate 让用户始终命中缓存、后台异步更新;同时开启 HTTP/3,让首次连接少一个往返。
资源提示也值得说一句。我们给图片 CDN 和字体 CDN 加了 preconnect,给接口域名加了 dns-prefetch。但我特意控制了数量——preconnect 超过四五个就开始互相抢带宽,尤其是移动网络下,你以为在帮忙,其实在添乱。另外我们清掉了两个历史遗留的 302 跳转,冷启动直接少了一个完整往返。
四、第三刀:图片和字体是首屏的隐形税
图片通常是一个页面里最重的东西,也是最容易被忽视的东西。我们的做法是把首屏大图改成 AVIF,配上 <picture> 和 srcset/sizes,让不同屏幕拿到不同尺寸的资源;同时给首屏主图加 fetchpriority="high"。这里我要点名一个非常常见的反模式:给首屏图片加 loading="lazy"。 懒加载是为了推迟视口外的东西,用在首屏大图上,等于主动让浏览器晚一点发现它,LCP 必然变差。其余图片则老老实实懒加载,并且都写上宽高,避免布局跳动。
这套组合拳下来,首屏图片体积从 2.4MB 降到 180KB。
字体同样是一笔隐形税。我们原本加载了三个字体文件共 480KB,其中大部分字形用户根本不会看到。改成单个可变字体、做子集化(只保留常用汉字和拉丁字符)之后,只剩 42KB;配合 font-display: swap、关键字体 preload,以及用 size-adjust 对齐回退字体的度量,重排带来的抖动也基本消失了。图标字体则全部换成按需的 SVG,省掉了一整个字体请求。
结果是 CLS 从 0.18 掉到 0.02。这个指标不像 LCP 那样引人注目,但它是“页面看起来很稳”的来源。
五、第四刀:让主线程喘口气
前面几刀都是网络层的,真正让用户觉得“卡”的,往往是主线程被占满。
我用 PerformanceObserver 监听长任务,把任何超过 50 毫秒的任务当 bug 处理。很快发现元凶是第三方脚本:客服 SDK、埋点、A/B 测试框架,加起来占了首屏 CPU 时间的一半以上。处理方式有三种,优先级从高到低:能删的删掉(我们真的删掉了两个从没人看过数据的 SDK);不能删的延后,等首屏渲染完成、甚至等用户第一次交互后再加载;必须早加载的隔离,通过 Web Worker 把它们挪出主线程。
另一个长任务来源是渲染本身。一个上千条的列表一次性塞进 DOM,低端机上要几百毫秒。换虚拟列表之后,首屏只渲染可见的十几条。再加上用 scheduler.yield() 把大计算切成小片,让浏览器有机会响应用户输入,INP 从 320 毫秒降到 110 毫秒。
六、缓存与预取:让第二次访问几乎免费
冷启动做到 0.8 秒之后,我又盯上了“第二次访问”。这部分靠的是缓存策略:静态资源带内容哈希、immutable 缓存一年,HTML 用边缘缓存加后台刷新,Service Worker 负责在离线或弱网时兜底。用户第二次打开时,首屏几乎是瞬间出现的。
更进一步的是预取。我们用 Speculation Rules API,在用户悬停或按下链接时预渲染下一个页面,导航体验接近原生应用。但预取必须有预算,别把用户的流量当成免费资源——我们只在 Wi-Fi 和 4G 且非省流模式下开启,并且限制预渲染页面数量。
七、0.8 秒之后:把性能变成预算和纪律
所有优化都有一个共同的结局:如果不设防线,半年后它会悄悄回退。所以最后一步是把性能写成预算,放进 CI:首屏 JS 不超过 200KB(Brotli 后)、LCP 的 P75 不超过 1.2 秒、INP 不超过 200 毫秒,任何一个 PR 让数字超标,构建就失败。每个 PR 自动附带体积差异报告,谁引入了 30KB 的新依赖,一眼就能看到。
同时,RUM 面板按机型档位看 P75,而不是看全局平均。我们会定期回到真机上手动跑一遍,因为自动化工具永远测不出“在旧手机上滑动时的那种粘滞感”。
需要诚实地说:0.8 秒是一个特定条件下的结果——中端安卓、4G、冷启动、P75。它不是放之四海皆准的 SLA。一个视频站点和一个表单页面的目标完全不同,盲目追求数字只会让你在错误的地方花力气。
写在最后
回头看这三个月,最有效的几件事没有一件是“高深技术”:删掉用不到的依赖、把非首屏代码拆出去、首屏图片不用懒加载、字体做子集化、第三方脚本延后加载。它们共同的特征是做减法——少送一点、晚送一点、提前算一点。
我越来越觉得,前端性能本质上不是一个工程问题,而是一个产品决策问题。砍掉一个没人看的第三方 SDK,收益往往大于你把构建配置调三个通宵。而当你愿意为“快”在需求评审上争论、愿意为一个 30KB 的依赖说“不”的时候,3 秒到 0.8 秒就不是奇迹,只是水到渠成。
用户的耐心不会因为你用了什么框架而变长。他们只会用脚投票:能立刻打开的页面,才是好页面。