背景
最近在做一个 H5 营销活动时,遇到了一个比较头疼的问题:页面要用到 7 种字体(SourceHanSansCN Regular/Bold/Heavy/Medium、YouSheBiaoTiHei、Zuume 等),原始ttf
文件体积加起来 42MB,就算换成压缩率较高的woff2
格式也需 20MB 左右。
这些字体基本都是首屏就用的,移动端一上来就加载这么大的文件,直接把首屏拖慢了,网络没那么好的用户,可能会长时间白屏,这种情况下,只能想办法给字体"瘦身"。
常规手段为什么不够用
一般来说,要在 Web 中使用自定义字体,通过以下方式即可:
css
@font-face {
font-family: "SourceHanSansCN-Bold";
src: url("./SourceHanSansCN-Bold.woff2") format("woff2"),
url("./SourceHanSansCN-Bold.woff") format("woff"),
url("./SourceHanSansCN-Bold.ttf") format("ttf");
font-display: swap;
}
.box{
font-family: "SourceHanSansCN-Bold";
}
woff
格式相对于ttf
,一般可以减少 40% 左右的体积,而woff2
相对于ttf
,一般可以减少 50% 左右的体积。woff/woff2
的兼容性良好,几乎所有现代浏览器都已支持,只是在 IE 浏览器的支持上略差一点,在移动端使用一般是没有问题的,并且通过woff2->woff->ttf
的降级策略,可以兼顾性能与兼容性。
font-display: swap
是一种字体加载策略,它的作用是在字体文件加载完成之前,使用浏览器默认的字体显示,等字体文件加载完成后,再切换到目标字体。通过这个配置,就可以避免 H5 在初始化渲染阶段被字体文件阻塞,导致页面白屏。
以下是兼容性汇总图:
![]() |
![]() |
---|---|
![]() |
![]() |
上述的方案看起来问题不大,但是在移动端流量寸土寸金的情况下,仍然遇到了一些问题,比如在用户网络不佳的时候,可能需要 10 秒后才"闪一下"换到目标字体 ,体验差强人意。更差的情况是,部分真机上 swap
不生效,页面依旧会长时间白屏。
业界方案调研
在经过一些调研后,发现主要有两种方案:
- 根据 HTML 中的字符,裁剪字体文件,比如 font-spider。
bash
npm install font-spider -g
font-spider index.html
font-spider 的工作原理是解析 HTML 文件,提取其中用到的字符,然后对字体文件进行裁剪。优点是使用简单,但局限性也很明显:
-
只支持 HTML 文件,无法处理 JS/TS、JSX/TSX、Vue 等现代前端文件。
-
只适用于静态站点,不适用于 React/Vue 等动态项目。
- 手动设置要保留的字符,然后裁剪字体文件,比如 Fontmin。
bash
npm install fontmin --save-dev
js
import Fontmin from "fontmin";
const fontmin = new Fontmin()
.src(path.resolve(__dirname, "../src/assets/fonts/source/*.ttf"))
.dest(outputDir)
.use(
Fontmin.glyph({
text: "需要保留的文字内容",
hinting: false,
})
)
.use(Fontmin.ttf2woff())
.use(Fontmin.ttf2woff2());
Fontmin 更加灵活,可以手动指定要保留的字符。但在实际开发中,手动维护字符集是个繁琐的工作,需要额外的自动化脚本支持。看起来这两种方案都不太适合我们的需求。
我们的解决方案
基于项目需求,我们写了一个字体裁剪工具,核心思路是:
-
用 Node.js 扫描指定目录/文件,提取"所有可见字符"(全量提取,带去重)。
-
将提取的字符与一份日常维护的常用字符表(common.txt)合并,兜底不可静态分析的场景。
-
用 subset-font 生成子集字体,直接输出
woff2/woff/ttf
三种格式。
代码示例:
ts
// 核心配置
const config = {
sourceDir: "./src", // 源代码目录
fontDir: "./fonts", // 字体文件目录
outputDir: "./output",// 输出目录
fileExtensions: ["ts", "js", "html", "tsx", "jsx"], // 支持的文件扩展名
commonTxtPath: "./common.txt", // 常用字符文件路径
targetFormats: ["woff2", "woff", "ttf"] as FontFormat[], // 目标字体格式
ignorePatterns: ["**/node_modules/**", "**/dist/**", "**/.git/**"], // 忽略的文件/目录模式
};
// 字体裁剪核心流程
async function fontTrim() {
const charsOutDir = config.outputDir;
const fontsOutDir = path.join(config.outputDir, "fonts");
// 清理并创建输出目录
await fs.rm(charsOutDir, { recursive: true, force: true }).catch(() => {});
await ensureDir(charsOutDir);
await ensureDir(fontsOutDir);
// 全量提取可见字符/剔除无效字符
const extractedSet = await extractVisibleCharsFromDirectory(
config.sourceDir,
config.fileExtensions,
config.ignorePatterns
);
const extractedStr = toSortedString(extractedSet);
// 合并用户常用字符
const userSet = await readUserCommonChars(config.commonTxtPath);
const merged = new Set<string>([...extractedSet, ...userSet]);
const mergedStr = toSortedString(merged);
// 保存字符集文件
const extractedOut = path.join(charsOutDir, "characters.extracted.txt");// 提取到的字符
const mergedOut = path.join(charsOutDir, "characters.merged.txt");// 与 common.txt合并后的字符
await writeText(extractedOut, extractedStr);
await writeText(mergedOut, mergedStr);
// 查找字体文件
const fontFiles = await findFontFiles(config.fontDir, config.ignorePatterns);
// 裁剪并输出字体文件
await subsetFonts(fontFiles, mergedStr, fontsOutDir, config.targetFormats);
}
关于"提取所有可见字符 ",一般来说会优先考虑精准提取,例如一些自动国际化的工具,会去精准提取需要国际化的字符,并进行配置替换。这种做法涉及较复杂的正则匹配或 AST 解析,并且为了实现更多的代码场景覆盖,也需要不断地穷举边界情况、更新提取逻辑,比如 log 内容、注释内容、 <img alt="图片"/>
里的 alt 属性是否需要提取等。
但在字体裁剪这种场景下,实现精准提取的成本和收益并不一定成正比 ,因为代码的绝大部分字符是 a-z/A-Z
,在进一步去重之后,冗余字符不会太多。以我们一个包含 3 个页面及 30 个弹窗的营销活动为例,提取完tsx
文件中的所有可见字符后,字符总数只有 700 多个(包括中文、英文、符号)。
该工具还会增加一份日常维护的常用字符文件,以兜底一些无法覆盖到的场景,比如某些字符来自于后端配置,如下是一份 1500 左右的常用字符:

工具的运行效果如图:
![]() |
![]() |
---|
可以看出,在增加了 1500 左右的冗余字符后,裁剪效果也非常明显:
- 原始字体文件:10MB(ttf)
- 裁剪后:349KB(ttf)、225KB(woff)、173KB(woff2)
- 压缩率:96%+
辅助优化策略
字体加载优化
如果是暂时没有用到的字体,那么即便定义了@font-face
也不会立即下载字体文件。这种情况下可通过以下方式进行预加载,当 DOM 元素用到该字体时(即开始应用 font-family
CSS 属性),就可以快速生效:
html
<link rel="preload" href="./SourceHanSansCN-Bold.woff2" type="font/woff2" >
另外在一些老旧设备下,font-display: swap
失效时依旧可能会导致页面加载稍慢(比如裁剪后整体字体文件体积较大,达到 1MB 的情况下),这时可以考虑异步加载字体文件,结合上面的预加载方式,可以通过以下的代码进行优化:
ts
// 导入字体文件
import SourceHanSansCNBoldTtf from "./SourceHanSansCN-Bold.ttf";
import SourceHanSansCNBoldWoff from "./SourceHanSansCN-Bold.woff";
import SourceHanSansCNBoldWoff2 from "./SourceHanSansCN-Bold.woff2";
interface FontConfig {
family: string;
woff2: string;
woff: string;
ttf: string;
}
interface FontSupport {
woff2: boolean;
woff: boolean;
ttf: boolean;
}
interface FontLoaderParams {
fonts?: string[];
}
class FontLoader {
defaultFonts: FontConfig[] = [
{
family: "SourceHanSansCN-Bold",
woff2: SourceHanSansCNBoldWoff2,
woff: SourceHanSansCNBoldWoff,
ttf: SourceHanSansCNBoldTtf,
},
];
private params: FontLoaderParams = {};
constructor(params: FontLoaderParams) {
this.params = params;
}
apply() {
// 异步加载字体,避免阻塞主线程
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
this.loadFonts();
});
} else {
setTimeout(() => {
this.loadFonts();
}, 0);
}
}
/**
* 检测浏览器对字体格式的支持
*/
private detectFontSupport() {
const support = {
woff2: this.checkFontFormat("woff2"),
woff: this.checkFontFormat("woff"),
ttf: this.checkFontFormat("ttf"),
};
return support;
}
/**
* 检测字体格式支持
*/
private checkFontFormat(format: keyof FontSupport) {
const userAgent = navigator.userAgent.toLowerCase();
switch (format) {
case "woff2":
// Chrome 36+, Firefox 39+, Safari 12+, Edge 14+
return (
/chrome/(3[6-9]|[4-9]\d|\d{3,})/.test(userAgent) ||
/firefox/(3[9]|[4-9]\d|\d{3,})/.test(userAgent) ||
/safari/(1[2-9]|[2-9]\d|\d{3,})/.test(userAgent) ||
/edge/(1[4-9]|[2-9]\d|\d{3,})/.test(userAgent)
);
case "woff":
// 大部分现代浏览器都支持
return !/msie [6-8]/.test(userAgent);
case "ttf":
// 几乎所有浏览器都支持
return true;
default:
return false;
}
}
private loadFonts() {
const params = this.params;
// 获取配置的字体列表,如果没有配置则使用全部字体
const fontsToLoad =
params?.fonts || this.defaultFonts.map((font) => font.family);
// 过滤出需要加载的字体配置
const selectedFonts = this.defaultFonts.filter((font) =>
fontsToLoad.includes(font.family)
);
// 检测字体格式支持
const fontSupport = this.detectFontSupport();
// 预加载字体文件
this.preloadFonts(selectedFonts, fontSupport);
// 异步加载字体定义
this.loadFontFaces(selectedFonts);
}
// 按照优先级顺序预加载字体:woff2 -> woff -> ttf
private preloadFonts(fonts: FontConfig[], fontSupport: FontSupport) {
const { head } = document;
const createPreloadLink = (fontPath: string, format: keyof FontSupport) => {
const link = document.createElement("link");
link.rel = "preload";
link.href = fontPath;
link.as = "font";
link.type = `font/${format}`;
link.crossOrigin = "anonymous";
head.appendChild(link);
};
fonts.forEach((font) => {
if (fontSupport?.woff2) {
createPreloadLink(font.woff2, "woff2");
} else if (fontSupport?.woff) {
createPreloadLink(font.woff, "woff");
} else if (fontSupport?.ttf) {
createPreloadLink(font.ttf, "ttf");
}
});
}
// 异步加载字体定义
private loadFontFaces(fonts: FontConfig[]) {
const style = document.createElement("style");
style.id = "async-font-faces";
let cssText = "";
fonts.forEach((font) => {
const src = `url('${font.woff2}') format('woff2'),
url('${font.woff}') format('woff'),
url('${font.ttf}') format('truetype')`;
cssText += `
@font-face {
font-family: '${font.family}';
src: ${src};
font-display: swap;
}
`;
});
style.textContent = cssText;
document.head.appendChild(style);
}
}
export default FontLoader;
JS 运行后,HTML 中的输出为:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="preload" href="./SourceHanSansCN-Bold.bb4742a8.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="./SourceHanSansCN-Heavy.72ef6b93.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<style>
@font-face {
font-family: 'SourceHanSansCN-Bold';
src: url('./SourceHanSansCN-Bold.bb4742a8.woff2') format('woff2'),
url('./SourceHanSansCN-Bold.b672db54.woff') format('woff'),
url('./SourceHanSansCN-Bold.b672db53.ttf') format('ttf');
font-display: swap;
}
@font-face {
font-family: 'SourceHanSansCN-Heavy';
src: url('./SourceHanSansCN-Heavy.72ef6b93.woff2') format('woff2'),
url('./SourceHanSansCN-Heavy.eb8ab999.woff') format('woff'),
url('./SourceHanSansCN-Heavy.eb8ab99f.ttf') format('ttf');
font-display: swap;
}
</style>
</head>
<body>
</body>
</html>
UGC(用户生成内容)场景优化
字体裁剪方案无法适用于 UGC 场景,因为用户生成的内容往往是不可预测的,比如用户昵称、用户评价内容等。这种情况下如果依然采取字节裁剪方案,可以进一步增加冗余字符(如 3500 常用字符),另外增加备用字体及字体样式来调整优化,比如:
css
.username {
font-family: "SourceHanSansCN-Bold", "Source Han Sans CN Bold", "PingFang SC",
"Heiti SC", "Hiragino Sans GB", sans-serif;
font-weight: 500;
}
SourceHanSansCN-Bold
是经过裁剪的字体,如果用户生成的内容中包含了未被裁剪的字符,那么就会用备用字体来渲染,通过设置一些系统中可能会存在及与主字体相似的备用字体,并且适当调整字体样式,可以很大程度上减小视觉上的差异。
总结与思考
该方案通过从源代码文件中提取字符及主动增加冗余字符的方式,在大幅减少字体文件体积的同时,进行了容错处理,提升了功能稳定性。
在实际应用中,我们还通过异步加载、预加载、备用字体等策略,进一步优化了字体加载性能和容错能力,解决字体渲染阻塞、UGC 覆盖等边界问题。
综合来看,该方案的设计思想和实现方式都比较简单,适用于中小型项目的快速开发。不过在大型项目中,可能需要更多地考虑方案的长期可靠性、扩展性及实现流程自动化等。