
unplugin v3.0.0一发布我就把手头几个插件项目升了一轮。先说结论这次升级不是小打小闹的修修补补类型生成机制直接换了底子构建工具支持列表也扩充了还顺手把之前v2时代几个让我难受的API问题一并收拾了。如果你平时用Vite、webpack、Rollup、Rspack、esbuild做前端开发或者维护过内部组件库、工具库那unplugin这个名字你应该不陌生。这是一套把插件开发统一到同一套API上的工具链核心目标就一句话插件只写一遍构建工具随便换。它由unjs团队维护目前生态里像unplugin-auto-import、unplugin-vue-components这类明星插件都跑在它的底座上。这篇文章我不打算写成官方更新日志的复读机。我会从实际升级和使用的角度把v3.0.0里那些值得关注的改动拆开讲包括为什么社区要在这个时间点做类型生成重构、从v2迁移时我踩到的坑怎么避以及拿新版本写一个真实插件时代码该是什么样。无论你是被组件库按需加载折磨过还是准备给团队封装一套跨构建工具的构建插件这篇都值得你花十分钟读完。1. 构建工具碎片化时代的插件生态困局与unplugin的破局思路1.1 多构建工具并存带来的“插件地狱”先还原一个我经常遇到的场景。假设你要给团队封装一个“自动注入构建信息”的插件——每次打包时往代码里塞入版本号、构建时间、git commit hash。听起来需求很简单吧现实是Vite里你写configureServer和transformIndexHtmlwebpack里你写compiler.hooks.emitRollup里你要用generateBundleesbuild里根本没有真正意义的插件钩子你得用onResolve和onLoad模拟。同一个需求四套API四份实现四份维护成本。更麻烦的是某些构建工具对transform代码的处理方式还不一样。同一种“把Vue文件里的自定义块取出来做处理”的逻辑在不同工具里处理边界完全不同。团队内部如果同时跑着Vite和webpack项目就得养两套插件代码。这就是所谓“插件地狱”。不是某一个工具设计得不好而是工具各有各的哲学Vite追求极致的开发体验webpack将扩展点下沉到编译底层Rollup更关注模块分析的纯粹性esbuild则把性能放在第一位。这种差异本身是健康的但这让“同时支持多种工具”的插件作者付出了巨大代价。1.2 unplugin的核心承诺一套插件API覆盖主流构建工具unplugin的破局思路其实很朴素既然底层工具各说各话那就在上面做一层“翻译层”。它定义了一套构建工具无关的插件API然后分别为Vite、webpack、Rollup、esbuild、Rspack甚至Farm实现适配器把这套API的调用翻译成目标工具能理解的钩子。你写插件时只需要关注“这个文件我该不该处理”“这个模块内容我要怎么转换”“我要不要生成一个新的虚拟模块”这几件事剩下的交给unplugin。用我个人最喜欢的一个类比它就像是一个通用的电源转接头你带着同一个插头到哪个国家都能插上电只是有时候需要一个适配器。unplugin就是那套你已经装好的适配器集合。它提供的核心钩子包括load读取模块内容时拦截适合做虚拟模块或按需加载transform对模块源码进行转换适合做代码注入、语法降级、自定义文件类型解析watchChange监听文件变化可以联动重新生成内容buildEnd构建结束时的清理工作writeBundle产物写出时的处理这套API的设计思路是“最小但够用”。开发者不需要关心某个构建工具是否支持某种细粒度钩子只需要遵守这套抽象API。unplugin内部再来处理兼容性边界。如果某个工具确实无法支持某个特性它会给出明确的降级行为。1.3 unplugin生态里已经跑起来的经典案例聊到这里有人可能会觉得“这又是一个刚起步的小工具”。实际上unplugin已经在前端基建里站稳了脚跟。我们平时常用的按需自动导入解决方案基本都是搭建在它上面的unplugin-auto-import——自动导入Vue、Vue Router、Pinia等API不需要你手动import { ref } from vue。现在可以同时给Vite、webpack、Rollup项目用一套配置到处跑。unplugin-vue-components——按需引入UI组件库不显式import组件模板里写了就自动帮你引入对应组件和样式。这个插件让Element Plus、Ant Design Vue、Vant这类库的按需加载体验提升了一个档次。这两个插件我是一直在生产环境跑的它们对多工具的支持基本是一比一复刻。以前你要在Vite里用unplugin-vue-components跑一套配置在webpack里跑又是另一套写法。现在切工具几乎无感知。这也是为什么v3.0.0这个版本值得单独聊聊——它是unplugin这种“一次编写到处执行”理念的又一次集中兑现。2. v3.0.0升级解析类型生成重建、工具矩阵扩容与破坏性变更清单2.1 从vue-tsc到type-gen类型生成机制彻底重构这次升级里最让我关注的是类型生成机制的替换。v2时代unplugin官方文档里有一项功能叫“TypeScript类型生成”用来给虚拟模块或自动导入的API自动生成.d.ts类型声明文件。过去它依赖vue-tsc来解析Vue组件的信息比如unplugin-vue-components需要知道一个组件用了哪些props它的类型声明就要从这个组件里把SFC的props信息提取出来然后写进独立的d.ts文件里。v2的做法耦合得比较紧如果你不是Vue项目或者没装vue-tsc这套类型生成就不太好用。而且vue-tsc本身体积不小解析速度也一般。v3里官方把这个能力重构为type-gen引入了一套全新的、可嵌入的类型生成通道。它不再把Vue编译器当作前置依赖来使用而是允许插件作者在构建流程里更轻量地生成类型声明。你可以在插件配置里控制是否生成类型输出路径甚至对生成结果做自定义处理。实际体验上最大的差别是项目里不再强制依赖vue-tsc的运行环境类型生成速度也有可感知的提升。尤其对于组件库开发者如果你正在用unplugin-vue-components来给库做按需引入那么升级之后组件props类型提示的生成链路干净了很多。2.2 构建工具支持矩阵的变化vite与webpack仍是主力新面孔值得关注unplugin v3继续维护了Vite、webpack、Rollup、esbuild这几个成熟适配器同时新增了rolldown和farm两个较新的构建引擎适配。Rolldown是Rollup的Rust重写版近年来讨论热度很高它在保持Rollup友好生态的同时追求更高性能。如果你看过Rolldown的路线图会发现它的目标是逐步兼容Vite的大部分插件行为。unplugin提前接入rolldown适配器其实是在为下一阶段的构建性能竞赛做准备。Farm则是一套用Rust编写的前端构建工具主打编译性能同时兼容部分Vite的插件机制。它现在的生态体量还小但如果你有玩新工具的习惯unplugin v3对Farm的支持让插件作者一开始就可以用同一套API快速接入而不必等社区慢慢从零开发适配层。不过从我实际跑过的项目看目前95%以上的使用场景还是Vite和webpack。Rspack我最近也在测它作为webpack的高速替代品在unplugin v3里是继续支持的。你在webpack生态里写的插件迁移到Rspack时unplugin中间层会做不少兼容工作至少不会每一步都踩到API差异。2.3 必须了解的破坏性变更升级前先看清楚这些v3.0.0作为大版本破坏性变更肯定有。我挑几个最容易影响升级路径的说第一插件选项类型向团队分工场景调整。unplugin v2里插件往往以单个配置对象为主到了v3整个PluginOption的类型结构做了重新设计更强调配置的模块化组合。比如某个插件可能同时支持多个不同功能子模块这在类型层面的表达更清晰了但如果你在代码里硬编码了旧配置字段的类型升级时会有编译报错。第二移除transformInclude与transform统一收敛到transform。v2版本里transformInclude用于声明哪些文件需要被transform处理然后在transform里做实际转换。v3直接把这两个概念合并现在统一由transform钩子的返回结果来决定需要处理的范围。对于简单的插件来说代码更简洁了但对于依赖细粒度include/exclude控制的插件需要调整逻辑。第三shared模块的多构建器协作模式有变。如果你写过“同一个插件共享某份数据给多个构建器实例”的逻辑v3里shared模块的内部API有更新更倾向于显式传递共享数据而不是依赖全局单例状态。这块主要影响的是比较高级的插件作者。2.4 围绕v3重新设计的官网与示例集除了代码层面的改动v3还重新做了官方文档站点和示例仓库。示例从简单的“最小插件”一路覆盖到涉及多构建器、类型生成、虚拟模块的高级用例每个示例都标注了测试矩阵vite/rollup/webpack/esbuild等。这个细节对我这种重度依赖“抄官方示例起步”的插件作者来说非常友好。以前找示例需要在不同仓库之间交叉跳转现在unplugin官方集中维护了一套覆盖全场景的示例你在写插件第一步时能直接跑通一个最简版然后再往里加逻辑。3. 从v2到v3迁移过程中踩过的坑与你需要知道的解决方案3.1 升级依赖时的版本对应关系v3发布后依赖unplugin的生态插件也在陆续跟进。我自己先升级了一个内部组件库的按需导入插件基于unplugin-vue-components的二次封装然后又把一个基于unplugin的自动导入工具更新到v3。先上一个版本对应表这是我实际使用后确定的相对稳定组合依赖我的建议版本unplugin^3.0.0vite^5.0.0 或 ^6.0.0webpack^5.0.0rollup^4.0.0esbuild^0.20.0 及以上typescript^5.4.0 及以上需要特别说明的是unplugin v3对Vite插件的适配逻辑内部有调整如果你同时使用了Vite 4和unplugin 3遇到插件钩子不触发的情况优先检查是不是Vite版本太低导致兼容层没走通。我测试下来Vite 5以上比较稳妥。3.2 类型生成器替换删除vue-tsc依赖后需要注意的边界在升级过程中我先把项目中原本为类型生成配置的vue-tsc相关脚本清理掉了。主要指这些地方package.json中的vue-tsc依赖不再需要保留除非你项目本身用vue-tsc做类型检查tsconfig.json里可能存在的vueCompilerOptions配置项如果不是为了Vue语言工具本身的类型检查可以移除如果有自定义的unplugin-vue-components类型生成回调需要按照v3里新的type-gen API重写如果你只是普通用户使用unplugin-auto-import和unplugin-vue-components的v3兼容版本类型生成是插件内部完成的你几乎感觉不到变化。但如果你是插件作者要特别注意校验“生成d.ts的时机”。我遇到过一个小问题在Vite环境下type-gen生成的d.ts文件更新时机稍微延后导致HMR刷新时类型提示偶尔滞后。后来确认是构建工具的watcher触发顺序导致的最终通过调整插件里的生成钩子顺序解决了。如果你做的是纯工具链项目不涉及Vue SFC类型推导那这个迁移基本上是无感的。3.3 插件选项结构升级中的常见报错与排查思路很多从v2直接跳到v3的项目会在启动阶段报出一些类型错误甚至运行时错误。我总结了几条最常见的报错一TypeError: plugin.apply is not a function。这种往往出现在webpack适配层。原因是v3的插件实例化方式和v2不同了旧写法里直接把配置对象作为插件导出新版本要求必须通过unplugin工厂函数生成插件实例。早期的写法比如export default myPlugin // 在v2里你可能这样导出在v3里需要改成像我下面这样显式包装以webpack为例import { createUnplugin } from unplugin const myPlugin createUnplugin((options) ({ name: my-plugin, transform(code, id) { // do something } })) export default myPlugin.webpack其实unplugin一直推荐用工厂模式导出v2时有些插件图省事绕过了这层。v3直接把这个口子堵死了。报错二Cannot read properties of undefined (reading includes)。这个通常来自transformInclude相关的旧代码。我在迁移一个老插件时它原本用transformInclude(id) { return id.includes(.foo) }这样定义转换范围在v3里这段逻辑需要放到transform内部处理否则transform的执行上下文里相关数据未初始化。报错三d.ts文件重复生成或路径错乱。v3的type-gen组件在多个构建工具同时工作时对输出文件的冲突管理比v2严格了。你在webpack和Vite的配置里如果都挂了同一个插件可能出现两个构建器同时写同一个.d.ts文件的情况。此时建议通过插件的选项显式指定一个共享输出目录或者让其中一种构建器关闭类型生成。3.4 迁移验证清单逐一构建工具过一遍才算完迁移不是“npm install一下跑通就行”我个人建议至少按下面清单走一遍用Vite启动开发服务器确认页面能正常打开、组件自动导入生效、模板编译不报错用Vite执行vite build确认生产构建产物正常、无Tree Shaking相关警告用webpack跑一次生产构建重点检查虚拟模块是否被正常解析CSS是否按需注入如果有Rollup场景比如组件库打包执行一次完整打包并检查声明文件输出打开一个用到自动导入API的Vue文件确认IDE类型提示正常且明确指向生成后的d.ts路径我迁移时当时还专门把Rspack跑了一遍。v3的Rspack适配目前质量还行但某些边界API比如自定义VFS模块解析有可能触发Rspack当前实现尚未覆盖的unplugin内部路径遇到这类情况建议用官方issue跟踪一下大多数属于Rspack本身还没完全对齐。4. 用unplugin v3从零编写一个带类型生成的按需加载插件4.1 明确插件目标与设计思路理论讲了不少还是得来点真东西。下面我写一个相对完整的示例插件目标功能是提供一个虚拟模块virtual:project-info自动从项目中读取package.json里的版本信息注入构建时的时间戳给Vue组件或工具函数直接使用并自动生成类型声明。直观一点的场景是你在页面底部展示“版本号构建时间”不用每次发版后手动改注释代码里直接import { version, buildTime } from virtual:project-info就完事。虚拟模块按需引入时不会触碰磁盘上真实存在的文件完全由插件在内存里生成模块内容。像vite里很多内置模块就是这么干的。4.2 项目初始化与插件主逻辑实现先用npm创建一个插件项目初始化TypeScript环境mkdir my-unplugin-info cd my-unplugin-info npm init -y npm install unplugin^3.0.0 typescript^5.4.0 -D npx tsc --init然后编写插件主文件src/index.tsimport { createUnplugin } from unplugin import { readFileSync } from node:fs import { resolve } from node:path import { fileURLToPath } from node:url export interface PluginOptions { /** * package.json 的相对路径默认从项目根目录找 */ cwd?: string /** * 是否生成类型声明文件 * default true */ dts?: boolean | string } const MODULE_ID virtual:project-info function loadProjectInfo(cwd: string) { const pkgPath resolve(cwd, package.json) const pkg JSON.parse(readFileSync(pkgPath, utf-8)) return { name: pkg.name || unknown-project, version: pkg.version || 0.0.0, description: pkg.description || , license: pkg.license || UNLICENSED, buildTime: new Date().toISOString(), } } export const ProjectInfoPlugin createUnpluginPluginOptions | undefined((options {}) { const cwd options.cwd ?? process.cwd() const shouldGenerateDts options.dts ! false return { name: unplugin-project-info, enforce: pre, resolveId(id) { if (id MODULE_ID) { return \0 MODULE_ID } }, load(id) { if (id \0 MODULE_ID) { const info loadProjectInfo(cwd) return { code: export const name ${JSON.stringify(info.name)}; export const version ${JSON.stringify(info.version)}; export const description ${JSON.stringify(info.description)}; export const license ${JSON.stringify(info.license)}; export const buildTime ${JSON.stringify(info.buildTime)}; export default ${JSON.stringify(info)};, map: null, } } }, async buildEnd() { if (!shouldGenerateDts) return // 使用 unplugin 的 type-gen 相关辅助生成独立 d.ts const { generateDts } await import(unplugin/typegen) await generateDts({ moduleId: MODULE_ID, dtsFile: typeof options.dts string ? options.dts : project-info.d.ts, projectInfo: loadProjectInfo(cwd), }) }, } }) export default ProjectInfoPlugin export const vitePlugin ProjectInfoPlugin.vite export const webpackPlugin ProjectInfoPlugin.webpack export const rollupPlugin ProjectInfoPlugin.rollup export const esbuildPlugin ProjectInfoPlugin.esbuild export const rspackPlugin ProjectInfoPlugin.rspack注意我在resolveId里把虚拟模块ID变成了\0 MODULE_ID这个做法是从Rollup的约定里来的——带\0前缀的模块路径表示“这是虚拟模块不要当真实文件去解析”。Vite、webpack的适配层都继承了这个约定。如果不加这个前缀有些构建工具会尝试去文件系统里找这个模块然后报解析失败。4.3 type-gen集成让虚拟模块的类型安全起来上面buildEnd钩子里我引用了unplugin/typegen这是v3的新能力。它的作用是插件在构建结束阶段动态生成一个project-info.d.ts内容大致是declare module virtual:project-info { export const name: string export const version: string export const description: string export const license: string export const buildTime: string const projectInfo: { name: string version: string description: string license: string buildTime: string } export default projectInfo }这样你的项目里tsconfig只需要把生成的d.ts路径包含进来编辑器里所有对virtual:project-info的访问都会有完整的类型推导不用手动写env.d.ts。如果你不需要类型生成直接把dts: false配上去即可我默认开启是为了工程化体验一致。4.4 在Vite和webpack项目中分别接入并测试在Vite项目里接入// vite.config.ts import { defineConfig } from vite import ProjectInfoPlugin from my-unplugin-info export default defineConfig({ plugins: [ProjectInfoPlugin.vite()], })然后在任意.ts或.vue文件里使用import { version, buildTime } from virtual:project-info console.log(当前版本, version) console.log(构建时间, buildTime)启动vite dev或vite build插件会在内存虚拟模块中返回信息并在构建结束后生成project-info.d.ts。在webpack项目里接入时稍微注意一下配置方式// webpack.config.js const { webpackPlugin } require(my-unplugin-info) module.exports { plugins: [webpackPlugin()], // 因为虚拟模块是 \0 开头的尽量别忘了把 \0 前缀模块排除在真实解析之外 // 多数情况下 unplugin 适配层会自动处理不需要手动配置 }在webpack 5里测试时发现如果项目的resolve.alias配置了拦截规则要小心它是否误伤virtual:开头的模块。我在某个老项目里曾因为alias配置里写了virtual: false导致插件生成的模块无法解析排查了半天。最后把alias范围收紧即可。4.5 发布插件时的版本与发布前自检清单插件写完后如果要发布到npm还有几点建议在package.json的exports字段里显式声明类型入口避免使用方拿不到.d.ts发布前用npm publish --dry-run确认dist/目录和类型文件是否完整在README里明确列出支持的构建工具版本矩阵减少使用方踩版本兼容坑建议在test/目录里放一份最小Vite项目和最小webpack项目的fixtures用vitest或jest做冒烟测试确保每次改动不会破坏多工具兼容我后来用这套示例插件跑完了Vite、webpack、Rollup三条链路又顺手在Rspack上验证了基础解析。整体下来v3的适配层比v2更稳定工具切换时基本没出现“同一个插件在A工具正常、在B工具静默失效”的隐形问题。5. v3.0.0发布背后的生态信号与我的插件开发选型建议5.1 为什么说unplugin是前端基建的“工具箱化”信号如果你观察过这几年前端工具链的演进会发现一个趋势大家不再追求单一工具解决所有问题而是把大而全的工具拆成可插拔的小件。Vite靠插件化走到了今天Rspack用webpack兼容降低了迁移成本Rolldown又试图在Rollup的语义上做极致性能。这些工具彼此之间有的是竞争关系有的是继承关系但它们共享同一个需求——插件生态必须跟着走。unplugin v3把“多工具适配”这件事推进得更深了。它不止是给Vite webpack做个适配而是把适配层做成了可预测、带类型保障的公共层。前端基建的工具箱化在这里体现得格外明显。5.2 什么场景值得用unplugin什么场景没必要我这里也给一个相对务实的建议值得用unplugin的场景插件需要同时支持Vite和webpack或者未来可能迁移到Rspack插件的核心逻辑与构建工具的API强绑定较少主要做“模块解析、内容转换、虚拟模块注入”这一类事你维护的是通用工具型插件自动导入、组件解析、自定义文件类型支持希望尽可能让更多人无障碍使用没必要用unplugin的场景插件只为某个工具的特定生命周期写死逻辑比如webpack的某些compiler钩子Vite的configureServer项目是纯静态站点或轻量脚本没有复杂插件需求深度使用某个构建工具的原生特性适配层带来的抽象反而碍手碍脚5.3 从v3出发的后续扩展思路如果你对unplugin v3感兴趣我建议按这个路径去试先用官方基础示例跑通一个“helloworld”转换插件再试一次虚拟模块插件的写法体会\0前缀的约束结合unplugin-auto-import的源码读一读它是最能体现unplugin能力的真实案例尝试给自己的插件加上type-gen能力体验一把v3的新特性另外可以关注rolldown适配器后续的更新节奏。等到Rolldown大范围可用时unplugin这套API大概率会成为Vite生态插件在Rolldown上复用的主通道。提前用v3的抽象API写插件相当于给未来的构建工具切换上了一份保险。就我个人而言我在生产环境已经用unplugin v3替换了原来的两个内部插件目前运行稳定。如果你正在做通用的前端构建期插件或者维护组件库需要一套跨构建工具的按需加载方案v3值得现在就动手试试。迁移成本其实不大收获得却很明确。