首屏加载优化:从路由拆包到预加载的完整清单
本文目录
首屏是用户对站点质量的第一判断,也是最多"改了一处、慢了两处"的重灾区:加个依赖、埋点 SDK 升级、一张首图换了未压缩版本,都能让首屏悄悄劣化一个等级。这篇按实际排障顺序组织一份完整清单:先看瀑布找到慢在哪一段,再按关键渲染路径决定动哪一层——拆包、关键 CSS、字体、预加载、渲染模式,最后是防止回退的检查清单。
先定义"首屏"再谈优化
"首屏加载慢"是一句需要拆解的话。工程上用两个指标锚定:
- FCP(首次内容绘制):用户第一次看到任何内容。主要被 HTML 体积、CSS 阻塞、字体阻塞影响。
- LCP(最大内容绘制):主要内容真正可见。在 FCP 基础上叠加了最大资源(通常是首图)和 JS 执行的耗时。
优化动作要对应到指标:压缩首屏 JS 主要救的是"能交互"的时间,但如果 LCP 元素是一张 2MB 的图,拆包对 LCP 几乎无效。动手前先确认指标分布(参考站内 Core Web Vitals 一文的归因方法),再按下文分层处理。
用 DevTools 看瀑布:找到慢的那一段
Network 面板的瀑布图是首屏排障的起点。每个请求的横条由几段颜色组成:排队 → DNS → TCP/TLS → 等待(TTFB)→ 内容下载。看瀑布时盯两类信号:
- 单条横条过长:某个资源下载(或等待)耗时不合理,通常是巨型 JS 包、无缓存的大图、慢接口。
- 横条之间出现阶梯:请求 A 完成后才发起请求 B——这就是"瀑布式加载",串行链条把每段耗时累加。
典型的瀑布链条长这样:HTML → CSS → 字体文件(CSS 解析完才发现)→ JS → JS 里发的接口 → 接口数据触发的首图。六个环节每个 200ms,LCP 就是 1.2s 起步,而且没有任何单点"特别慢"——这种问题的优化方向是"砍环节"而不是"压单点"。
Performance 面板补充另一半真相:网络瀑布只显示下载,解析执行在 Main 轨道。首屏 LCP 后紧跟一段黄色长块(脚本执行)时,说明慢在主线程,拆包和减少首屏 JS 才是正解。
上图是拆包 + 首屏精简前后的一次典型对照(数据为示意):两类耗时都降,因为体积砍掉的同时解析执行量同步减少。
关键渲染路径:谁在阻塞谁
浏览器把 HTML 变成像素有一条固定流水线:HTML 解析 → 发现 CSS/JS → 构建 DOM/CSSOM → 布局绘制。阻塞关系决定优化手法:
| 资源类型 | 对渲染的影响 | 优化方向 |
|---|---|---|
CSS(<link rel="stylesheet">) | 阻塞首次渲染:没有 CSSOM 不画像素 | 控制体积、内联关键部分 |
同步 <script> | 阻塞 HTML 解析:脚本可能改 DOM | 消灭、延迟、加 defer |
defer 脚本 | 并行下载,解析完后按序执行 | 首屏可交互脚本的默认形态 |
async 脚本 | 并行下载,下完立即执行(无序) | 独立脚本(埋点、探测类) |
模块脚本 <script type="module"> | 默认 defer 行为 | 首选 |
| 字体 | 不阻塞渲染但阻塞文本(视 font-display) | preload + 指标对齐 |
| 图片 | 不阻塞,但决定 LCP | 优先级提示 |
一张经验表:生产环境首屏 HTML 里出现没有 defer/async 的同步第三方脚本,几乎总是一个待修问题。
代码分割:路由级与组件级
"单 bundle 打天下"是首屏最常见拖累:用户访问 /about,却下载了整个管理后台的图表代码。分两个层次拆。
路由级拆分
每个路由一个 chunk,进入时才下载:
// React Router 场景:组件改为动态 import,构建工具自动分 chunk
import { lazy, Suspense } from 'react';
const AdminDashboard = lazy(() => import('./routes/AdminDashboard'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<AdminDashboard />
</Suspense>
);
}
两个必须处理的配套问题:
- 加载态:
Suspense/异步组件必须有骨架兜底,否则网络慢时是白屏 + CLS。 - 预取下一条路由:拆分后导航要现下载 chunk,交互延迟可感知——用后文的 prefetch 把"导航时下载"提前为"悬停/空闲时下载"。
组件级拆分
路由内的大件单独拆:富文本编辑器、图表库、地图、代码高亮,都是典型候选——体积大且非首屏必需。判断标准不是"组件行数"而是"依赖体积"。
// 打开文章时才加载代码高亮器
async function highlightOnVisible(el: HTMLElement): Promise<void> {
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
const { highlight } = await import('./highlighter'); // 高亮库(含语言定义)按需加载
highlight(el);
});
observer.observe(el);
}
手动控制分包边界
依赖被多个入口共享时会提取为公共 chunk,公共 chunk 被切得太碎反而增加请求数。构建工具里手动归组:
// Vite 场景:把稳定的第三方框架归到一组,避免业务改动引发整组重下
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['echarts'],
},
},
},
}
归组原则:变更频率一致的代码放一组。框架代码(几周不变)与业务代码(天天变)分开,用户升级版本时框架 chunk 直接命中缓存,只下载业务增量。
关键 CSS 内联
CSS 阻塞渲染,但用户等不了"整个站点的样式"都下载完才看到第一屏。折中方案:首屏必需的样式内联进 HTML,其余照常走 <link>:
<style>
/* 内联:首屏布局与首屏元素的样式,体积严格控制 */
body { margin: 0; font-family: var(--font-body); }
.hero { aspect-ratio: 16/9; background: #f3f3f3; }
</style>
<link rel="preload" as="style" href="/site.a3f9c2.css">
<link rel="stylesheet" href="/site.a3f9c2.css" media="print" onload="this.media='all'">
边界必须写清楚:
- 内联 CSS 随每次 HTML 请求重复传输,且吃不到缓存。内联 50KB 就等于每次访问多传 50KB——只内联"折叠线以上"真正需要的部分,实践上通常几 KB 到十几 KB。
- 用构建工具自动提取(critical 类工具)时要在 CI 里卡内联体积上限,否则会随样式迭代悄悄膨胀。
media="print"+ onload 切换是异步加载非关键 CSS 的经典技巧,preload防止低优先级样式拖慢后续。
字体优化
字体是"用户看不见但一直在等"的资源。四个动作按收益排:
<!-- 1. 提前下载首屏要用的字体(必须带 crossorigin,否则 preload 与实际请求是两条 CORS 配置不同的请求,字体下载两次) -->
<link rel="preload" as="font" type="font/woff2" href="/body.a3f9c2.woff2" crossorigin>
/* 2. 先显示回退字体,不阻塞文本渲染;3. 裁剪:每个字重单独文件、去掉用不到的字符集 */
@font-face {
font-family: 'Body';
src: url('/body.a3f9c2.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* 4. 回退字体度量对齐,减小 swap 瞬间的布局偏移 */
@font-face {
font-family: 'BodyFallback';
src: local('PingFang SC'), local('Microsoft YaHei');
size-adjust: 100.5%;
ascent-override: 0.96;
}
取舍提醒:font-display: swap 换来 FCP 提前,代价是切换瞬间可能产生 CLS;optional 完全无偏移但网络字体大概率不显示。内容型站点一般选 swap + 度量对齐,追求零偏移的着陆页可以选 optional。这部分与 CWV 一文的 CLS 小节互为补充。
预加载策略与优先级提示
预加载是一组语义不同的工具,用错场景会适得其反:
| 工具 | 语义 | 典型场景 | 滥用后果 |
|---|---|---|---|
rel="preload" | 本页马上要用,提高优先级 | LCP 图片、首屏字体、关键 CSS | 预载了没用的资源,抢真实关键资源带宽 |
rel="prefetch" | 下一个导航可能要用,空闲时下载 | 下一条路由的 chunk | 与本页资源抢空闲带宽 |
fetchpriority="high" | 单资源优先级提示 | LCP <img> | 到处都是 high 等于没有 high |
| Speculation Rules | 预渲染/预取下一页(较新 Chrome) | 预判用户下一步访问的链接页 | 预渲染页执行了埋点/计数逻辑,数据失真 |
{
"prefetch": [
{
"source": "document",
"where": { "href_matches": "/blog/*" },
"eagerness": "moderate"
}
]
}
上面是 Speculation Rules 的基础形态:对站内博客链接做温和预取,鼠标悬停时再升级为预渲染由 eagerness 策略控制。接入前的检查项:预渲染页面里的埋点要有判断(曝光上报会重复)、带表单/播放器的页面不要预渲染。
preload 的三条硬规则:as 必须与实际用途一致(as="script" 预载的 URL 后续必须真的被执行,否则控制台警告且带宽白费);预载的 URL 与使用处逐字符一致;每页 preload 数量个位数为宜。
SSR / SSG / 预渲染:渲染模式的选择
渲染模式决定"HTML 里有没有首屏内容",是首屏的上限问题:
| 方案 | 首屏 HTML | 首屏表现 | 代价与适用 |
|---|---|---|---|
| CSR(纯 SPA) | 空壳 + JS | 最慢:等 JS + 接口 + 渲染 | 交互后续最流畅,后台类应用可接受 |
| SSG | 构建期全量生成 | 快:静态 HTML 直接命中 CDN | 内容变更要重新构建,适合内容稳定页 |
| SSR | 请求时生成 | 快但受服务端 TTFB 影响 | 服务器成本、缓存策略更复杂 |
| Islands(如 Astro) | 静态 HTML + 局部水合 | 快且 JS 少 | 组件必须显式标注交互边界 |
| 预渲染(prerender) | 构建期对指定路由快照 | 接近 SSG | 只对已知路由有效,动态数据要客户端补 |
迁移建议按风险递增排:CSR → 关键页面预渲染 → 首页/落地页 SSG/SSR。一刀切改 SSR 是常见的过度工程——多数内容站只需让"营销页和文章页"静态化,后台路由保持 CSR 即可。
首屏优化检查清单
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 同步阻塞脚本 | 首屏 HTML 内第三方脚本全部 defer/async |
| 2 | 路由级拆包 | 首屏 JS 不含未访问路由的代码 |
| 3 | 首屏 JS 体积 | 按业务定预算并在 CI 卡口(如 gzip ≤ 200KB) |
| 4 | LCP 资源优先级 | 首图有尺寸声明、无 lazy、优先级提示到位 |
| 5 | 字体 | preload + swap + 回退度量对齐,无二次下载 |
| 6 | 关键 CSS | 折叠线以上样式不阻塞渲染,内联体积有上限 |
| 7 | 瀑布检查 | Network 面板无超过三段的串行依赖链 |
| 8 | 缓存 | 带 hash 资源 immutable,二次访问命中缓存 |
| 9 | 监控回归 | LCP/INP 按版本监控,劣化可告警 |
| 10 | 移动端实测 | 低端真机走一遍首屏,实验室模拟不替代真机 |
清单的价值不在"一次过一遍",而在每次涉及首屏的发布前重跑一遍——第 3 条和第 9 条是自动化能接管的,其余靠人。
注意事项与边界
- 拆包不是越多越好:chunk 数量超过几十个后,请求调度本身成为开销,HTTP/2 也救不了过多的碎片。
- 预加载的带宽占用是零和的:给 A 提优先级等于给 B 降优先级,preload 列表要按"本页真实关键"裁剪。
- prefetch 依赖预测,命中率低时是纯浪费;基于真实导航数据(哪些页接着哪些页被访问)配置才有效。
- SSR 的首屏收益以服务端 TTFB 为上限:接口 P99 三秒的下游会把 SSR 变成"更慢的白屏",先量接口再上 SSR。
- 实验室环境的网络面板要开"Disable cache"和限速,否则本机热缓存下的数字会严重失真。
- 一切本地结论要在真实用户数据(RUM)上复核,参考 Core Web Vitals 一文的 Field/Lab 区分。
- 首屏优化和广告位、运营浮层、AB 实验脚本是长期博弈方:给第三方脚本设定"不进首屏 HTML、defer 执行、失败不重试"的准入规则,否则每次大促前都会重新劣化一轮。
总结
首屏优化是一条从测量到锁定的流水线:用瀑布图定位是"单点慢"还是"链条长";单点慢就按关键渲染路径压对应资源——阻塞脚本、CSS 体积、LCP 资源优先级;链条长就砍环节——拆包、关键 CSS、预取下一条路由、按页面类型选择渲染模式;最后把体积预算和指标监控接进 CI,防止三个月后悄悄回退。先测量后动手,永远是第一步。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。