把中文官网首屏从 4.8MB 压到 200KB:一次可以照抄的实战记录
一个纯静态企业官网,首屏 2.95 秒、传输 4.78MB、85 个请求。
优化后:移动端约 176KB / 6 个请求,桌面端约 240KB / 6 个请求。
本文只讲思路、数据和做法,不含任何真实域名、IP、服务器路径等环境信息,可直接套用到自己的站点。
一、先别改代码,先把"慢"量化
打开浏览器开发者工具 → Network,勾选 Disable cache,网络限速选 Fast 4G,强制刷新,然后看三样东西:
- 总传输量(底部状态栏的 transferred)
- 请求数
- 首屏最大的那个资源(按 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 铺满整个区域。这里有两个问题:
- 格式旧、尺寸单一:手机也在下 1920px 宽的图;
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、以及(可选)站点图标。
做法很朴素但有效:
- 把关键 CSS 内联进
<head>。这个站点的全部样式只有 13 KB(压缩后约 10 KB),内联后消除一次阻塞往返。 - 删掉一切首屏用不到的外部 CSS/JS,包括前文那套字体 CSS。
- 字体、首屏图用
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位>实现时的几个关键细节,都是踩过坑才补上的:
- 字符集要做归一化排序再比较,否则同样的字因为顺序不同会天天判定为"有变化"。
- 保存字符集的文件不要用
strip()读。本例子集字符串首位恰好是空格字符,strip()会把它吃掉,导致每次比对都不相等、每次重建——幂等性直接失效。用rstrip("\r\n")。 - 过滤控制字符(
\x00-\x1f),别把它们塞进 URL。 - 重建失败不能阻断主流程。字体是锦上添花,标题同步才是主业:字体步骤用 try/except 包住,失败只记日志。
- 原子写入:先写临时文件再
os.replace(),避免定时任务和用户请求撞车读到半个文件。 - 版本号用内容哈希:只有内容真的变了,
?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 |
| 网格间距 | 20px | 16px |
| 流程步骤间距 | 较大 | 26px |
| 页脚上内边距 | 较松 | 34px |
三条经验:
- 先定"节奏",再逐块调。统一了"区块 44 / 标题到内容 26 / 卡片 22"这几个基准,整页才会一致,而不是东一块紧西一块松。
- 压缩间距的同时要压缩卡片内边距和图标下间距,只改外边距会显得"内容挤在卡片里"。
- 响应式要分档。手机端别照搬桌面值:区块 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 |
| 请求数 | 85 | 6 | 6 |
| HTML | 未压缩 | 39 KB 源码 / 10 KB gzip | 同左 |
| 字体 | 508 KB CSS + 4.38 MB 分片 | 132 KB(1 个文件) | 同左 |
| 首屏大图 | 459 KB JPG | 30 KB WebP | 94 KB WebP |
| 关于我们配图 | 427 KB JPG | 37 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 五六个资源,它们会互相抢有限的连接带宽。
结语
这次优化没有一个"黑科技",全是四件事的排列组合:
- 把不必要的东西删掉(整站字体 CSS、旧格式大图);
- 把必要的东西变小(按用字子集、WebP、双尺寸);
- 把剩下的东西缓存好(长缓存 + 内容哈希);
- 把"会腐化"的环节自动化(字体子集跟随内容重建,且幂等)。
真正的收益不在于"少了几 MB",而在于——以后每次更新内容,都不会悄悄把性能打回原形。