为什么 Vite 插件值得自己写
用过 Vite 的团队大概都有同感:大多数需求靠官方配置就能解决,但一旦遇到“导入 YAML 文件”“开发环境动态注入配置”这类场景,就需要自己动手写插件。Vite 插件本质上是带有生命周期钩子的对象,理解了这些钩子的触发时机,就掌握了 Vite 扩展能力的钥匙。

这篇文章从一个实际需求讲起:团队需要把 site.config.yaml 直接作为模块导入,并希望在文件修改后页面自动更新。初看很简单,但完整实现会涉及 transform、configureServer、虚拟模块和 HMR 协同,正好覆盖 Vite 插件开发的主干路径。
插件的最小骨架
Vite 插件是一个函数,返回包含 name 和钩子的对象。下面是一个处理 YAML 文件的最简插件:
export default function yamlPlugin() {
return {
name: 'vite-plugin-yaml',
transform(code, id) {
if (id.endsWith('.yaml')) {
const json = JSON.stringify({ content: code })
return {
code: `export default ${json}`,
map: null
}
}
}
}
}
这里的 transform 钩子会在模块代码被用到之前执行,适合做格式转换。需要注意的是,插件名最好带上项目名或用途前缀,方便调试时定位。
当需求只涉及单一文件格式时,这个骨架已经够用。但真实的插件往往还需要修改开发服务器行为,这就引出了自定义中间件。
用 configureServer 注册中间件
开发环境中,很多改动无法通过 transform 实现,比如拦截一个接口请求,返回临时数据。Vite 在创建 dev server 时会调用 configureServer 钩子,我们可以在返回的函数中拿到标准的 connect 中间件签名:
configureServer(server) {
return (req, res, next) => {
if (req.url === '/api/config') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ready: true }))
return
}
next()
}
}
这段代码会在请求进入 Vite 内部中间件之前执行,适用于接口 mock、统一鉴权、代理转发等场景。有几个关键点:
- 必须对不处理的请求调用
next(),否则会阻塞后续流程。 - 如果希望中间件在 Vite 内部中间件之后执行,需要先调用
server.middlewares.use(),再返回函数。具体顺序取决于你在configureServer里怎么注册。 - 生产环境的 preview server 不支持
configureServer,如果也要在预览模式下拦截请求,应该使用configurePreviewServer。
到这里,我们已经能在 dev server 上做很多事。但下一个问题是:如何处理动态配置和 HMR 的关系?
虚拟模块与 HMR:让动态数据也能热更新
回到开头的 YAML 导入需求。如果只在 transform 里转格式,文件修改后模块不会自动更新,因为 Vite 的模块图并不知道这个 YAML 文件对应哪个模块。想要实现真正的 HMR,通常需要结合虚拟模块和 handleHotUpdate。
虚拟模块是指并非真实存在于文件系统的模块。下面的代码创建了一个 virtual:site-config 模块,内容来自 site.config.json:
const virtualId = 'virtual:site-config'
const resolvedId = '\0' + virtualId
function siteConfigPlugin() {
let siteConfig = {}
return {
name: 'vite-plugin-site-config',
resolveId(id) {
if (id === virtualId) return resolvedId
},
load(id) {
if (id === resolvedId) {
return `export default ${JSON.stringify(siteConfig)}`
}
},
configureServer(server) {
const file = server.config.root + '/site.config.json'
const update = () => {
siteConfig = JSON.parse(fs.readFileSync(file, 'utf-8'))
server.hot.send('site-config:changed', siteConfig)
}
fs.watch(file, update)
update()
}
}
}
在这个例子里,resolveId 负责把虚拟标识符映射为带 \0 前缀的内部 id,load 返回模块实际内容。使用 \0 是 Vite 的约定,用来避免虚拟模块被 Node 的解析器误处理。
客户端代码可以这样接收更新:
import siteConfig from 'virtual:site-config'
if (import.meta.hot) {
import.meta.hot.on('site-config:changed', (newConfig) => {
// 将 newConfig 应用到当前页面
updateView(newConfig)
})
}
这里用 server.hot.send 推送自定义事件,客户端用 import.meta.hot.on 监听,形成一条完整的 HMR 链路。但这样写还有隐患:配置文件变化后,如果还有模块通过 import 引用了 site.config.json,Vite 的模块图不会自动失效,必须借助 handleHotUpdate 主动处理。
handleHotUpdate:精确控制模块更新
handleHotUpdate 钩子在文件发生变更时触发,你可以决定哪些模块需要被更新。一个常见的需求是:当修改的是 JSON 文件时,不是让页面整体 reload,而是只让相关虚拟模块更新。
async handleHotUpdate(ctx) {
if (ctx.file.endsWith('site.config.json')) {
const mod = ctx.server.moduleGraph.getModuleById(resolvedId)
if (!mod) return []
ctx.server.moduleGraph.invalidateModule(mod)
return [mod]
}
return []
}
这里,返回的模块数组代表需要重新执行的模块,返回空数组表示完全不更新。注意需要先调用 invalidateModule 清除模块缓存,否则 Vite 可能从缓存中返回旧代码。
虚拟模块的更新与普通模块不同,客户端还需要配合 import.meta.hot.accept 才能接受替换。完整逻辑可以这样写:
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 用新模块更新数据源
})
}
到这里,自定义事件和模块热替换是两套互补的机制:事件适合不要求模块自身替换的轻量通知,accept 适合需要重新加载模块的场景。
常见误区与排查思路
在写插件时,以下几个错误很容易踩到:
- 忘记处理
enforce和apply。前者控制与其他插件之间的顺序,后者控制环境。写插件时建议明确声明,尤其在打包阶段。 - 忽略虚拟模块 id 的
\0前缀。如果返回的 id 不是以\0开头,Vite 可能会尝试从文件系统加载,运行时报错。 - 没有调用
next()。中间件不调用 next 会导致请求挂起,排查时表现为页面一直没有响应。 - 混淆
configureServer和configurePreviewServer,导致开发环境正常的插件在本地预览时失效。 - HMR 事件缺少对应监听。插件发送了事件,但业务侧没有用
import.meta.hot.on监听,功能自然不生效,而且不会报错。
遇到问题时,建议先用 vite --debug 启动开发服务器,观察钩子调用和模块更新日志,这比直接加 console.log 快很多。
选型对比:钩子、中间件还是虚拟模块?
很多插件功能存在多种实现路径,选择哪一种取决于目标和使用阶段。下面是一个简单的参考:
| 实现方式 | 典型场景 | 复杂度 |
|---|---|---|
| transform 钩子 | 转译自定义文件格式 | 低 |
| configureServer 中间件 | 请求拦截、接口 mock、代理 | 中 |
| 虚拟模块 + HMR | 动态配置、运行时数据模块 | 中高 |
如果只是处理文件格式,先写 transform 就好。一旦涉及请求级控制,就要进入中间件。需要模块热更新时,再考虑虚拟模块和 HMR 配合。
落地建议
- 从最小插件开始,先写一个能处理单一文件的 transform 钩子,建立调试手段。
- 使用
defineConfig配合 TypeScript 的 Plugin 类型,减少配置错误。 - 把插件中的文件 I/O、状态逻辑与钩子解耦,方便测试和后续演进。
- 为插件编写自动化测试,使用 Vite 的
createServerAPI 在临时目录中启动真实实例。
最后,任何插件都要考虑开发环境与生产构建的差异。HMR 相关的钩子只影响 dev 流程,build 时不会执行;反之,如果希望插件在 build 时也起作用,要确保 transform 等钩子不带 dev 专属逻辑。
小结
Vite 插件开发的核心不是记忆 API,而是理解模块生命周期。从简单的 transform 到 configureServer,再到虚拟模块和 HMR,每一步都是自然演进的。你不需要一开始就掌握全部钩子,只要先解决一个具体问题,然后在解决过程中逐渐补全知识拼图就够了。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/687/