续集:Vite 字体插件重构之路 —— 从“能用”到“生产级稳定”

一、遇到的问题:本地跑得欢,上线 404

在 v0.1 版本中,我的实现逻辑非常简单粗暴:

  1. 插件运行,调用 subset-font 生成 .woff2 文件。
  2. 使用 fs.writeFileSync 直接把文件写到 src/assets/fonts/subset 目录下。
  3. 生成一个 font.css,里面写着 src: url('./subset/xxx.woff2')
  4. 用户手动引入这个 CSS。

看起来没问题?但在真实项目中,这个方案有两个致命缺陷:

1. 路径引用的“相对论”陷阱

当我们在开发环境(vite dev)时,文件都在本地磁盘上,相对路径 url('./subset/...') 能正常工作。 但在 vite build 生产构建时,Vite 会把 CSS 内联到 HTML 中,或者打包成独立的 CSS 文件放在 assets 目录深处。 一旦目录结构发生变化(例如设置了 base: '/app/'),原本写死的相对路径就会失效,导致浏览器报 404 Not Found

2. “脏”文件与构建副作用

直接用 fs 模块往 src 目录写文件,是一种“副作用”很强的做法:

  • 这些生成的临时文件会被 Git 识别,污染工作区。
  • 它们没有经过 Vite 的资源处理管道(Asset Pipeline),导致文件名没有 Hash(缓存问题),也不会出现在 manifest.json 中。

二、重构核心:拥抱 Vite/Rollup 标准流

为了解决上述问题,我决定对插件进行彻底重构。核心目标是:不再手动写磁盘,而是把资源“交给” Vite 处理。

1. 弃用 fs.write,改用 emitFile

Vite(基于 Rollup)提供了一个标准的 API this.emitFile,专门用于在构建过程中发射文件。

Before (v0.1):

//  坏味道:直接操作磁盘,脱离构建流
fs.writeFileSync(outputPath, subsetBuffer);

After (v0.3):

//  最佳实践:告诉构建工具“我有一个文件要打包”
this.emitFile({
  type: 'asset',
  fileName: 'assets/fonts/my-font.woff2',
  source: subsetBuffer
});

这样做的好处是,Vite 会自动处理这些文件,把它们放到正确的 dist 目录,并且我们可以利用 Vite 的机制来处理路径。

2. 解决生命周期的“竞态问题”

在重构过程中,我踩了一个深坑:生命周期执行顺序

我最初想在 transformIndexHtml 钩子(处理 HTML 时)去注入 CSS 标签。但是,字体子集化是一个耗时操作(CPU 密集型),如果放在错误的钩子执行,会导致 HTML 已经处理完了,字体还没生成好。

最终的架构方案:

  • buildStart 阶段: 这是构建开始的最早阶段。我在这里执行最耗时的“字体子集化”和“字符扫描”工作。计算出所有的 Hash 文件名,准备好要发射的数据。 为什么在这里? 确保后续任何钩子执行时,数据都已经准备就绪。

  • generateBundle 阶段: 在这里统一调用 emitFile 发射所有字体文件和生成的 CSS 文件。

  • transformIndexHtml 阶段: 因为在 buildStart 阶段我们已经计算好了最终的文件名(包含 Hash),所以在这里可以直接生成 <link rel="stylesheet"> 标签并注入到 HTML 的 <head> 中。

3. 自动注入:从“手动挡”变“自动挡”

v0.1 版本需要用户手动在 main.tsimport './subset/font.css'。 现在,得益于对 transformIndexHtml 的利用,插件默认开启 injectCss: true

用户什么都不用做,构建完成后,index.html 里会自动多出一行:

<link rel="stylesheet" href="/assets/fonts/font-a1b2c3d4.css">

这不仅省事,更重要的是路径绝对正确。插件会根据 Vite 配置的 base 自动拼接路径,无论你部署在根目录还是子目录,都能完美加载。

三、代码细节:一个小 Bug 的教训

在 v0.3.1 的迭代中,我还修复了一个变量作用域的小 Bug。

Bug 现场:

//  错误代码
const result = await processFont(...)
// 这里试图解构 fontPath,但 processFont 返回值里漏传了这个字段
const { fontPath } = result 
console.log(path.basename(fontPath)) // -> undefined

修复与优化: 在 v0.3.3 中,我不仅修复了变量传递,还反思了一下:为什么需要传递这个变量? 其实 cssEntry 对象里已经包含了 relativePath,完全可以通过它推导出文件名。于是我删除了冗余的 fontPath 返回值,让数据流更清晰。

//  优化后:减少冗余数据传递
const { cssEntry } = result
// 直接从已有数据推导
const fileName = path.basename(cssEntry.relativePath)

这提醒我们:重构不仅是改架构,更是清理冗余逻辑的好时机。

四、v0.3.x 版本的新特性总结

经过这次“伤筋动骨”的重构,@fe-fast/vite-plugin-font-subset 现在具备了生产级的能力:

  1. 零配置自动注入:默认自动生成 CSS 并注入 HTML,无需手动 import。
  2. 构建产物纯净:不再污染 src 源码目录,所有产物直接进入 dist
  3. 路径安全:完美支持 Vite 的 base 配置,支持 CDN 部署路径。
  4. Hash 缓存友好:生成的文件名带有内容 Hash,利于浏览器长效缓存。

五、写在最后

从 v0.1 到 v0.3,代码量增加了不少,但使用者的心智负担却降低了。

做开源项目往往就是这样:把复杂留给自己,把简单留给用户。 最初只是为了解决自己项目的 16MB 字体问题,现在它已经变成了一个更加健壮的通用解决方案。

如果你也在使用 Vite 开发中文项目,深受字体体积困扰,欢迎尝试一下这个插件,也欢迎在 GitHub 上提 Issue 或 PR,我们一起把它打磨得更好。


下一阶段计划: 目前插件主要针对 Build 阶段优化。接下来我可能会探索一下如何在 Dev 开发阶段提供更好的体验(比如利用缓存避免重复构建),敬请期待!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]