首屏渲染优化实战:Critical CSS 内联、字体 font-display 与资源预加载,把 LCP 从 4 秒压到 1.5 秒

为什么你的站"能开"但"开得慢"

很多个人站长的优化停在一个层面:压缩图片、开 Gzip、上 CDN。这些做完,页面总加载时间确实下降了,但用户体感依然慢——因为他们感知的"快慢"不是"整个页面下载完",而是首屏出现内容、可读、可点的那一两秒。这就是 Core Web Vitals 里 LCP(Largest Contentful Paint,最大内容绘制)要衡量的东西。

本文聚焦首屏渲染路径(Critical Rendering Path),用三个最有效、改动最小的手段把 LCP 压下来:Critical CSS 内联、字体加载控制、资源预加载。不依赖任何构建工具,纯 Nginx + HTML 就能落地,适合个人站。

先量化:别凭感觉优化,先测出 LCP

优化前必须知道当前值。打开 Chrome DevTools 的 Performance 面板录制一次加载,或者直接用命令行量一把关键指标:

# 用 curl 测量关键阶段耗时(能看到 TTFB 与总时间)
curl -o /dev/null -s -w "dns:%{time_namelookup}s connect:%{time_connect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n" https://www.example.com/

更贴近真实用户体感的是在 Console 里读 PerformanceObserver:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    console.log('LCP:', Math.round(e.startTime), 'ms', e.element);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

也可以直接查 Lighthouse 报告里的 LCP。目标值:LCP ≤ 2.5 秒为"好",2.5~4 秒为"需改进",超过 4 秒为"差"。下面的实操假设你当前 LCP 约 4 秒,要压到 1.5 秒左右——这个跨度听起来大,但对一个典型个人博客是完全可达的。

瓶颈一:渲染阻塞的 CSS 与 JS

浏览器解析 HTML 时,遇到 <link rel="stylesheet"> 会暂停渲染,等 CSS 下载并解析完;遇到没有 async/defer 的 <script> 会暂停解析,等 JS 下载执行完。这就是"渲染阻塞"。一个 300KB 的 CSS 从 CDN 拉下来可能就要 600ms,期间页面是白屏。

关键思路是把"首屏渲染所必需的 CSS"内联进 <head>,把非首屏的 CSS 异步加载。个人站的 CSS 通常不大,往往首屏真正用到的只是其中一小部分,完全可以手工提取。

