跳转到内容
陆上江南
返回

中文网站自托管变量字体方案:unicode-range 分包实战

编辑页面

问题

中文网站面临一个英文站不存在的问题:一个字库动辄 5-10 MB。完整加载不可行,不加载则回退到系统默认字体,不同平台观感差异巨大。

常见的应对方式各有短板:

方案首屏体积跨平台一致性国内访问维护成本
Google Fonts CDN小(按需)慢/不可用
系统字体栈
完整自托管大(5-10MB)
unicode-range 分包小(按需)

我在两个项目中落地了同一种方案——Noto 变量字体 + unicode-range 分包,这篇文章记录完整操作和效果。

方案原理

变量字体

传统字体每个 weight(400、600、700)是一个独立文件。变量字体把整个字重轴打包进一个文件:

/* 传统:三个文件 */
@font-face { font-family: "Noto Serif"; font-weight: 400; src: url(noto-400.woff2); }
@font-face { font-family: "Noto Serif"; font-weight: 600; src: url(noto-600.woff2); }
@font-face { font-family: "Noto Serif"; font-weight: 700; src: url(noto-700.woff2); }

/* 变量字体:一个文件,覆盖 200-900 */
@font-face {
  font-family: "Noto Serif Variable";
  font-weight: 200 900;
  src: url(noto-wght.woff2) format("woff2-variations");
}

unicode-range 分包

变量字体仍然完整包含了所有汉字(约 2 万个),文件依然很大。unicode-range 将字库按字符编码范围切成多个分片,浏览器只下载当前页面实际用到的字符所在的分片。

打开一个中文页面时,浏览器会:

  1. 解析 HTML,收集所有字符
  2. 匹配每个 @font-face 规则的 unicode-range
  3. 只下载覆盖了这些字符的那几个 woff2 文件
  4. 其余分片保持未加载状态

实际分片结构

以 Noto Serif SC 为例,@fontsource 将其拆成 101 个 woff2,每个覆盖一个 Unicode 区块:

/* 分片 4:表情符号区域 */
@font-face {
  font-family: "Noto Serif SC Variable";
  font-weight: 200 900;
  src: url(./files/noto-serif-sc-4-wght-normal.woff2) format("woff2-variations");
  unicode-range: U+1f1e9-1f1f5, U+1f1f7-1f1ff, /* ... */;
}

/* 分片 21:基本汉字(CJK Unified Ideographs 核心区) */
@font-face {
  font-family: "Noto Serif SC Variable";
  font-weight: 200 900;
  src: url(./files/noto-serif-sc-21-wght-normal.woff2) format("woff2-variations");
  unicode-range: U+4E00-9FFF, /* ... */;
}

完整的 101 个分片覆盖了从拉丁字母到扩展汉字、从标点到表情符号的全部 Unicode 区块。每个分片几十 KB 到几百 KB 不等,总量约 6.1 MB。

实操:astro-paper-blog

astro-paper-blog 是基于 AstroPaper 模板的个人博客,框架为 Astro + Tailwind CSS。

1. 安装字体源

通过 @fontsource 安装三套 Noto 变量字体:

pnpm add @fontsource-variable/noto-serif
pnpm add @fontsource-variable/noto-serif-sc
pnpm add @fontsource-variable/noto-sans-mono

@fontsource 包自带 woff2 文件和对应的 font.css(含所有 @font-face 声明),放在 node_modules/@fontsource-variable/ 下。构建时将 font.cssfiles/ 目录复制到 public/fonts/ 即可。

2. 加载字体 CSS

src/layouts/Layout.astro<head> 中先后加载三份字体样式:

<link rel="stylesheet" href={getAssetPath("fonts/noto-serif/font.css")} />
<link rel="stylesheet" href={getAssetPath("fonts/noto-serif-sc/font.css")} />
<link rel="stylesheet" href={getAssetPath("fonts/noto-sans-mono/font.css")} />

三份 font.css 各自包含数个到上百个 @font-face 声明,使用 unicode-range 声明覆盖范围。

3. 配置字体栈

src/styles/theme.css 中定义 CSS 变量,Noto 变量字体排最前面,后面跟系统回退:

--font-app:
  "Noto Serif Variable", "Noto Serif SC Variable", "Songti SC", "STSong",
  "Source Han Serif SC", "Noto Serif CJK SC", "SimSun", serif;

--font-code:
  "Noto Sans Mono Variable", ui-monospace, "Cascadia Mono", "Cascadia Code",
  monospace;

