一个纯静态企业官网,首屏 2.95 秒、传输 4.78MB、85 个请求。
优化后:移动端约 176KB / 6 个请求,桌面端约 240KB / 6 个请求。
本文只讲思路、数据和做法,不含任何真实域名、IP、服务器路径等环境信息,可直接套用到自己的站点。

一、先别改代码,先把"慢"量化

打开浏览器开发者工具 → Network,勾选 Disable cache,网络限速选 Fast 4G,强制刷新,然后看三样东西:

  1. 总传输量(底部状态栏的 transferred)
  2. 请求数
  3. 首屏最大的那个资源(按 Size 排序)

再用命令行交叉验证一次,避免被缓存干扰:

curl -sS -o /dev/null -w 'http=%{http_code} time=%{time_total}s size=%{size_download}\n' \
     -H 'Accept-Encoding: gzip' https://example.com/

初始账单长这样(真实数据,已脱敏):

指标优化前
首屏时间2.95 s
首屏传输量4.78 MB
请求数85
其中字体相关1 个 CSS(508KB) + 80 个字体文件(4.38MB)
首屏大图1 张 JPG,459 KB

一眼就能看出:问题不在服务器,也不在代码逻辑,而在"字体 + 大图"这两样静态资源。


二、第一个元凶:中文字体

为什么中文字体一动就是几 MB

拉丁字母只有 26 个基础字符,整套字体几百 KB 就够了。中文常用字 3000~7000 个,每个字的字形轮廓都复杂得多,一套完整的中文字体动辄 5~10 MB。

很多站点(包括本文这个)最初是这么做的:

<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">

看起来只加了一行,实际发生的是:

  • 这份 CSS 有 508 KB,声明了 80 个 @font-face 分片;
  • 浏览器先下载 CSS,解析后再按"页面用到了哪些字"去拉对应的分片;
  • 结果是首屏分几次拉走 4.38 MB 的字体文件,还会带来文字闪烁(FOUT)。

正确做法:只打包"这个页面用到的字"

Google Fonts 的 CSS API 支持 text= 参数,你告诉它页面实际用到的字符,它只返回包含这些字的子集:

