续集:Vite 字体插件重构之路 ------ 从"能用"到"生产级稳定"
前言 : 在上一篇文章《把 16MB 中文字体压到 400KB:我写了一个 Vite 字体子集插件》中,我分享了如何通过自研插件
@fe-fast/vite-plugin-font-subset解决中文字体体积过大的问题。文章发出后,不少朋友试用并反馈了意见。更重要的是,在实际生产环境的复杂构建链路中,我发现 v0.1 版本存在一些设计上的"硬伤"。
开源不仅仅是发布代码,更是一个持续迭代的过程。今天这篇"续集",就来聊聊这次 v0.3.x 重构背后的故事:如何解决路径 404 问题、生命周期竞态,以及如何更优雅地接入 Vite 构建流。
一、遇到的问题:本地跑得欢,上线 404
在 v0.1 版本中,我的实现逻辑非常简单粗暴:
- 插件运行,调用
subset-font生成.woff2文件。 - 使用
fs.writeFileSync直接把文件写到src/assets/fonts/subset目录下。 - 生成一个
font.css,里面写着src: url('./subset/xxx.woff2')。 - 用户手动引入这个 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):
javascript
// ❌ 坏味道:直接操作磁盘,脱离构建流
fs.writeFileSync(outputPath, subsetBuffer);
After (v0.3):
javascript
// ✅ 最佳实践:告诉构建工具"我有一个文件要打包"
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.ts 里 import './subset/font.css'。 现在,得益于对 transformIndexHtml 的利用,插件默认开启 injectCss: true。
用户什么都不用做 ,构建完成后,index.html 里会自动多出一行:
html
<link rel="stylesheet" href="/assets/fonts/font-a1b2c3d4.css">
这不仅省事,更重要的是路径绝对正确 。插件会根据 Vite 配置的 base 自动拼接路径,无论你部署在根目录还是子目录,都能完美加载。
三、代码细节:一个小 Bug 的教训
在 v0.3.1 的迭代中,我还修复了一个变量作用域的小 Bug。
Bug 现场:
javascript
// ❌ 错误代码
const result = await processFont(...)
// 这里试图解构 fontPath,但 processFont 返回值里漏传了这个字段
const { fontPath } = result
console.log(path.basename(fontPath)) // -> undefined
修复与优化: 在 v0.3.3 中,我不仅修复了变量传递,还反思了一下:为什么需要传递这个变量? 其实 cssEntry 对象里已经包含了 relativePath,完全可以通过它推导出文件名。于是我删除了冗余的 fontPath 返回值,让数据流更清晰。
javascript
// ✅ 优化后:减少冗余数据传递
const { cssEntry } = result
// 直接从已有数据推导
const fileName = path.basename(cssEntry.relativePath)
这提醒我们:重构不仅是改架构,更是清理冗余逻辑的好时机。
四、v0.3.x 版本的新特性总结
经过这次"伤筋动骨"的重构,@fe-fast/vite-plugin-font-subset 现在具备了生产级的能力:
- 零配置自动注入:默认自动生成 CSS 并注入 HTML,无需手动 import。
- 构建产物纯净 :不再污染
src源码目录,所有产物直接进入dist。 - 路径安全 :完美支持 Vite 的
base配置,支持 CDN 部署路径。 - Hash 缓存友好:生成的文件名带有内容 Hash,利于浏览器长效缓存。
五、写在最后
从 v0.1 到 v0.3,代码量增加了不少,但使用者的心智负担却降低了。
做开源项目往往就是这样:把复杂留给自己,把简单留给用户。 最初只是为了解决自己项目的 16MB 字体问题,现在它已经变成了一个更加健壮的通用解决方案。
如果你也在使用 Vite 开发中文项目,深受字体体积困扰,欢迎尝试一下这个插件,也欢迎在 GitHub 上提 Issue 或 PR,我们一起把它打磨得更好。
GitHub : github.com/william-xue... NPM :
npm install @fe-fast/vite-plugin-font-subset -D
下一阶段计划: 目前插件主要针对 Build 阶段优化。接下来我可能会探索一下如何在 Dev 开发阶段提供更好的体验(比如利用缓存避免重复构建),敬请期待!