Vite 8 在 2026 年 3 月发布稳定版,把底层的 esbuild 加 Rollup 双打包器换成了单一的 Rolldown。官方在公告里专门说了一件事:插件 API 保持 Rollup 式钩子,绝大多数现有插件开箱即用。
这句话让很多人松了一口气,顺手就把 "Vite 插件不用改了" 当成了结论。真去翻官方插件文档会发现另一个变化:钩子的推荐写法已经改了,从 if (!id.endsWith(...)) return 换成了 filter + handler 的对象形式。老写法还能跑,新写法在 Vite 8 下能让 Rolldown 在 Rust 侧提前过滤,不用每个模块都叫醒一次 JS。
这篇不重复入门步骤,只讲三个大部分教程默认你懂、但通常没人明说的点。它们单独拿出来都不大,凑在一起就是教程和真实项目之间的那段距离。
第一个坑:虚拟模块只认识"它见过的文件"
教程里讲虚拟模块,通常是给你一段这样的代码:
export function notePlugin() {
const publicId = 'virtual:notes'
const resolvedId = '\0' + publicId
const notes = new Map()
return {
name: 'vite-plugin-note',
resolveId(id) {
if (id === publicId) return resolvedId
},
load(id) {
if (id === resolvedId) {
return `export default ${JSON.stringify(Array.from(notes.values()))}`
}
},
transform(source, id) {
if (!id.endsWith('.note')) return
const note = parseNote(source)
notes.set(id, note)
return { code: `export default ${JSON.stringify(note)}`, map: null }
},
}
}跑起来一切正常。你在 src/ 里加一个 welcome.note,浏览器里 import notes from 'virtual:notes' 能拿到数据,HMR 也工作。
然后你新建第二个文件 about.note,没去 import 它,只是在编辑器里改了几行,刷新页面,virtual:notes 里就是没有这条。
到这一步,常见的反应是清 node_modules/.vite、重启 dev server,折腾一圈才反应过来缓存是冤枉的,真正的原因藏在这个 Map 的填充时机里。notes.set(id, note) 写在 transform 钩子里,所以只有被 Vite 真正 import 过的 .note 文件,才会进入这个 Map。没被 import 过的文件,Vite 压根不会去 transform 它,你的虚拟模块当然也不认识它。
重启之后 Map 是空的,要等第一次有人 import 才会被填上。这个行为在开发早期文件少的时候不明显,一旦项目里有几十个 .note 文件,靠 import 驱动填充的索引就和真实目录状态脱节了。
教程默认你知道这一点,但很少明说。能搜到的中文入门里,大多数到这里就开始讲"加上 HMR 就完美了"。
真要做到"虚拟模块反映目录当前状态",得在插件启动时扫一遍目录,再用 chokidar 或者 Vite 自己的 server.watcher 监听增删改,文件变化时主动调用 server.moduleGraph.invalidateModule 把这个虚拟模块失效掉。这一套写下来比 transform 钩子本身长得多。
我的判断是:如果只是想给单个文件做格式转换,别碰虚拟模块。真要维护一份内容索引,先想清楚能不能接受"索引只覆盖被 import 过的文件",不能接受再上目录扫描。这两条路的复杂度不在一个量级。

