一份 1.2MB 的篆体字,最后页面上常常只用到几十个字。剩下的几千个,是带着它们旅行——上传到 CDN,缓存到浏览器,从未被任何一个像素调用过。
为什么要写它
valaxy-theme-shuimo 里内置了一份峄山碑篆体,原始字库 ~1.2MB。任何一个开篆体口子的站点都会无差别地把这一坨发给所有访客——哪怕全站只在印章上盖了四个字。
我一开始想过把篆体改为按需懒加载,但篆字是要参与首屏画印章的——晚到一帧就露出兜底字体。又想过手动维护一份「站点常用字」字符表,可这种东西每次写新文章都得记得回来加几个字,迟早翻车。
@jobinjia/vite-plugin-shuimo-font-subset 是给这个问题的答案:构建时扫源码,扫到哪个字就留哪个字。结果是 10–30KB 的迷你 WOFF2,剩下的 99% 字节躺在源字体里,永远不上船。
一个插件,两个钩子
整个插件只有 ~190 行,核心是两段 Vite hook:
export default function shuimoFontSubset(options: PluginOptions): Plugin {
return {
name: 'shuimo-font-subset',
apply: 'build', // dev 不动手术,篆体仍是完整的
async buildStart() { /* 扫源码,攒字符集 */ },
async generateBundle() { /* 换字体字节 */ },
}
}apply: 'build' 是有意为之——开发时字体是全的,写文章不会因为没扫到而看不到字;只有正式构建才裁。
buildStart:扫一遍源码
scanContent 用 globby 把 scanFiles 解析成绝对路径,再并发读取每一份文件,按 codepoint 过滤:
for (const char of content) {
const cp = char.codePointAt(0)
if (
(cp >= 0x4E00 && cp <= 0x9FFF) // CJK 统一汉字
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK 扩展 A
|| (cp >= 0x0021 && cp <= 0x007E) // ASCII 可打印
) {
chars.add(char)
}
}只保留这三段——理由很简单:篆体字库不太可能有韩文、假名、扩展 B/C 的字形,扫到了也是空。其余 Unicode 区段被直接丢弃。
如果有运行时拼接、永远扫不到的字(比如来自 LocalStorage、来自远端 JSON),可以再开个口子:
shuimoFontSubset({
// ...
extraChars: '隔窗梅雪', // 始终强加入子集
})多 cwd 扫描:把主题包也卷进来
最初版本的 scanFiles 只接受 string[],意味着你必须用同一个 cwd——但篆字其实分布在两处:
- 用户站点:
pages/**/*.md、src/**/*.{vue,ts} - 主题包内部:
valaxy-theme-shuimo自己的组件里硬编码的"墨韵书斋"等四个字
主题里那几个字用户的 pages/ 里永远不会出现。后来 scanFiles 改成可接 { cwd, patterns }[]:
shuimoFontSubset({
targetFonts: ['yishanbeizhuanti.woff2'],
scanFiles: [
{ cwd: process.cwd(), patterns: ['pages/**/*.md', 'src/**/*.{vue,ts}'] },
{
cwd: path.dirname(require.resolve('valaxy-theme-shuimo/package.json')),
patterns: ['components/**/*.vue'],
},
],
})各 source 独立 glob、独立读取,最后合并到同一个字符集里。这一步在主题站点上把意外缺字的问题彻底消掉了。
generateBundle:换骨不换皮
扫完字,剩下的活就是把构建产物里那份 woff2 偷偷换掉。Vite 把所有静态资源放在 bundle 对象里,每一个 asset 有 name(原始基名)和 fileName(哈希后的输出名)两个字段——匹配要看的是前者:
for (const asset of Object.values(bundle)) {
if (asset.type !== 'asset') continue
const candidateName = asset.name ?? path.basename(asset.fileName)
if (!targetBasenames.has(candidateName)) continue
const original = Buffer.isBuffer(asset.source)
? asset.source
: Buffer.from(asset.source as Uint8Array)
asset.source = await subsetFont({
fontBuffer: original,
chars: targetChars,
format: options.format ?? 'woff2',
})
}subset-font 是 papandreou 写的小工具,底下包的是 HarfBuzz subset——和 Google Fonts 那一套同源的字体裁剪库,业界事实标准。
值得说的是 asset.source = subset 这一行。Rollup 和 Rolldown 都允许对 asset 对象做属性赋值——它们只禁止往 bundle 这个映射本身新增 key(bundle[newKey] = ...)。所以原地改 source 是合法且兼容的:插件能在 Vite 5 / 6 / 7 / 8、Rollup 和 Rolldown 之间无缝运行。
一些"想清楚再写"的小事
只换字节,不换扩展名。format 选 woff、truetype 都可以,但插件不会去改 import 里的字体路径——你要 format: 'woff2',你的 import 也得自己写成 .woff2。这是一条「单一职责」红线,越界了就成"魔改 import"。
匹配键是原始基名。Vite 默认会给资源加哈希后缀(yishanbeizhuanti.5a82c1.woff2),所以匹配只能看 asset.name——也就是入口时的原始文件名。这一点容易让人困惑:调试时你看到的输出名跟配置里写的不一样,但插件比对的是原文件名。
生成器无能为力的事。subset-font 只能从源字体里挑出字符,无法合成。如果你想给峄山碑加一个它没有的字,不管你在 extraChars 里写多少次都不会出现——它本来就不存在。
Hangul、假名、扩展 B 都被悄悄跳过。扫描器对未支持区段是「静默丢弃」而不是「报错」——理由是:篆体字库本身就不会有这些字形,报错也救不了你;当真要用,得换字库。
这本质上是一台"内容感知"的过滤器
如果把这件事写成一句话:
编译时扫一遍你写过的字,把字体里没用到的部分丢掉。
它的形状非常简单——一个 Set<string> + 一次 subset-font 调用。但配合 Vite 的 asset pipeline 之后,它改变了一种部署假设:字体不再是一个一次性发布、永远全量的静态资源,而是和文章内容一起、随着 build 输出动态调整。
写一篇新文章,多了几个生僻字?下一次 build 字体自动把它们加进去。 删一个旧页面,那些只在它里面出现过的字也跟着走?下一次 build 自动瘦回去。
字体第一次有了「内容」。
尾声
字字必裁。
我们曾以为字体是黑盒——你下载,浏览器渲染,不必关心里面是什么。但 WOFF2 其实是一个明白可读的容器:HarfBuzz 看得懂,subset-font 看得懂,一段 ~190 行的 Vite 插件也看得懂。它愿意只留下你用过的字,让那些躺过千年的生僻字回到原字库里继续等下一个读者。
体积变小是结果;让字体「随站而生」是更长远的那件事。