# 把页面里出现的所有字符提取出来,做 URL 编码后请求
CHARS=$(python3 -c "
import re,sys,urllib.parse
html=open('index.html',encoding='utf-8').read()
text=re.sub(r'<[^>]+>','',html)
print(urllib.parse.quote(''.join(sorted(set(text)))))
")
curl -A 'Mozilla/5.0 (...)' \
  "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@100..900&text=$CHARS&display=swap"

返回的 CSS 里只有一个 @font-face,src 指向一个 fonts.gstatic.com/l/font?kit=... 的地址。把那个 woff2 下下来,就是我们要的子集。

这次的结果:4.38 MB → 132 KB,请求数 80 → 1。

几个必须注意的点:

  • 变量字体别只留一个字重。 用 wght@100..900 拿到的变量字体子集,一套字形能覆盖全部字重,比分别打包 400/500/700 三套更省。
  • 子集必须包含标题、按钮、导航里的字,否则会掉字。提取时要覆盖整个 HTML 的可见文本,而不是只取正文。
  • 验证文件是真字体:读前 4 个字节应为 wOF2,体积应大于 4KB(太小的多半是错误页被存成了 .woff2)。

兜底策略

子集只覆盖"当前页面的字"。将来新增内容若包含子集外的生僻字,浏览器会自动回退到系统字体——页面不会崩,只会个别字观感略有差异。这个风险可以用自动化消除(见第六节)。


三、首屏大图:459 KB 的 JPG 怎么变成 30 KB

原首屏用的是一张 459 KB 的 JPG,并且通过 CSS background-image 铺满整个区域。这里有两个问题:

  1. 格式旧、尺寸单一:手机也在下 1920px 宽的图;
  2. background-image 无法响应式选图,也没有办法给浏览器"这是首屏最重要资源"的提示。

改法:

<link rel="preload" as="image"
      href="/static/hero.webp"
      imagesrcset="/static/hero-828.webp 828w, /static/hero-1920.webp 1920w"
      imagesizes="100vw" fetchpriority="high">

<img class="hero-bg"
     src="/static/hero-1920.webp"
     srcset="/static/hero-828.webp 828w, /static/hero-1920.webp 1920w"
     sizes="100vw"
     fetchpriority="high" decoding="async" alt="">

配套 CSS(图片铺满、文字压在上面):

.hero { position: relative; min-height: 520px; overflow: hidden; }
.hero-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hero-overlay { position: absolute; inset: 0; background: linear-gradient(...); }
.hero-content { position: relative; z-index: 2; color: #fff; }

要点:

  • 双尺寸 + srcset:手机拿 828w(30 KB),桌面拿 1920w(94 KB),浏览器的 DPR 和视口自己判断。
  • fetchpriority="high" + preload:让首屏图优先于其它资源下载,这是 LCP 的关键;
  • object-fit: cover:宽高比变化时不会拉伸变形;
  • decoding="async":图片解码不阻塞主线程。
  • 转换工具:cwebp -q 80 in.jpg -o out.webp,或者用带 WebP 支持的图像库批量转;WebP 通常比同画质 JPG 小 30%~50%。

四、把请求数从 85 降到 6

首屏只需要六样东西:HTML、首屏图、一份自托管字体、一份客服组件 CSS、一个客服 JS、以及(可选)站点图标。

做法很朴素但有效:

  1. 把关键 CSS 内联进 <head>。这个站点的全部样式只有 13 KB(压缩后约 10 KB),内联后消除一次阻塞往返。
  2. 删掉一切首屏用不到的外部 CSS/JS,包括前文那套字体 CSS。
  3. 字体、首屏图用 preload,其它资源不要 preload——preload 太多会互相抢带宽。
判断标准很简单:把 Network 面板按 "Initiator" 看一下,凡是首屏渲染不依赖的请求,都可以延后(defer、loading="lazy"、或者干脆删)。

五、缓存与压缩:让第二次访问接近 0 请求

静态资源的缓存策略:

# 图片:30 天
location ~* \.(webp|png|jpe?g|gif|svg|ico)$ {
    expires 30d;
    add_header Cache-Control "public";
}
# 字体:一年(字体文件内容不变就不会换 URL)
location ~* \.(woff2?|ttf|otf)$ {
    expires 365d;
    add_header Cache-Control "public";
}
# JS / CSS:30 天
location ~* \.(js|css)$ {
    expires 30d;
    add_header Cache-Control "public";
}

三个容易踩的坑:

  • 别用 add_header 去写 HSTS、Alt-Svc 这类已在别处声明的头。add_header 在 location 里一旦出现,会覆盖同级的其它 add_header,安全头会静默消失。用 expires 指令生成 Expires/Cache-Control,就不会干扰已有头。
  • HTML 不要长缓存(或只给 no-cache),否则用户永远看不到更新。
  • 开启了 gzip 的话,记得确认 text/html、css、js、svg 都在 gzip 类型里;有条件再上 brotli,还能再小 15%~20%。

版本号:解决"改了没生效"

资源长缓存后,内容一变就必须换 URL。最省事的做法是给 URL 加内容哈希:

/static/fonts/subset.woff2?v=38ad366e

?v= 后面用文件 sha1 的前 8 位。注意:浏览器缓存 key 包含 query string,所以 ?v= 变了就会重新下载;而服务器端忽略 query,仍然返回同一个文件。


六、让"字体子集"自动跟着内容走

这是本次优化里最值得复用的部分。

站点首页有一个"最新文章"区块,标题由定时任务每天从博客 RSS 同步。标题一变,页面用字就变,手工重建字体子集必然会被忘记。所以把它做成自动的:

读取页面 → 提取可见字符 → 与上次记录的字符集比对
   ├── 一致且字体文件存在 → 什么都不做
   └── 不一致 / 字体缺失 → 重新请求子集 → 原子写入 → 更新 ?v=<sha1前8位>

实现时的几个关键细节,都是踩过坑才补上的:

  1. 字符集要做归一化排序再比较,否则同样的字因为顺序不同会天天判定为"有变化"。
  2. 保存字符集的文件不要用 strip() 读。本例子集字符串首位恰好是空格字符,strip() 会把它吃掉,导致每次比对都不相等、每次重建——幂等性直接失效。用 rstrip("\r\n")。
  3. 过滤控制字符(\x00-\x1f),别把它们塞进 URL。
  4. 重建失败不能阻断主流程。字体是锦上添花,标题同步才是主业:字体步骤用 try/except 包住,失败只记日志。
  5. 原子写入:先写临时文件再 os.replace(),避免定时任务和用户请求撞车读到半个文件。
  6. 版本号用内容哈希:只有内容真的变了,?v= 才变;否则每次重建都让所有访客重新下载字体。

核心逻辑(Python,示意):

def extract_chars(html: str) -> str:
    text = re.sub(r"<[^>]+>", "", html)
    text = re.sub(r"[\x00-\x1f]", "", text)
    return "".join(sorted(set(text)))

old = read_state()                      # 上次的字符集
new = extract_chars(page_html)
if old != new or not font_exists():
    data = build_font(new)              # 请求 Google text= 子集
    if not (len(data) > 4096 and data[:4] == b"wOF2"):
        raise RuntimeError("返回的不是有效 woff2")
    atomic_write_bytes(font_path, data) # 临时文件 + os.replace
    atomic_write_text(state_path, new)
    ver = hashlib.sha1(data).hexdigest()[:8]
    html = FONT_URL_RE.sub(lambda m: f"{m.group(1)}?v={ver}", html)

跑通之后的表现:

首次:字体字符集变化(523 字),重建子集 → 135088 字节,版本 38ad366e
第二次:内容无变化(6 条)
第三次:内容无变化(6 条)

幂等是这套机制能否长期无人值守的分水岭。


七、顺手把留白收紧:别只改一个数

性能之外,视觉密度也很影响"体感速度"——同样一屏,留白越大,用户越觉得页面"空、慢、信息少"。

这次把整站的垂直节奏统一收紧了一档(桌面端):

位置优化前优化后
区块上下内边距较大44px
区块标题 → 内容较松26px
卡片内边距较松22px 18px
网格间距20px16px
流程步骤间距较大26px
页脚上内边距较松34px

三条经验:

  1. 先定"节奏",再逐块调。统一了"区块 44 / 标题到内容 26 / 卡片 22"这几个基准,整页才会一致,而不是东一块紧西一块松。
  2. 压缩间距的同时要压缩卡片内边距和图标下间距,只改外边距会显得"内容挤在卡片里"。
  3. 响应式要分档。手机端别照搬桌面值:区块 32px、卡片 18px 14px、网格 12px,观感才不局促。

两个真实的回归教训

优化过程中出现过两次"改好了又坏":

  • 关于我们图片被拉伸:给 <img> 加了 width:100% 却没给 height:auto,图片按容器高度被强行拉伸。修复只需一行:.about-img img { width: 100%; height: auto; display: block; }。
  • 字体观感变差:子集化之后字重/字形覆盖不全,页面换成系统字体渲染。修复方式是改用变量字体子集(wght 100–900 全轴),而不是只打包单一字重。

结论:动字体、动图片这类"看起来只是资源替换"的改动,务必在真机上强刷看一遍,缓存会让你以为一切正常。


八、最终账单

指标优化前优化后(移动端)优化后(桌面端)
首屏时间2.95 s明显下降(见下方说明)—
首屏传输量4.78 MB≈ 176 KB≈ 240 KB
请求数8566
HTML未压缩39 KB 源码 / 10 KB gzip同左
字体508 KB CSS + 4.38 MB 分片132 KB(1 个文件)同左
首屏大图459 KB JPG30 KB WebP94 KB WebP
关于我们配图427 KB JPG37 KB WebP同左

顺带回收了约 6.4 MB 磁盘(旧字体分片、旧字体 CSS、旧 JPG/WebP 大图)。删之前逐项确认了"已无任何页面引用",并先用 curl 访问了删除后的 URL 应返回 404。

说明:首屏时间这一项没有给出精确小数,因为真实 LCP 需要在真机浏览器里测(DevTools Lighthouse 或真实用户监控)。传输量和请求数是 HTTP 层实测值,可以直接复现。

九、可照抄的检查清单

字体

  • 是否引入了整站字体 CSS?→ 改为按用字生成子集
  • 是否用了变量字体(覆盖全部字重)?
  • 子集是否只含页面实际用字?
  • 是否给字体加了长缓存 + ?v= 内容哈希?
  • 内容会更新?

→ 把"重建子集"接进内容同步流程,并保证幂等

图片

  • 首屏大图是否 ≤ 100 KB?
  • 是否用了 WebP/AVIF?
  • 是否有 srcset + sizes 双尺寸?
  • 是否 fetchpriority="high" + preload?
  • <img> 是否同时有 width/height 或 aspect-ratio(防抖动)?
  • 非首屏图片是否 loading="lazy"?

请求与渲染

  • 关键 CSS 是否内联?
  • 首屏有没有阻塞渲染的外部 CSS/JS?
  • 请求数是否 ≤ 10?
  • preload 是否只给了首屏真正需要的资源?

缓存与压缩

  • HTML 是否短缓存/不缓存?
  • 静态资源是否 30 天以上缓存 + 文件名或 ?v= 指纹?
  • gzip 是否开启并覆盖 html/css/js/svg?
  • 有没有因为 add_header 把 HSTS 之类的安全头覆盖掉?

视觉

  • 垂直节奏是否统一?
  • 手机端是否单独调过间距?
  • 真机强刷确认过图片不变形、字体不掉字?

十、还可以继续做的(以及别急着做的)

可以继续做

  • 上 CDN:静态资源交给边缘节点,TTFB 和跨地域体验提升最明显。
  • 开 brotli:比 gzip 再小 15%~20%,尤其是 HTML/CSS/JS。
  • HTTP/3:服务端能开 QUIC 的话,弱网下握手和重传表现更好。但要注意——如果你在响应头里声明了 Alt-Svc: h3=":443",就必须真的在防火墙放行 UDP 443,否则浏览器会先尝试 HTTP/3、失败后再回退 TCP,反而多一次往返。
  • 关掉 TLS 1.1:只保留 TLS 1.2 / 1.3,安全性更好、握手也更简单。

别急着做

  • 别为了"看起来很快"去把首屏做成骨架屏然后异步塞内容——对 SEO 和真实体感都没好处。
  • 别给 HTML 加长缓存"提升命中率",代价是用户永远看不到更新。
  • 别一次性 preload 五六个资源,它们会互相抢有限的连接带宽。

结语

这次优化没有一个"黑科技",全是四件事的排列组合:

  1. 把不必要的东西删掉(整站字体 CSS、旧格式大图);
  2. 把必要的东西变小(按用字子集、WebP、双尺寸);
  3. 把剩下的东西缓存好(长缓存 + 内容哈希);
  4. 把"会腐化"的环节自动化(字体子集跟随内容重建,且幂等)。

真正的收益不在于"少了几 MB",而在于——以后每次更新内容,都不会悄悄把性能打回原形。

标签: 官网, 首页, 优化, 加速

添加新评论