数据能说明问题:很多独立博客的移动端 LCP 卡在 2.8 秒到 4 秒这个区间,而它们并不缺 CDN、不缺 HTTP/2,页面里也没有大图。用 PageSpeed Insights 一跑,报告会准确地把责任指向「移除阻塞渲染的资源」,但那几十 KB 的 CSS 就摆在那里,删不掉也合不了。这篇讲的是怎么真的把它挪出关键路径——Critical CSS 的提取、内联、异步加载,以及字体和高优先级图片这两块最常见的二次阻塞。
一、先搞清楚谁在阻塞渲染
浏览器解析 HTML 遇到 会暂停渲染,直到该样式表下载并解析完毕。这不是 bug,而是为了避免「先渲染无样式内容再重排」(FOUC)。所以问题不在于「有 CSS」,而在于「首屏需要的 CSS 和全站需要的 CSS 被混在一起,全都放在 head 里同步加载」。
# 用 Lighthouse 的 CLI 版拿一份结构化报告,方便对比前后
npx lighthouse https://example.com \
--only-categories=performance \
--form-factor=mobile --throttling-method=simulate \
--output=json --output-path=./lh-before.json --chrome-flags="--headless"
# 看阻塞资源清单
node -e "const r=require('./lh-before.json');
const a=r.audits['render-blocking-resources'];
console.log(a.score, a.displayValue);
(a.details.items||[]).forEach(i=>console.log(i.url, i.totalBytes, i.wastedMs));"同时要看 render-blocking-insight 之外的另一个指标:关键请求深度。一个 CSS 文件本身不大,但它引用了字体文件,字体又引用了另一份 CSS,链深度变成三层,移动端 RTT 高的时候光排队就要一秒多。
二、Critical CSS 到底是什么,不该是什么
Critical CSS 指「渲染首屏(above the fold)所必需的那部分规则」。做法是:把这段规则内联进 的 里,让首屏无需等待任何外部请求即可绘制;其余 CSS 用异步方式加载。
三个反面教材:
- 把整站 CSS 都内联。某主题的 style.css 有 180KB,内联进每个页面,等于把可缓存的外部资源变成每页都要重复传输的字节。首屏确实快了,但从第二页开始整体变慢,而且 HTML 无法被压缩得好。
- 只内联重置样式。reset/normalize 只占首屏需要的 5%,剩下的布局规则仍然阻塞,几乎没效果。
- 手工维护两套样式。Critical CSS 和主 CSS 一旦不同步,首屏会闪一下再跳到正确样式,CLS 反而变差。必须自动化。
合理的体积目标是:压缩后 10–20KB,覆盖首屏可见区域 + 少量公共组件(导航、按钮、字体基础样式)。
三、用工具提取,而不是手写
最省事的方案是 Critical(npm 包 critical),它用无头浏览器加载页面,按视口尺寸算出实际用到的规则。
npm i -D critical
# 基本用法:生成 critical CSS 并改写 HTML
npx critical index.html \
--base public/ \
--inline \
--width 1300 --height 900 \
--minify true \
--extract false \
> public/index.critical.html要点说明:
--width/--height决定「首屏」的定义。桌面 1300×900 与移动 412×823 得到的规则集完全不同,建议跑两次、取并集,或者按 UA 分别服务(成本更高,一般不必)。--extract false表示不把用到的规则从原 CSS 中删除。保留一份完整 CSS 作为异步加载的兜底,是最稳妥的策略——万一提取漏了,页面样式仍然完整,只是会晚一点应用。- 生成的文件要在每次主题/模板变更后重新跑,建议挂进构建脚本,而不是一次性手工执行。
如果是纯静态站(Hexo/Hugo/Next 静态导出),也可以在 CI 里对所有页面批量处理。注意只在模板层做一次,不要每次请求动态计算——那是把服务端 CPU 换成首屏时间,得不偿失。
四、异步加载主 CSS 的三种写法
把非关键 CSS 改成不阻塞,主流有三种办法,各有取舍。
方案 A:preload + onload 切换
优点:兼容性好,语义清晰,浏览器会以较高优先级下载。缺点:仍然是一次额外请求;若 preload 的资源和最终使用的不完全一致(比如带查询串),会下载两次。务必保证 href 完全一致,并给 CSS 加长缓存 Cache-Control: public, max-age=31536000, immutable + 文件名带 hash。
方案 B:media 技巧
把 media 设成不匹配的值,浏览器就不会用它阻塞渲染,但会低优先级下载。简单、有效,适合没有构建流程的老站点。缺点是优先级低,弱网下样式到位更晚,可能出现短暂无样式内容。
方案 C:内联 + 后续注入(最彻底)
不占关键路径,也不需要 preload。代价是主 CSS 的下载起步更晚。实践中最平衡的做法是 A + C 混用:preload 主 CSS(让它尽早开始下载),同时用 script 在 DOM 就绪后把它变成 stylesheet。这样既早下载又不阻塞。
五、字体:第二个隐形阻塞点
即便 CSS 处理完了,自定义字体仍可能让首屏文字延迟显示(FOIT),或者从无衬线跳到自定义字体(FOUT 引起的 CLS)。三条规则:
@font-face {
font-family: 'SiteSans';
src: url('/fonts/sitesans.woff2') format('woff2');
font-display: swap; /* 先用系统字体显示,字体到了再换 */
unicode-range: U+0000-00FF; /* 只要拉丁字符,避免整包下载 */
size-adjust: 104%; /* 微调系统字体的度量,减少跳动 */
}- 只加载 woff2,老格式在现代浏览器已无必要。
- 中文字体动辄 3–8MB,必须做子集化(按页面实际用到的字符)或者干脆用系统字体栈。
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;在很多场景下比下载一个中文字体更快也更好看。 - 首屏用到的字体文件加
,注意crossorigin不能漏,字体请求是 CORS 请求,漏了会导致下载两次。
六、首屏图片与 LCP
LCP 元素如果是图片,它往往才是真正的瓶颈,CSS 优化只解决了一部分。做法:

