上个月接了个后台系统的活,首页打开要两三秒才有内容,白屏期间连个 loading 都看不见。用户骂得挺难听,但代码里其实没写什么重逻辑。
问题不在 JS 慢,在于我把该并行的东西排成了一队。
先把流程摊开
浏览器从拿到 HTML 到画出第一帧,大致是这么一条链子:
- 解析 HTML,边解析边生成 DOM 树
- 解析 CSS,生成 CSSOM 树(这一步会阻塞渲染)
- 遇到同步
<script>,停下 HTML 解析,等 JS 下载并执行完 - DOM 与 CSSOM 合并成渲染树
- 布局(Layout):算出每个盒子在哪、多大
- 绘制(Paint)与合成(Composite)
第 3 步是罪魁祸首。我把统计图表库、日期组件、埋点 SDK 三个同步脚本放在了 <head> 里,浏览器只能老老实实排队等它们下载。
<!-- 改之前:三份 JS 串行下载,首屏被吊死 -->
<head>
<script src="/vendor/charts.js"></script>
<script src="/vendor/datepicker.js"></script>
<script src="/vendor/analytics.js"></script>
</head>
第一步:把不着急的脚本挪走
<script> 加上 defer,下载变成并行,执行顺序还在文档之后,DOM 也准备好了。埋点这种完全不影响渲染的,直接换成异步加载。
<head>
<!-- 关键逻辑用 defer:并行下载,DOM 解析完后按序执行 -->
<script src="/vendor/charts.js" defer></script>
<script src="/vendor/datepicker.js" defer></script>
<!-- 埋点不影响首屏,干脆异步 -->
<script src="/vendor/analytics.js" async></script>
</head>
这里有个容易踩的坑:async 的执行时机不确定,脚本之间如果互相依赖就会偶发报错。所以要么只用 defer,要么确认脚本之间真的没有依赖。
第二步:CSS 也要拆
CSS 是渲染阻塞资源,整站样式表越大,白屏越久。但有意思的是,真正撑起首屏的样式往往只占一小部分。我的做法是把首屏需要的布局、颜色、字体规则抽出来内联到 <head>,剩下的用 preload 加延迟加载。
<head>
<style>
/* 首屏关键 CSS:容器宽度、骨架屏、字体、主题色 */
:root { --brand: #4f46e5; }
body { margin: 0; font-family: system-ui, sans-serif; }
.shell { max-width: 960px; margin: 0 auto; padding: 24px; }
.skeleton { height: 14px; border-radius: 6px; background: #eceaf0; }
</style>
<link rel="preload" href="/assets/app.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/assets/app.css"></noscript>
</head>
顺序很关键:先给用户一个结构正确的骨架屏,细节样式慢一点没关系。用户的感知是"页面出来了",而不是"页面白着"。
第三步:别让图片把布局顶来顶去
图片没有宽高,浏览器第一次布局时按 0 高度算,加载完再推下去,整页内容跳动。这个指标叫 CLS,用户感受就是"我点错地方了"。
显式写上宽高,再用 CSS 让它自适应:
.thumb {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
background: #f3f1ee;
}
aspect-ratio 比写死 padding-top: 56.25% 那套 hack 干净太多,现代浏览器都支持,可以放心用。
实测数字
同一台机器、同一个网络环境,用 Chrome 的 Performance 面板跑三次取中位数:
| 指标 | 改之前 | 改之后 |
|---|---|---|
| FCP | 2.4s | 0.9s |
| LCP | 3.1s | 1.4s |
| CLS | 0.21 | 0.02 |
改动量其实很小:三个 defer、一段内联 CSS、几处图片宽高。真正花时间的是定位,而不是修改。
可以照抄的清单
- 首屏之外的一切都往后放:
defer、动态import()、loading="lazy" - 关键 CSS 内联,其余 CSS 用
preload延迟应用 - 所有
<img>都写死width/height或aspect-ratio - 字体加
font-display: swap,避免文字隐形 - 用
preconnect提前建连 CDN 域名 - 上线后用真实用户数据(RUM)验证,别只信本地 Lighthouse
不折腾架构、不引入新框架,就把白屏从两秒多压到一秒以内。性能优化很多时候就是这样,找到那个串行的点,把它拆开。