当前位置: 首页 > news >正文

[特殊字符] Web 字体裁剪优化实践:把 42MB 字体包瘦到 1.6MB

背景

最近在做一个 H5 营销活动时,遇到了一个比较头疼的问题:页面要用到 7 种字体(SourceHanSansCN Regular/Bold/Heavy/Medium、YouSheBiaoTiHei、Zuume 等),原始ttf文件体积加起来 42MB,就算换成压缩率较高的woff2格式也需 20MB 左右。

这些字体基本都是首屏就用的,移动端一上来就加载这么大的文件,直接把首屏拖慢了,网络没那么好的用户,可能会长时间白屏,这种情况下,只能想办法给字体“瘦身”。

常规手段为什么不够用

一般来说,要在 Web 中使用自定义字体,通过以下方式即可:

@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不生效,页面依旧会长时间白屏

业界方案调研

在经过一些调研后,发现主要有两种方案:

  1. 根据 HTML 中的字符,裁剪字体文件,比如 font-spider。
npm install font-spider -g
font-spider index.html

font-spider 的工作原理是解析 HTML 文件,提取其中用到的字符,然后对字体文件进行裁剪。优点是使用简单,但局限性也很明显:

  • 只支持 HTML 文件,无法处理 JS/TS、JSX/TSX、Vue 等现代前端文件。
  • 只适用于静态站点,不适用于 React/Vue 等动态项目。

2.手动设置要保留的字符,然后裁剪字体文件,比如 Fontmin。

npm install fontmin --save-dev

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 更加灵活,可以手动指定要保留的字符。但在实际开发中,手动维护字符集是个繁琐的工作,需要额外的自动化脚本支持。看起来这两种方案都不太适合我们的需求。

我们的解决方案

基于项目需求,我们写了一个字体裁剪工具,核心思路是:

  1. 用 Node.js 扫描指定目录/文件,提取“所有可见字符”(全量提取,带去重)。
  2. 将提取的字符与一份日常维护的常用字符表(common.txt)合并,兜底不可静态分析的场景。
  3. 用 subset-font 生成子集字体,直接输出woff2/woff/ttf 三种格式。

代码示例:

// 核心配置
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 属性),就可以快速生效:

<link rel="preload" href="./SourceHanSansCN-Bold.woff2" type="font/woff2" >

另外在一些老旧设备下,font-display: swap 失效时依旧可能会导致页面加载稍慢(比如裁剪后整体字体文件体积较大,达到 1MB 的情况下),这时可以考虑异步加载字体文件,结合上面的预加载方式,可以通过以下的代码进行优化:

// 导入字体文件
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 -> ttfprivate 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 中的输出为:

<!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 常用字符),另外增加备用字体及字体样式来调整优化,比如:

.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 覆盖等边界问题。

综合来看,该方案的设计思想和实现方式都比较简单,适用于中小型项目的快速开发。不过在大型项目中,可能需要更多地考虑方案的长期可靠性、扩展性及实现流程自动化等。

http://www.dtcms.com/a/540703.html

相关文章:

  • 平滑过渡,破解多库并存:浙人医基于金仓KFS的医疗信创实战解析
  • 做经营性的网站需要注册什么条件网站构思
  • Answer企业社区实战:零成本搭建技术问答平台,远程协作效率提升300%!
  • “听书”比“看书”更省力?
  • 大连 手机网站案例网站定位方案
  • window安装MYSQL5.5出错:a windows service with the name MYSQL alreadyexists....
  • 珠海做网站报价影响网站排名的因素
  • 6.1.2.2 大数据方法论与实践指南-离线任务SQL 任务开发规范
  • Java 大视界 -- Java 大数据在智能交通高速公路收费系统优化与通行效率提升实战(429)
  • 网站可以做怀孕单吗平面设计图数字标识
  • 图神经网络入门:手写一个 VanillaGNN-从邻接矩阵理解图神经网络的消息传递
  • 网站模版带后台酒类招商网站大全
  • 营销型网站创建网页制作三剑客通常指
  • 【笔试真题】- 电信-2025.10.11
  • 云渲染与传统渲染:核心差异与适用场景分析
  • 什么是流程监控?如何构建跨系统BPM的实时监控体系?
  • 直通滤波....
  • eclipse做网站代码惠州市
  • 零基础新手小白快速了解掌握服务集群与自动化运维(十五)Redis模块-Redis主从复制
  • 视频网站自己怎么做的正规的大宗商品交易平台
  • vue3 实现贪吃蛇手机版01
  • 胶州网站建设dch100室内装修设计师工资一般多少钱
  • 计算机视觉、医学图像处理、深度学习、多模态融合方向分析
  • 小白入门:基于k8s搭建训练集群,实战CIFAR-10图像分类
  • 关系型数据库大王Mysql——DML语句操作示例
  • VNC安装
  • 网站建设论文 php苏州关键词排名提升
  • 【MySQL】用户管理详解
  • 怎么制作手机网站金坛区建设工程质量监督网站
  • 企业网站的布局类型怎样免费建设免费网站