正文使用宋体/衬线风格,代码使用等宽变量字体。--font-appNoto Serif Variable(拉丁)在前、Noto Serif SC Variable(中文)紧随其后——浏览器先尝试用拉丁字体渲染英文和数字,遇到 CJK 字符时回退到中文字体。

4. 最终产物

public/fonts/
├── noto-serif/         2 个 woff2,240 KB   (拉丁字母)
├── noto-serif-sc/    101 个 woff2,6.1 MB   (简体中文)
└── noto-sans-mono/     2 个 woff2,208 KB   (等宽代码)

总量 6.6 MB 存在服务器上,单个页面实际下载量远小于此——一篇中文博客约使用 2000-3000 个不重复汉字,分布在 2-3 个 Unicode 区块,实际下载约 300-600 KB。已下载的分片被浏览器长期缓存,访问站内其他页面时只补充新出现的字符分片。

实操:interview-wiki

interview-wiki 是后端面试知识库,框架为 Quartz v5(TypeScript 静态站点生成器)。与 Astro 不同,Quartz 有一套自己的插件和资源管理机制。

1. 复制字体文件

interview-wiki 直接复用了 astro-paper-blog 的字体产物,不做二次安装:

cp -r ~/Documents/projects/astro-paper-blog/public/fonts \
     ~/Documents/projects/interview-wiki/quartz/static/fonts

Quartz 会把 quartz/static/ 下的所有文件原样复制到构建产物的 /static/ 路径。

2. 嵌入 @font-face 声明

astro-paper-blog 通过 <link> 标签加载独立的 font.css。Quartz 的架构不同——它的 SCSS 编译管线由 esbuild 的 sass 插件处理,自定义样式只能写在 quartz/styles/custom.scss 中。

为了在不修改 vendored 框架代码的前提下注入 @font-face,我创建了 quartz/styles/_fonts.scss 作为 SCSS partial,将三份 font.css 的内容合并进去,并把相对路径改为绝对路径:

// quartz/styles/_fonts.scss(2376 行,仅展示结构)

/* ===== noto-serif ===== */
@font-face {
  font-family: "Noto Serif Variable";
  font-weight: 100 900;
  src: url("/static/fonts/noto-serif/files/noto-serif-latin-wght-normal.woff2")
    format("woff2-variations");
  unicode-range: U+0100-02BA, /* ... */;
}

/* ===== noto-serif-sc ===== */
/* 101 个 @font-face 声明,每个覆盖不同 unicode-range */

