“开发一个 Vite 插件”这件事听起来像是资深构建工具玩家才会碰的领域但实际上它是前端工程化里最被低估的进阶练习。很多团队用了 Vite 一两年中途自定义需求全靠攒一堆 Unplugin、Rollup 插件拼装解决结果一旦遇到跟服务端钩子、虚拟模块、环境变量注入相关的需求就只能满城找轮子。与其绕来绕去不如自己上手把插件机制吃透。我在几个中型项目里把 Vite 从 Rollup 体系迁移过来之后最大的体会是Vite 插件并不是独立的黑科技它本质上是 Rollup 插件规范的能力超集。只要把钩子时序、环境边界、虚拟模块这几个骨架搭明白之后的扩展无非是往里面填充业务逻辑。这篇文章我会从一个真实可跑的插件例子出发拆解创建 Vite 插件过程中的核心概念同时把热词里高频出现的几个问题比如”process is not defined”、打包太慢、Buffer 识别不了跟插件机制串起来讲希望能帮正在啃 Vite 源码或者准备自己封装工具链的朋友少走一点弯路。1. Vite 插件系统的设计边界先搞清楚它和 Rollup 的区别1.1 插件钩子的两套坐标系第一次接触 Vite 插件时我最迷惑的地方在于文档里 Rollup 钩子和 Vite 独有钩子混杂在一起。实际上可以把整个插件体系拆成两套坐标系来理解。第一套是构建期钩子继承自 Rollup处理模块解析、代码转换、AST 分析这些传统静态编译阶段的事情。第二套是服务期钩子属于 Vite 自己的扩展处理 dev server 启动、middleware 注册、HTML 注入、文件监听这些浏览器开发环境下才需要的能力。两套坐标系的关系可以用下面这张表格快速对照钩子类型钩子名称触发时机适用场景构建标号buildStart每次构建启动时校验环境、读取配置、初始化缓存模块解析resolveId遇到 import 语句时别名映射、虚拟模块入口加载模块loadresolve 之后transform 之前读取文件源码、生成虚拟模块内容转换模块transform模块内容读取后编译 TS、注入代码、替换路径代码生成renderChunkchunk 生成阶段产物后加工、代码分割优化服务端钩子configureServerdev server 创建时注册中间件、改写请求响应配置钩子config配置文件加载后修改 Vite 配置对象产物钩子generateBundle打包完毕后写自定义文件、上报统计需要注意的一点同一个钩子在serve和build两种模式下的表现不同因为 dev 模式走的是原生 ES module 按需编译而 build 模式走的是完整 Rollup 打包流程。写插件时最好明确表态你的插件是只服务 dev只服务 build还是两者都要兼容。1.2 为什么说 Vite 插件是“增强版”Rollup 插件Rollup 插件规范最大的局限是它假定所有模块都是静态文件整个构建链路是单向的从入口到输出。但 Vite 的 dev server 需要动态响应浏览器发来的模块请求这就导致 Vite 插件不得不在 Rollup 规范之外提供 ”middleware 机制” 和 “HTML 环境注入” 这类能力。具体到实现层面Vite 插件对象可以看成是这样export default function myVitePlugin(options {}) { let config null return { name: my-vite-plugin, enforce: pre, // pre | post | 默认 config(config, env) { // 在加载配置文件后执行可以合并配置 return { define: { __CUSTOM__: JSON.stringify(options.value) } } }, configResolved(resolvedConfig) { config resolvedConfig }, resolveId(source) { // 自定义解析逻辑 }, load(id) { // 加载自定义模块 }, transform(code, id) { // 转换代码 }, configureServer(server) { // 挂中间件 return () { // 这个函数会在 server 内部插件执行后调用 } }, transformIndexHtml(html) { // 改造 HTML } } }这也就是为什么热词里提到 “vue3 vite 微前端方案” 时真正的整合入口通常是插件而不是 webpack 时代的 loader。因为只有插件能同时控制 dev server 的转译和 build 阶段的产物输出保证微前端子应用在两种模式下的行为一致。2. 手写一个可运行的 Vite 插件从初始化到钩子串联2.1 工程初始化和目录设计创建 Vite 插件本身不需要脚手架它就是普通的 Node 包。我的习惯是把整个调研结构做成独立包再通过本地路径引用方式接进业务项目验证这样插件迭代不会被业务构建流程阻塞。一个最小可用的插件包目录大致长这样vite-plugin-demo/ ├── package.json ├── src/ │ ├── index.ts # 插件主入口 │ ├── resolve.ts # 解析逻辑 │ ├── transform.ts # 转换逻辑 │ └── virtual.ts # 虚拟模块 └── dist/ # 构建产物package.json 里需要注意一个字段{ name: vite-plugin-demo, version: 0.1.0, main: dist/index.js, module: dist/index.mjs, types: dist/index.d.ts, peerDependencies: { vite: ^4.0.0 || ^5.0.0 || ^6.0.0 }, engines: { node: 18 } }之所以要同时声明module和main是因为插件可能在 Node 环境vite.config.ts被加载也可能直接出现在浏览器侧代码里虚拟模块导出的内容。Vite 4 以上版本对 ESM 的加载要求越来越严格如果插件只提供 CJS 导出某些 dev server 场景下会直接抛ERR_REQUIRE_ESM。2.2 核心钩子串联从 resolveId 到 transform现在我写一个真实会用到的插件示例自动注入全局 loading 状态。这个插件能帮我们理解从模块解析到代码转换的完整链路。先定义插件入口import type { Plugin } from vite interface LoadingOptions { widgetPath: string enableInProd?: boolean } export default function viteLoadingPlugin(options: LoadingOptions): Plugin { let resolvedWidgetPath let isSSR false return { name: vite-loading-widget, enforce: pre, configResolved(config) { resolvedWidgetPath options.widgetPath isSSR config.build.ssr }, resolveId(id) { if (id __LOADING_WIDGET_IMPORT__) { return \0virtual:loading-widget } }, load(id) { if (id \0virtual:loading-widget) { return import widget from ${JSON.stringify(resolvedWidgetPath)} export default widget } }, transform(code, id) { if (isSSR) return null if (!id.includes(src/views/)) return null const importStatement import __LOADING_WIDGET__ from __LOADING_WIDGET_IMPORT__ if (code.includes(importStatement)) return null return { code: ${importStatement}\n${code}, map: null } } } }这里面有三个关键设计点第一虚拟模块用\0前缀做隔离标记。resolveId返回以\0开头的模块 ID后续的load钩子会拿到对应的路径。普通文件解析不会跟这个 ID 冲突也不会被其他插件意外处理。第二load钩子里返回的字符串会被直接当作模块内容。这里我用 JSON.stringify 把组件路径包了一层防止 Windows 环境下反斜杠把字符串搞坏。这个细节很容易被忽略但实际踩坑概率极高——路径里的\在模板字符串中会被转义成乱码。第三transform钩子末尾必须返回null代表这个模块不需要转换。Vite 文档里没有强调这一点但一旦误返回 undefined某些旧版本 Vite 会认为本次转换本身出错了直接终止构建。2.3 在业务项目里接入和验证写好的插件本地接入方式有两种。一种是在 vite.config.ts 里直接引用路径import viteLoadingPlugin from ../vite-plugin-demo/src/index export default { plugins: [ viteLoadingPlugin({ widgetPath: /src/components/GlobalLoading.tsx, enableInProd: true }) ] }另一种是用 npm link 或者 pnpm workspace把插件包作为正式依赖引进来。我建议开发初期用路径引用因为链路更短改动能立刻生效。等到测试稳定、准备发布时再改成正式依赖。验证时可以启动 dev server打开任意带src/views/前缀的页面观察 Network 面板里是否出现virtual:loading-widget这个模块请求。如果能看到说明 resolveId → load 链路已经通了。接着角落里随便改一行代码看加载组件是否闪烁那就是 transform 注入生效了。3. 开发期高发问题复盘从 “process is not defined” 到 Buffer 识别失败3.1 错误根因浏览器运行时不认识 Node 全局变量热词里 “vite中项目一直报错process is not defined” 应该是过去几年问询量最高的 Vite 问题之一。这个错误的直接原因是某段代码在浏览器端执行时引用了process变量而浏览器全局作用域里根本没有process。但为什么 Vite 项目里会凭空出现process引用最常见的原因是第三方依赖用了process.env.NODE_ENV来判断环境而且这个依赖被放在了 dev server 的转换链路里。Vite 默认的 define 配置只处理字面量替换不会给所有模块自动注入process全局对象。这里就跟插件机制产生了深度关联如果你在插件里写了process.env.NODE_ENV而这个代码片段经过 transform 后被插入到浏览器模块中那必然导致运行时崩溃。我从插件作者角度建议三种处理方式第一种插件内判断环境时使用 Vite 传入的 config 对象而不是直接访问process。configResolved 钩子会收到完整的 resolvedConfig里面已经包含了当前构建模式。configResolved(config) { if (config.command build config.build.ssr false) { // 只在浏览器端 build 时执行 } }第二种如果你确实需要在浏览器代码里注入环境信息用config钩子的 define 字段config() { return { define: { process: JSON.stringify({ env: { NODE_ENV: production } }) } } }这种方式会把代码里所有的process.xxx引用替换成普通对象。注意不要把整个 Node 的 process 都引进来那是灾难。第三种在 transform 钩子里对特定模块做兼容替换transform(code, id) { if (code.includes(process.env)) { return { code: code.replace(/process\.env\.NODE_ENV/g, JSON.stringify(development)), map: null } } }这块属于兜底方案除非知道自己在做什么否则别全局替换。3.2 热词 “vite 不识别 buffer” 的插件层面应对“Buffer is not defined” 和 “process is not defined” 本质是同一类问题区别在于 Buffer 还牵扯到 Node 内置模块的 polyfill 策略。浏览器端没有 Node 的 Buffer 实现如果某个模块声明Buffer.from()运行时一定会报错。常见做法是给 Rollup 配置内置模块 polyfill在插件中可以用resolveId把 Node 核心模块重定向import { builtinModules } from node:module resolveId(source) { if (builtinModules.includes(source)) { return \0polyfill:${source} } } load(id) { if (id.startsWith(\0polyfill:buffer)) { return export { Buffer } from buffer-polyfill } }不过我实际项目中更推荐的做法是先排查到底是哪个三方依赖触发了 Buffer 引用。用插件的能力在resolveId里打印调用来源能快速定位问题包resolveId(source, importer) { if (source buffer) { console.warn([vite-plugin] import buffer from ${importer}) } }3.3 完整的排查链路贴一个真实的调试过程拿一个我最近处理的案例来说。同事在业务代码里引入了一个解析 Excel 的库然后 dev server 直接崩溃报错信息挂在Buffer上。我没有直接开 polyfill而是按下面这条链路走了一遍第一步先把 Vite 的optimizeDeps.exclude关掉这个库看是否跟预构建有关。结果报错依旧说明问题发生在模块运行时解析阶段。第二步在插件里拦一轮resolveId把这个库路径和引导路径全部打印出来。发现库里有一个子模块直接import buffer。第三步给这个子模块单独加 resolve 别名匹配到内置模块后返回一个 polyfill 模块。这一步只影响这个库不影响全局。第四步验证 dev 和 build 两种模式确认产物大小和编译时间都在预期内。这提示了一点诊断 Vite 插件问题不要一上来就堆配置。把钩子的输入输出打出来远比瞎猜高效因为 Vite 的模块图是运行时实时生成的路径依赖很难从静态代码里看出来。4. 进阶能力实战虚拟模块、配置钩子和类型声明4.1 虚拟模块的正确用法不只是返回一段字符串前面代码里我演示了最简单形式的虚拟模块。实际项目中虚拟模块的价值远不止于此最典型的用法是把服务端数据暴露成模块。假设你想让业务代码像这样导入当前用户信息import userInfo from virtual:user-info console.log(userInfo.name)插件侧实现resolveId(id) { if (id virtual:user-info) { return \0virtual:user-info } }, load(id) { if (id \0virtual:user-info) { const info loadUserInfoFromServer() // 从服务端读取 return export default ${JSON.stringify(info)} } }这里有个隐藏的坑dev 模式下load钩子每次模块请求过来时都会重新执行所以你可以在里面动态读取最新数据。但 build 模式下模块内容一旦生成就会被缓存进 bundle。也就是说同一个插件在 dev 下表现为动态接口在 build 下表现为静态常量这正是很多微前端框架需要额外配置的原因。如果你确实需要在 dev 模式下给虚拟模块提供热更新能力那就需要用到 dev server 的handleHotUpdate钩子在服务端数据变化时发送自定义更新事件。这个属于进阶中的进阶但我强烈建议写过一两个插件后再去碰它。4.2 config 钩子和 configResolved 钩子的分工别搞混了很多插件作者把 config 和 configResolved 混着用但其实两者的语义完全不同。config钩子的触发时机是整个 Vite 配置对象刚被合并完还没有做默认值补充和路径规范化的阶段。在这个钩子里你可以自由修改配置Vite 会把返回值深度合并进最终配置比如修改 alias、添加 css.preprocessorOptions、追加 define 等。configResolved钩子的触发时机晚得多此时配置已经完成解析包含了所有默认值、外部配置、环境变量、最终路径。这个阶段的信息量最大适合缓存配置供后续钩子使用。举一个真实例子插件需要读取root路径下的某个文件。如果在 config 钩子里读此时 root 可能还是相对路径且没有归一化如果在 configResolved 里读config.root已经转成绝对路径读取就准确了。configResolved(config) { this.root config.root this.command config.command this.isSsrBuild config.build.ssr this.resolveAlias config.resolve.alias }4.3 给插件加类型声明让使用者不再拍脑袋发布插件之前给用户完整的 TypeScript 类型是专业度的分水岭。对 Vite 插件来说最少需要声明两部分。第一部分是插件入参和配置类型export interface ViteLoadingWidgetOptions { widgetPath: string enableInProd?: boolean injectTarget?: string }第二部分是增强UserConfig让用户在使用时能获得配置提示。通过 Vite 自带的声明合并特性你可以导出全局类型扩展declare module vite { interface UserConfig { loadingWidget?: ViteLoadingWidgetOptions } }不需要写pluginOptions.hook这种复杂的类型体操保持直观即可。好的类型声明是引导使用者正确配置的说明书不要在类型上堆砌太多装饰响应速度和维护成本都重要。5. 构建性能与场景协同创建插件时最容易忽视的两件事5.1 transform 钩子不是免费的性能压力往往在这里热词里 “vite打包太慢” 几乎每个项目都会遇到而插件对性能的拖累通常发生在transform钩子上。原因很简单dev server 是按需编译的浏览器请求哪个模块Vite 才转换哪个模块但 build 阶段是全量编译所有模块都要过每一层插件。如果某个插件在 transform 里做了很重的 AST 解析、source map 合并、字符串全量替换那几千个模块叠加下来就是灾难。我习惯遵循几条性能纪律第一能提前 return 就不要多做功。transform 里先用正则或者文件路径快速判断是否属于当前插件的处理范围不匹配立刻返回 null。第二避免重复编译。用this.addWatchFile来声明插件依赖的文件变化让 Vite 只对相关文件做失效而不是对整个模块图做失效。第三缓存代价高的计算结果。在 buildStart 之前把配置、正则、路径映射全部算好不要在 transform 里反复创建正则表达式对象。第四source map 生成按需关闭。如果插件只是做简单替换返回map: null就够了。生成 source map 的开销在大型代码库上相当可观。做一个横向对比表格哪种 transform 写法更高效写法性能特点适用场景全量 AST 解析慢信息最全需要做复杂语义分析的插件正则精准匹配快误判率低简单代码注入、路径替换字符串 replace最快但容易误替换极轻量的宏替换内容哈希缓存首次慢后续快高频访问的大型模块没有银弹但至少不要在每次 transform 里都跑一次全量解析。5.2 插件与微前端、SSR 场景的兼容性设计微前端方案webpack Module Federation 或者 Vite 自研模块共享之所以比普通 SPA 复杂是因为每个子应用拥有自己的构建流程和运行时但又要共享公共依赖。如果你写的插件里有虚拟模块而且这个模块的内部实现引用了业务依赖那么在微前端架构下要格外小心。因为虚拟模块在子应用维度生成但运行时却要在主应用容器里执行跨应用依赖很容易出现重复实例。针对这种场景我的插件设计是虚拟模块的内容保持纯函数和数据不确定业务依赖。把真正有业务处理的逻辑放在transform钩子注入让共享依赖走子应用自己的 import 路径。把全局状态保存在define配置里而不是在模块内部持有单例。SSR 场景需要注意的则是transform的产物不能依赖浏览器对象。如果你的插件往模块里注入了document、window访问那 SSR 渲染时就会当场爆炸。判断当前构建是否 SSR 的入口就是config.build.ssr在 transform 最开始就分流处理。5.3 插件版本维护与兼容性比写代码更耗心思创建好一个插件只是开始真正考验人的是后续维护。Vite 每隔大版本就会调整内部 API比如 Vite 5 调整了resolveId返回类型要求Vite 6 对transformIndexHtml的回调签名做了清理。我建议插件包在 peerDependencies 里明确声明支持的 Vite 版本范围而不是无脑4。凡是用了内部 API 的地方都用子函数封装一层方便将来按版本差异化实现import { version } from vite function applyCompat(config) { if (version.startsWith(5)) { return config.build.target esnext } // Vite 6 的兼容处理 }做兼容性维护比写插件本身更消磨精力但这也是插件作者走向资深的重要分水岭。能长期维护的插件一定是给用户画清楚了支持边界而不是让用户在一个坏了一半的插件里自行摸索。6. 一些从实战里沉淀下来的插件开发建议最后聊点纯经验性的东西都是踩坑踩出来的。我建议大家写插件时先想清楚输入输出的形状。很多插件写崩是因为作者并不清楚自己要拦截哪一段模块流等代码写了一半才发现钩子顺序和实际执行流程对不上。建议先画一份自己手写的执行线路图不用严格把 dev 和 build 两条链路分别标出来再按链路去选钩子能减少反复重写。第二个建议是永远保留一个最小的集成测试。这个测试不需要跑完整业务只需要一个带三四个文件的临时 Vite 项目通过启动 dev server 执行一次模块请求断言插件输出符合预期。有了这个测试后续改动时心态完全不一样。第三个建议跟热词 “webstorm插件”、“vscode插件” 这类 IDE 层面的东西无关但跟你的开发体验强相关开发 Vite 插件时尽量在本地 Node 版本和 LTS 版本之间保持一致否则 esbuild 的原生二进制很容易报平台相关的错。Node 18 以下的版本运行 Vite 5 或 6一些依赖预构建的缓存逻辑会有异常表现。我写这篇文章的初衷其实很简单看到太多团队遇到 “process is not defined” 这类问题时选择绕行或者堆 hack很少有人去深挖 Vite 插件在模块流转里的真正作用。如果你能完整实现一个小型插件上面提到的环境边界、钩子时序、性能权衡这几点就全部打通了再面对那些诡异报错时也会有判断依据而不是继续靠搜索引擎碰运气。