🚀 Web 字体裁剪优化实践:把 42MB 字体包瘦到 1.6MB

背景

最近在做一个 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 不生效,页面依旧会长时间白屏

业界方案调研

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

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

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

  • 只支持 HTML 文件,无法处理 JS/TS、JSX/TSX、Vue 等现代前端文件。

  • 只适用于静态站点,不适用于 React/Vue 等动态项目。

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

我们的解决方案

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

  1. 用 Node.js 扫描指定目录/文件,提取"所有可见字符"(全量提取,带去重)。

  2. 将提取的字符与一份日常维护的常用字符表(common.txt)合并,兜底不可静态分析的场景。

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

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

相关推荐
暖木生晖几秒前
引入资源即针对于不同的屏幕尺寸,调用不同的css文件
前端·css·媒体查询
袁煦丞33 分钟前
DS file文件管家远程自由:cpolar内网穿透实验室第492个成功挑战
前端·程序员·远程工作
用户0137412843734 分钟前
九个鲜为人知却极具威力的 CSS 功能:提升前端开发体验的隐藏技巧
前端
永远不打烊38 分钟前
Window环境 WebRTC demo 运行
前端
风舞39 分钟前
一文搞定JS所有类型判断最佳实践
前端·javascript
coding随想39 分钟前
哈希值变化的魔法:深入解析HTML5 hashchange事件的奥秘与实战
前端
一树山茶1 小时前
uniapp在微信小程序中实现 SSE进行通信
前端·javascript
coding随想1 小时前
小程序中的pageshow与pagehide事件,HTML5中也有?揭秘浏览器往返缓存(BFCache)
前端
萌萌哒草头将军1 小时前
Rspack 1.5 版本更新速览!🚀🚀🚀
前端·javascript·vue.js
阿卡不卡1 小时前
基于多场景的通用单位转换功能实现
前端·javascript