常见的两个错误:给首屏大图加了 loading="lazy"(浏览器会推迟到布局后才请求,直接拖后 LCP);以及不给 width/height,导致图片加载完成后页面重排,CLS 飙升。
七、优化前后怎么验证
别只看一次 Lighthouse 分数,它有波动。用真实用户数据兜底:
| 指标 | 目标 | 数据来源 |
|---|---|---|
| LCP | < 2.5s (75分位) | CrUX / GSC 核心网页指标 |
| CLS | < 0.1 | 同上 |
| INP | < 200ms | 同上 |
| 阻塞资源浪费时间 | < 300ms | Lighthouse |
实操上建议按这个顺序:先在弱网模拟(4G、CPU 4x 降速)下跑三次 Lighthouse 取中位数作为基线;改完之后用完全相同的条件复测;最后等 7–28 天看 GSC 的核心网页指标分组变化。只有真实用户数据改善,才算真的优化成功——实验室数据只是必要非充分条件。
八、容易踩的三个坑
- 内联 CSS 与 CSP 冲突。如果你有
Content-Security-Policy且没有'unsafe-inline',内联样式块会被拦。解法是用 nonce 或 hash,把内联的内容做 sha256 后加进 CSP 的style-src。 - 内联 CSS 与 HTML 压缩/缓存。首屏 CSS 变了,但没有版本号,CDN 缓存了旧 HTML,用户拿到的内联样式与新的主 CSS 不匹配。别把 HTML 缓存太久,或者给 HTML 加 ETag 并确保变更即失效。
- 只优化首页。文章详情页才是搜索流量的入口,且往往有更大的首屏图片和更复杂的侧栏。抽样 10 个真实落地页做同样的处理,否则优化只体现在首页的分数上。
Critical CSS 不是玄学,它就是把「首屏需要的」和「剩下的」分开,前者内联、后者异步。真正的门槛不在技术,而在于把它变成构建流程的一部分并持续验证。做完这一层,再谈图片格式、CDN、预连接,收益才会叠加起来。