/* ===== noto-sans-mono ===== */
@font-face {
  font-family: "Noto Sans Mono Variable";
  font-weight: 100 900;
  src: url("/static/fonts/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
    format("woff2-variations");
  unicode-range: U+0100-02BA, /* ... */;
}

然后在 custom.scss 中导入:

@use "./variables.scss" as *;
@import "./fonts";    // ← 2376 行 @font-face 全部内联到主样式表

编译后,105 条 @font-face 声明直接写入 index-*.css,不再需要额外的 <link> 标签。

3. 配置 Quartz 字体

quartz.config.yaml 中设置字体栈(与 astro-paper-blog 相同),并显式关闭 Google Fonts:

configuration:
  theme:
    fontOrigin: local
    cdnCaching: false
    typography:
      header: "Noto Serif Variable, Noto Serif SC Variable, Songti SC, STSong, Source Han Serif SC, Noto Serif CJK SC, SimSun, serif"
      body: "Noto Serif Variable, Noto Serif SC Variable, Songti SC, STSong, Source Han Serif SC, Noto Serif CJK SC, SimSun, serif"
      code: "Noto Sans Mono Variable, ui-monospace, Cascadia Code, Cascadia Mono, SFMono-Regular, SF Mono, Menlo, monospace"

plugins:
  - source: github:quartz-community/fonts
    enabled: true
    options:
      fontOrigin: local
      header: Noto Serif SC Variable
      body: Noto Serif SC Variable
      code: Noto Sans Mono Variable

这里有两层配置:configuration.theme 控制生成的 CSS 变量(--headerFont 等),plugins[fonts].options 控制 Quartz fonts 插件的行为。两层都要设 fontOrigin: local,否则 fonts 插件会按默认值 googleFonts 尝试从 Google Fonts 拉取字体。

4. 两个框架的差异总结

环节astro-paper-blog (Astro)interview-wiki (Quartz)
字体获取pnpm add @fontsource-variable/*从 astro-paper-blog 复制
@font-face 注入<link> 标签加载独立 CSSSCSS partial → 内联到主样式表
路径方式getAssetPath() 兼容子目录部署硬编码 /static/fonts/ 绝对路径
关闭外部请求天然无外部请求需显式设置 fontOrigin: local 两处
字体文件存放public/fonts/quartz/static/fonts/

效果

加载行为

打开一篇中文文章时,浏览器 Network 面板中只会看到 2-3 个中文字体分片的 woff2 请求,而不是 101 个全部下载。以 interview-wiki 首页为例:

noto-serif-sc-21-wght-normal.woff2    ~180 KB  (基本汉字)
noto-serif-sc-27-wght-normal.woff2    ~120 KB  (扩展汉字)
noto-serif-sc-latin-wght-normal.woff2  ~15 KB  (拉丁/标点)
noto-serif-latin-wght-normal.woff2     ~120 KB (拉丁衬线正文)
noto-sans-mono-latin-wght-normal.woff2 ~90 KB  (代码等宽)
─────────────────────────────────────────
合计                                   ~525 KB

525 KB 是首访一个中文页面的实际下载量。相比之下,完整下载 Noto Serif SC 需要约 5.7 MB(单个 woff2)或 6.1 MB(101 个分片全量)。

跨页缓存复用

已下载的分片被浏览器以 URL 为键缓存。站内导航到另一篇文章时:

对于博客/wiki 这类多页面站点,字体加载成本集中在首访的前几个页面,之后趋近于零。

回退机制

font-display: swap 确保文本立即以系统字体渲染,变量字体加载完成后无缝切换。用户在任何网络条件下都能立即阅读,不会出现 FOIT。

字体栈中的回退链覆盖主流平台:

macOS/iOS:  Songti SC → STSong → 宋体
Windows:    SimSun → 宋体
Linux:      Noto Serif CJK SC → Source Han Serif SC → 宋体

方案对比

与 Google Fonts CDN 对比

Google Fonts 也支持 unicode-range 分包,且无需自托管。但有两个致命问题:

  1. 国内不可用fonts.googleapis.com 被墙,请求超时后浏览器回退到系统字体,用户永远看不到自定义字体
  2. 构建依赖外部服务:CI 环境如果无法访问 Google Fonts,字体 CSS 获取失败会导致构建中断(interview-wiki 的 og-image 插件就因此被禁用)

自托管方案没有这些外部依赖,构建和运行时完全离线。

与系统字体栈对比

纯系统字体栈(如 font-family: "PingFang SC", "Microsoft YaHei", sans-serif)体积为零,但:

自托管方案在付出约 500 KB 首屏代价后,获得全平台统一的字体体验。

与完整自托管对比

完整自托管(单个 woff2,不做 unicode-range 分包)简单直接——一个 @font-face 声明、一个文件。但首屏下载 5.7 MB 的中文字体是不可接受的。

与内容子集化对比

另一种缩小体积的思路:扫描全部文章内容,提取去重汉字集合,用 pyftsubset 裁剪出一个只含这些字的 woff2。优点是文件更小(几千汉字约 1-2 MB),但:

unicode-range 分包用现成的 @fontsource 产物,新增文章零维护,浏览器只下载用到的分片,实际体验和子集化接近。

量化对比

指标系统字体栈Google Fonts完整自托管内容子集化本方案
首屏字体下载0~500 KB5.7 MB1-2 MB~500 KB
跨平台一致性
国内可用不可用
外部依赖依赖 Google
新增文章维护需重建字体
变量字体支持取决于文件可能丢失完整保留

适用范围

这套方案适合以下场景:

不适合的场景:

迁移路径

如果你的站点目前使用 Google Fonts 或系统字体,迁移步骤如下:

  1. 安装 @fontsource-variable/noto-serif-sc 和相关字体包
  2. node_modules/@fontsource-variable/*/ 下的 font.cssfiles/ 复制到静态资源目录
  3. 在 HTML <head> 或全局 CSS 中加载 font.css
  4. font-family 替换为包含 Noto Variable 的字体栈
  5. 移除 Google Fonts 的 <link>@import
  6. 构建并验证:Network 面板中应只有 2-5 个 woff2 请求,总量 < 1 MB

本文由 Claude Code 辅助生成。

📝 更新记录(2026-07-07 17:23:22)
时间操作说明Agent
2026-07-07 17:23:22创建初次生成全文Claude Code 2.1.201 / deepseek-v4-pro[1m]

编辑页面

上一篇
用 Claude Code Skills 打造个人 AI 工作流
下一篇
AstroPaper 博客改造日志