我只想给首页的几处固定标题换一种更厚、更有辨识度的字体。完整的 NotoSansCJKsc-Black.otf 却有 17,797,908 字节,直接放进小程序显然不合算;为十几个字增加运行时字体服务器、下载状态和失败回退,也把一个很小的视觉需求变成了长期依赖。
最后采用的办法很直接:维护一份字符清单,用 Node 脚本从 Noto Sans CJK SC Black 裁出 WOFF,再转成 Base64 写进 CSS。生成文件随源码发布,用户打开小程序时不需要再请求字体。
本次实际结果如下:
- 源 OTF:17,797,908 字节;
- 去重后字符:15 个;
- 裁剪后的 WOFF:4,968 字节;
- 包含
@font-face的内联 CSS:6,848 字节。
Base64 会让二进制内容膨胀约三分之一,但在字体只剩 5 KB 时,这点代价比增加一次运行时请求更容易接受。
为什么只用 Node
字体裁剪常见的做法是 PowerShell 负责下载,再调用 Python 的 FontTools。它能工作,但项目本身已经使用 Node,继续加入两套运行时只会增加环境安装、版本对齐和 CI 配置。
这里使用 subset-font。它通过 HarfBuzz 的 WebAssembly 构建处理字体子集,可以读取 TrueType、OpenType、WOFF 和 WOFF2,并输出 sfnt、WOFF 或 WOFF2。这样从下载、哈希校验、裁剪到生成 CSS 都能留在同一个 Node 脚本里。
依赖和命令放在客户端工程中:
{
"scripts": {
"font:build": "node ./scripts/fonts/build-display-font.mjs"
},
"devDependencies": {
"subset-font": "^2.5.0"
}
}需要重新生成时只运行:
npm run font:build单独维护字符清单
不要扫描整个源码自动收集汉字。展示字体只服务于少量、固定的短文案,显式清单更容易看出字体真正覆盖了什么,也不会因为一段普通正文混进页面而让字体体积悄悄增长。
例如 scripts/fonts/display-chars.txt 可以写成:
今日推荐明日预告昨日回顾
首页分类我的生成脚本会去掉空白并去重:
const charactersSource = await readFile(charactersPath, 'utf8')
const characters = [...new Set(charactersSource.replace(/\s+/g, ''))].join('')
if (!characters) throw new Error('裁剪字符不能为空')这也意味着动态昵称、接口返回内容和长段正文不适合使用这份字体。字符不在子集中时,渲染器会尝试后面的回退字体,字形会在同一句话里发生变化。
固定字体来源,而不是每次取最新版
可重复生成比“总能下载到一个同名字体”更重要。脚本应固定 Noto CJK 的提交,并校验源文件 SHA-256:
import { createHash } from 'node:crypto'
const FONT_COMMIT = 'PINNED_COMMIT'
const FONT_SHA256 = 'EXPECTED_SHA256'
const FONT_URL =
`https://raw.githubusercontent.com/notofonts/noto-cjk/${FONT_COMMIT}` +
'/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Black.otf'
const response = await fetch(FONT_URL)
if (!response.ok) {
throw new Error(`字体下载失败:HTTP ${response.status}`)
}
const sourceFont = Buffer.from(await response.arrayBuffer())
const actualHash = createHash('sha256').update(sourceFont).digest('hex')
if (actualHash !== FONT_SHA256) {
throw new Error(`字体源文件校验失败:${actualHash}`)
}这里选择 SC 版本,是为了明确使用简体中文地区字形。Noto CJK 仓库也把 SC 标为 Simplified Chinese,而不是把所有 CJK 版本当成可以无差别替换的文件。
脚本同时下载同一提交中的 Sans/LICENSE,并把它和生成产物放在一起。字体能裁剪、能随应用分发,不代表可以丢掉授权文件;具体分发仍应遵守 SIL Open Font License 1.1。
生成 WOFF 和内联 CSS
核心裁剪代码并不长:
const subset = await subsetFont(sourceFont, characters, {
targetFormat: 'woff',
preserveNameIds: [0, 1, 2, 3, 4, 5, 6, 13, 14],
})
if (subset.length === 0 || subset.length > 128 * 1024) {
throw new Error(`裁剪字体体积异常:${subset.length} 字节`)
}128 KB 不是平台限制,而是项目自己的防退化阈值。如果字符清单被意外扩大,生成过程会立刻失败,不会把一个已经失去意义的“大号子集”悄悄提交进仓库。
接着把 WOFF 写入 data: URL:
const fontCss = `/* 此文件由字体脚本生成,请勿手动修改。 */
@font-face {
font-family: LocalDisplayBlack;
src: url("data:font/woff;base64,${subset.toString('base64')}") format("woff");
font-style: normal;
font-weight: 900;
}
`
await writeFile(outputCssPath, fontCss, 'utf8')生成后的 CSS 和授权文件都提交到源码。日常构建读取本地文件,不访问 GitHub;只有字符或字体版本发生变化、主动运行 font:build 时才需要网络。
在 Taro 中只给指定文字使用
全局样式导入生成文件,再定义一个带系统字体回退的工具类:
@import "./assets/fonts/noto-sans-cjk-sc-black-display.css";
.font-display-black {
font-family: LocalDisplayBlack, "PingFang SC", "Microsoft YaHei", sans-serif;
font-weight: 900;
}组件中只给固定文案添加 class:
<Text className="font-display-black text-title">今日推荐</Text>不要把它设置成整个页面的默认字体。Black 字重适合短标题和按钮,正文长时间阅读会显得拥挤;更重要的是,当前子集根本没有覆盖正文所需字符。
字体文案变化时怎么维护
这套方案把维护动作变得很明确:
- 修改
display-chars.txt,加入新增文案中的字符; - 运行
npm run font:build; - 检查脚本输出的字符数和 WOFF 字节数;
- 提交字符清单、生成 CSS 和授权文件;
- 运行客户端的类型、代码和样式检查。
还应检查生成 diff。如果只是增加一两个字,产物通常只会小幅增长;体积突然跳到几十或几百 KB,往往说明字符清单混入了不该使用展示字体的内容。
验证边界
静态检查可以确认 CSS 能被构建工具读取、class 名存在、生成文件没有丢失,但无法证明所有微信基础库和真机都按预期渲染字体。上线前仍要在微信开发者工具和至少一台真机上确认:
- 首屏没有额外字体网络请求;
- 指定标题使用了新字形,其他正文保持系统字体;
- 字符清单中的每个字都能显示,没有局部回退;
- 冷启动时没有明显的字体切换闪烁;
- 小程序包体增长与生成 CSS 的大小相符。
如果后续字符从十几个增长到数百个,内联方式未必仍是最优。到那时应重新比较分包、本地字体文件和远程字体,而不是无条件沿用当前方案。方案的成立条件一直是:文案固定、字符很少、生成产物足够小。
结论
少量中文展示字体的关键不是“如何加载一个字体文件”,而是先把字体覆盖范围缩到真实需求。字符清单让范围可见,固定提交和 SHA-256 让结果可复现,体积阈值防止产物失控,生成后的 Base64 CSS 则消除了运行时字体服务。
对只有十几个固定标题字的小程序,这套 Node 单脚本方案已经足够。它没有引入新的服务器,也没有让页面承担字体加载状态;文案变化时,重新生成并检查体积即可。