Vite 插件开发与构建生命周期:从 Hook 时序到产物干预的工程化实践
Vite 的插件系统继承自 Rollup 并做了增强。理解 Hook 的触发时机和返回值的副作用,是写出可控、可复用插件的基础。本文围绕几个工程化常见场景展开。
一、插件骨架与 Hook 分类
// vite-plugin-demo.js
export default function demo(options = {}) {
return {
name: 'vite-plugin-demo', // 必填,调试与错误定位
enforce: 'pre', // 'pre' | 'post' | 默认
apply: 'build', // 'build' | 'serve' | 函数
// Vite 专属
config(config, env) {},
configResolved(resolved) {},
configureServer(server) {},
transformIndexHtml(html) {},
handleHotUpdate(ctx) {},
// Rollup 通用
resolveId(source, importer) {},
load(id) {},
transform(code, id) {},
buildStart() {},
generateBundle(opts, bundle) {},
closeBundle() {}
}
}
Hook 分两类:Vite 专属只在 dev/构建配置阶段触发;Rollup 通用在 dev 与 build 中都会跑,但 dev 下是按需触发(请求驱动),build 下是图遍历驱动。
二、enforce 与执行顺序
插件执行顺序:Alias → enforce: 'pre' → 默认 → Vite 核心 → enforce: 'post' → Vite 后置。
反例:写一个语法转换插件没设置 enforce: 'pre',结果在 Vite 内置 esbuild 转换之后才执行,源码已经被改写,正则匹配失败。
// ❌ 错误:默认顺序,处理时机滞后
export default () => ({
name: 'my-transform',
transform(code, id) {
if (!id.endsWith('.ts')) return
return code.replace(/__VERSION__/g, '1.0.0')
}
})
// ✅ 正确:pre 提前,源码替换
export default () => ({
name: 'my-transform',
enforce: 'pre',
transform(code, id) {
if (!/\.[jt]sx?$/.test(id)) return
return { code: code.replace(/__VERSION__/g, '1.0.0'), map: null }
}
})
三、虚拟模块:替代全局注入
业务里常见用 define 注入常量,但当注入对象较大时会全量替换字符串,污染产物。改用虚拟模块更干净。
export default function virtualEnv(env) {
const virtualId = 'virtual:app-env'
const resolvedId = '\0' + virtualId // \0 前缀避免被其他插件处理
return {
name: 'virtual-env',
resolveId(id) {
if (id === virtualId) return resolvedId
},
load(id) {
if (id === resolvedId) {
return `export default ${JSON.stringify(env)}`
}
}
}
}
// 使用
// import env from 'virtual:app-env'
// console.log(env.API_BASE)
配套 TS 声明:
// env.d.ts
declare module 'virtual:app-env' {
const env: { API_BASE: string; VERSION: string }
export default env
}
四、configureServer:注入开发中间件
开发期想 mock 接口,不必引第三方库:
export default function mock() {
return {
name: 'dev-mock',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/api/user', (req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ id: 1, name: 'Tom' }))
})
}
}
}
注意:configureServer 返回函数可获得 post hook,在内置中间件之后执行——通常用于兜底 404 处理。
configureServer(server) {
return () => {
server.middlewares.use((req, res, next) => {
// 所有内置中间件之后
next()
})
}
}
五、handleHotUpdate:自定义 HMR 边界
默认 HMR 对未知文件会触发整页刷新。处理自定义 DSL 时需手动控制:
handleHotUpdate(ctx) {
if (!ctx.file.endsWith('.my')) return
// 只让引用该模块的页面热更新,不刷新
ctx.server.ws.send({
type: 'custom',
event: 'my-dsl-update',
data: { path: ctx.file }
})
return [] // 返回空数组阻止默认行为
}
客户端监听:
if (import.meta.hot) {
import.meta.hot.on('my-dsl-update', ({ path }) => {
reloadDSL(path)
})
}
六、generateBundle:产物干预
构建结束前可以读写所有产物。例如生成资源清单:
export default function manifest() {
return {
name: 'asset-manifest',
apply: 'build',
generateBundle(_opts, bundle) {
const map = {}
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.isEntry) {
map[chunk.name] = fileName
}
}
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: JSON.stringify(map, null, 2)
})
}
}
}
反例:在 closeBundle 里通过 fs.writeFileSync 写文件——会绕过 Vite 的输出目录配置,且不会进入 bundle 对象,下游插件无法处理。优先用 emitFile。
七、apply 与环境隔离
Mock、可视化分析、Source Map 上传等不应该污染另一端:
export default defineConfig({
plugins: [
mock(), // 内部已 apply: 'serve'
visualizer({ open: false }), // 仅 build
{
...sentryUpload(),
apply(_cfg, { command, mode }) {
return command === 'build' && mode === 'production'
}
}
]
})
小结
- 涉及源码改写优先
enforce: 'pre',分析类放'post' - 常量、配置走虚拟模块而非
define全量替换 - 开发态用
configureServer,HMR 自定义返回[]阻断默认刷新 - 构建产物干预用
generateBundle+emitFile,避免直接写盘 - 用
apply严格隔离 dev/build 插件
把 Hook 时序吃透,插件就从“能跑”进化为“可控”。
0 评论
评论区
登录 后参与评论