第二个坑:dev 跑得通,build 不一定跑得通
Vite 的插件在 dev 和 build 两个命令下都会执行,这一点官方文档写得很清楚。不清楚的是两个环境下钩子的触发条件并不完全一样。
最典型的一个差异在 transform 钩子的过滤逻辑。dev 环境下,Vite 处理 import 时会在模块 id 后面带 query string,比如 welcome.note?t=1722345678901,用于 HMR 缓存失效。如果你的过滤条件是这样:
transform(source, id) {
if (!id.endsWith('.note')) return
// ...
}dev 下这个 endsWith 永远返回 false,因为 id 末尾永远跟着 ?t=...。但这个 bug 在 dev 服务器启动第一次时不一定会暴露,因为第一个版本可能没 query string,等触发 HMR 才出现。到 vite build 时,模块 id 又是干净的,一切正常。同一个插件在 dev 下偶发不工作、build 下完全正常,你很难把这两件事联系到一起。
稳妥的写法是先剥 query:
transform(source, id) {
const cleanId = id.split('?')[0]
if (!cleanId.endsWith('.note')) return
// ...
}另一个 dev 和 build 的差异在 apply 字段。有些逻辑你只想在 build 时跑,比如生成一份额外的产物文件。这种时候别靠 if (process.env.NODE_ENV === 'production') 这种环境变量判断,用插件自己的字段:
{
name: 'vite-plugin-note',
apply: 'build',
}或者在 configResolved 钩子里把 config.command 存下来,后面所有判断都用它。这两种写法在 dev 和 build 下行为一致,不依赖外部环境。
只要插件里有任何"我只想在某个环境跑"的逻辑,就用 apply 或 configResolved 显式表达,别写隐式判断。隐式判断埋在代码里,某次依赖升级之后以什么方式翻车,很难提前预料。
第三个坑:Vite 8 推荐的钩子写法变了
前两个坑是 Vite 插件一直以来的坑,第三个和 Vite 8 换 bundler 直接相关。
Vite 8 的底层换成了 Rolldown,一个用 Rust 写的打包器。官方文档里插件 API 的示例已经换成了这种形式:
{
name: 'vite-plugin-note',
transform: {
filter: {
id: /\.note$/,
},
handler(source, id) {
const note = parseNote(source)
return { code: `export default ${JSON.stringify(note)}`, map: null }
},
},
}跟老写法的区别在于过滤逻辑从 handler 函数体里被提到了 filter 字段。这个改动不是风格洁癖。Rolldown 在 Rust 侧做模块解析和调度,如果过滤条件能在 Rust 侧提前算好,就不用为了每个模块都跨语言叫醒一次 JS handler。项目小的时候没差别,项目里几千个模块的时候,每个钩子少一次 JS 调用,攒起来是能感知的。
老写法 transform(source, id) { if (...) return ... } 在 Vite 8 里仍然兼容,Rolldown 会把它当成一个不过滤的 handler 来跑,只是在 JS 里 return undefined。所以"我的插件在 Vite 8 下还能跑"和"我的插件在 Vite 8 下是按推荐方式写的"是两件事。
类似地,resolveId 和 load 也支持同样的 filter + handler 形式。虚拟模块的写法在官方文档里已经变成:
import { exactRegex } from '@rolldown/pluginutils'
resolveId: {
filter: { id: exactRegex('virtual:notes') },
handler() {
return '\0virtual:notes'
},
},exactRegex 是 @rolldown/pluginutils 提供的辅助函数,用来生成精确匹配的正则,避免手写正则把 \0 这种特殊字符搞错。
现有插件要不要立刻改,看两种情况。自己项目里内联的小插件,下次动到的时候顺手改了,不值得专门安排一次迁移。发布到 npm 给外部用的插件,尽早改,并且在 README 里说明支持的 Vite 版本。钩子过滤是 Rolldown 引入的,Rollup 4.38 和 Vite 6.3 起也支持,还在用 Vite 5 及更早版本的用户,拿到只写了 filter 的插件会跑不起来。官方文档因此建议 handler 里保留同样的过滤判断,开头先跑一次正则,命中不了就返回 null,老版本照样能走。
插件测试别只测 parser
写完插件大多数人会顺手给 parse 函数补几个单元测试,就当作测过了。这种做法漏掉了插件最容易出问题的地方:钩子和 Vite 内部的配合。
Vite 提供了一个程序化 API,可以在测试里直接起一个 dev server,然后用 server.transformRequest 触发一次真实的 transform:
import { afterAll, describe, expect, test } from 'vitest'
import { createServer } from 'vite'
import { notePlugin } from './vite-plugin-note.js'
let server
afterAll(async () => {
await server?.close()
})
describe('notePlugin', () => {
test('transforms a note into a module', async () => {
server = await createServer({
logLevel: 'silent',
plugins: [notePlugin()],
server: { middlewareMode: true },
})
const result = await server.transformRequest('/src/welcome.note')
expect(result.code).toContain('"title":"Hello Vite"')
})
})这个测试跑的是 Vite 真实的插件管线,resolveId、load、transform 三个钩子的配合,以及 Vite 内部的模块图、缓存、id 规范化,都会在这一次调用里被真实触发。自己 mock 出来的钩子调用,覆盖不到这一层。
CI 里再补一个最小 fixture 项目,跑一次 vite build。dev 和 build 不一致的那类问题,只有在真的 build 一次的时候才会暴露。vite-plugin-inspect 也值得装上,它能告诉你某个模块到底被哪些插件按什么顺序 transform 过,调顺序问题的时候省很多时间。
最后
Vite 8 把打包器换成 Rolldown,对外说的是"插件 API 不变"。这句话在兼容性层面是对的,在推荐写法层面不全面。
虚拟模块只认识被 import 过的文件,dev 和 build 的钩子触发条件有差异,filter 写法成了官方推荐、老写法仍然兼容。这三件事任何一件单独看都不大,凑在一起,就是教程没写的那部分。
教程负责让你能跑,剩下的部分得自己趟一遍。
#Vite #前端工程化 #构建工具
夜雨聆风