<head>
  <!-- 1. 内联 Critical CSS:只放首屏可见部分的样式 -->
  <style>
    body{margin:0;font-family:system-ui,-apple-system,sans-serif;line-height:1.7}
    header{height:64px;background:#1a1a2e;color:#fff}
    .hero h1{font-size:2rem;margin:0 0 12px}
    .hero p{color:#555}
    article{max-width:760px;margin:0 auto;padding:0 16px}
  </style>

  <!-- 2. 完整样式表异步加载:preload + onload 切换 rel -->
  <link rel="preload" href="/css/main.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/css/main.css"></noscript>
</head>

这里的 onload 技巧是关键:先把 rel 设为 preload 让浏览器以非阻塞方式下载,下载完成后在 onload 里把 rel 改回 stylesheet,这样样式在不阻塞首屏的前提下被应用。<noscript> 兜底保证禁用 JS 时样式仍在。这个模式不需要任何构建工具,是个人站最实用的 CSS 优化手段。

对于 JS,原则更简单:所有非关键的 script 都加 defer。defer 让脚本在 HTML 解析完、DOMContentLoaded 之前按顺序执行,不阻塞渲染。async 则是下载完立刻执行、不保证顺序,适合完全独立的统计脚本。

<script src="/js/app.js" defer></script>          <!-- 主逻辑,保顺序 -->
<script async src="/js/analytics.js"></script>     <!-- 统计,独立 -->

瓶颈二:字体加载导致的文字闪烁与延迟

Web 字体是 LCP 的隐形杀手。如果 LCP 元素是一段用自定义字体渲染的大标题,浏览器会经历 FOIT(Flash of Invisible Text)——字体没下载完之前,文字是隐形的,LCP 迟迟不能达标。默认行为下,浏览器最多会"隐形"文字 3 秒。

解决办法是在 @font-face 里用 font-display 控制策略:

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;   /* 关键:先用系统字体显示,字体到了再换 */
  unicode-range: U+4E00-9FFF;  /* 只加载需要的字符范围 */
}

font-display 的四个值对应不同权衡:

auto     浏览器默认,通常等同 block
block    短暂隐形(<3s),再换字体 —— 仍可能拖慢 LCP
swap     立刻用后备字体,字体到了替换 —— 首屏最快,推荐
fallback 极短隐形,再换 —— 折中
optional 浏览器自主决定是否用 —— 最省流量

对个人站,首选 swap。swap 的代价是可能看到一次"字体跳变"(FOUT),但换来的是文字立刻可读、LCP 立刻达标,权衡下来非常划算。再配合两件事进一步提速:

其一,字体子集化。 中文字体动辄几 MB,但你的页面未必用到全部汉字。用 fonttools 按站点实际用字生成子集,能把它从 3MB 压到几十 KB:

pip install fonttools brotli
pyftsubset myfont.ttf --text-file=used-chars.txt \
  --flavor=woff2 --output-file=myfont-subset.woff2

其二,提前建立连接并预加载字体文件。 放在 <head> 最前面:

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" href="/fonts/myfont-subset.woff2" as="font"
      type="font/woff2" crossorigin>

注意 preload 字体时必须带 crossorigin,否则会因为跨域属性不匹配导致"预加载了但没被使用",浏览器会在 Console 里警告。

瓶颈三:找出并优先加载 LCP 元素

回到 LCP 本身:LCP 元素通常是首屏最大的一块内容——一张 banner 图、一段大标题、一个首屏卡片。针对性地预加载 LCP 元素本身,收益最直接。 先在 DevTools 里确认 LCP 元素是哪个,然后:

<!-- 若 LCP 是一张首屏大图 -->
<link rel="preload" as="image" href="/img/hero.webp"
      imagesrcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w"
      imagesizes="100vw">

<!-- 图片本身:首屏图绝不要用 loading="lazy",那会推迟 LCP -->
<img src="/img/hero.webp" width="1600" height="900"
     fetchpriority="high" alt="首屏图">

<!-- 首屏以下的图片才用懒加载(兼容写法) -->
<img src="/img/below.jpg" loading="lazy" width="800" height="600" alt="文章配图">

这里有三个高频误区:第一,给首屏图加 loading="lazy",会让浏览器推迟到布局后才开始加载,直接杀死 LCP;第二,不写 width/height,导致布局抖动(CLS),也间接影响体感;第三,把 LCP 图交给 JS 动态插入,浏览器无法提前发现,白白浪费几百毫秒。给 LCP 元素加 fetchpriority="high",是告诉浏览器"这个最重要,先加载它"。

Nginx 侧的三项配套

前端改完,服务器侧还要配合三件事,否则前面的努力会被网络层吃掉。

一、开启 HTTP/2 与合理的缓存头。 HTTP/2 的多路复用能让内联后的多个小资源和预加载并行下载,而不是在 HTTP/1.1 的 6 连接队列里排队:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # 字体与静态资源长缓存(内容哈希文件名才敢用 long)
    location ~* \.(woff2|webp|avif|css|js)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    # HTML 不缓存或短缓存,保证内容及时更新
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }
}

二、开启 Brotli 压缩(比 gzip 再省约 20%)。 现代浏览器都支持 br,尤其是 CSS/JS/HTML:

brotli on;
brotli_comp_level 5;
brotli_types text/html text/css application/javascript application/json image/svg+xml;

三、TTFB 本身不能慢。 如果后端(PHP/Node)首次响应要 800ms,前面所有前端优化都是"在慢地基上装修"。开启 OPcache、给数据库加索引、必要时给动态页加页级缓存,把 TTFB 压到 200ms 以内,LCP 才有下探空间。用前面那条 curl -w 命令量 TTFB,超过 500ms 就先处理后端。

上线后的验证与常见反弹

改完别急着收工,按这份清单逐项确认:

[ ] DevTools Performance 复测,LCP 是否下降(目标 <2.5s)
[ ] Console 无 "preload was not used" 警告(字体 crossorigin 对吗)
[ ] 首屏样式是否完整(内联 Critical CSS 有没有漏掉关键规则)
[ ] API 页/内页也要测,别只优化首页
[ ] LCP 图无 loading="lazy",有 fetchpriority="high"
[ ] font-display: swap 已生效(Network 里字体不是阻塞项)
[ ] HTTP/2 是否真开启(curl -I 看响应协议)
[ ] HTML 无长缓存,避免改版后用户看到旧页面
[ ] 移动端(4G 模拟)复测,桌面快不代表移动快

常见的"优化反弹"有两种:一是内联的 Critical CSS 漏掉了某些规则,导致首屏出现一瞬间的"无样式闪烁",看起来比优化前还糟——解决办法是把内联部分补全,宁可多几十行也别漏关键布局;二是给 main.css 加了 immutable 长缓存却忘了文件名加内容哈希,改版后老用户拿到旧的 CSS 配上新的 HTML,样式全乱——记住长缓存的前提是文件名随内容变化。

首屏优化的本质,是围绕"用户第一眼看到什么"来重新排布加载优先级。把渲染阻塞砍掉、把字体做成不阻塞、把最大那块内容提前拉过来,这三步做完,一个原本 4 秒的 LCP 压到 1.5~2 秒是常规结果,而且全程不需要复杂构建链。对个人站长来说,性价比远高于无止境地压图片体积。

常见问题速查

问:Critical CSS 到底要内联多少?
答:以"首屏在不滚动的情况下可见的所有元素"为界。个人站通常只需要几十到一两百行:重置样式、页头、正文容器、标题层级、首屏图容器。宁可多内联一点,也别漏掉关键布局规则,漏了会出现一瞬间的无样式闪烁,反而更糟。

问:内联样式会不会增加 HTML 体积?
答:会,但换来的是"首屏不再等一个外部 CSS"。内联部分通常几 KB,而一个完整的 main.css 可能几十到几百 KB。权衡下来,几 KB 阻塞换来几百 KB 不阻塞,非常划算。要注意内联内容也会随 HTML 一起传输,所以别把整站 CSS 都塞进去。

问:为什么 preload 的字体会报 "not used" 警告?
答:字体预加载必须带 crossorigin 属性,即便字体在同域。因为字体请求天然是跨域(CORS)模式,属性不匹配时浏览器会预加载一份、实际又拉一份,于是警告"预加载了但没被使用"。加上 crossorigin 即可。

问:用了 CDN,这些优化还有意义吗?
答:有,而且更明显。CDN 降低的是传输时间,但渲染阻塞、字体隐形、LCP 元素发现时间这些是"浏览器侧"的问题,CDN 帮不上。两边都要做:CDN 解决"送得快",首屏优化解决"画得快"。

问:移动端和桌面端结果差很多怎么办?
答:移动端 CPU 慢、4G 网络抖动大,LCP 通常比桌面差一大截。务必用 DevTools 的 4G + CPU 4x 节流复测,并优先给移动端做响应式图片(srcset + sizes),别把 1600px 的大图推给小屏手机。

检查清单:上线前过一遍

[ ] Critical CSS 已内联,首屏无样式闪烁
[ ] 完整 CSS 用 preload + onload 异步加载,含 noscript 兜底
[ ] 非关键 JS 全部加 defer,统计脚本用 async
[ ] @font-face 设置了 font-display: swap
[ ] 字体做了子集化,体积从 MB 降到几十 KB
[ ] preconnect 字体域名,preload 字体带 crossorigin
[ ] LCP 元素无 loading="lazy",并设 fetchpriority="high"
[ ] 图片都写了 width/height 防止 CLS
[ ] Nginx 开了 HTTP/2,静态资源长缓存 + immutable
[ ] 开了 Brotli,HTML/CSS/JS 均生效
[ ] TTFB 已压到 200ms 以内
[ ] 用 4G + CPU 节流复测移动端 LCP
Last modification:October 12th, 2026 at 01:25 pm

Leave a Comment