Vite 插件入门:理解钩子,比照着模板改更重要

要不要为一个需求写 Vite 插件,我现在有一个判断标准:这段逻辑能不能用 vite.config.js 里的配置项直接表达。如果能,就别写插件——配置项是声明式的,别人接手时一眼看懂;插件是命令式的,多了一层钩子和执行时机要理解。但如果这段逻辑要跨 dev/build 两种模式、要感知模块内容、要在构建产物生成前后插一手,配置项就不够用了,这时候才轮到插件。

内部工具链最近在慢慢往 Vite 上迁。手头这次是一个很具体的需求:每次构建生成版本信息,页面右下角要能看到环境、commit、构建时间,错误上报里也要带上这些字段。最早是写一个脚本在构建前生成一个 JSON 文件,前端代码里 fetch 读取。能跑,但 dev 模式下这个文件经常是旧的,忘了重新触发脚本,调试的人经常拿着过期的构建时间看半天。把这块逻辑挪进 Vite 插件后,dev 和 build 走同一套生成函数,入口反而清楚了。

插件对象长什么样

Vite 插件本质上是一个包含若干可选钩子的对象,插件工厂函数返回这个对象:

1export default function myPlugin() {
2  return {
3    name: "my-plugin",
4    transform(code, id) {
5      return code;
6    },
7  };
8}

Vite 目前是 2.x 系列,插件接口从 2.0 开始就兼容 Rollup 插件规范——这意味着一部分社区里现成的 Rollup 插件可以直接塞进 Vite 的 plugins 数组里用,不用等专门的 Vite 版本。但反过来不成立:Vite 插件里 configureServertransformIndexHtmlconfigconfigResolved 这几个钩子是 Vite 自己加的,Rollup 完全不认识。如果一个插件同时给 Vite 和纯 Rollup 项目用,就得留意这几个钩子只在 Vite 里会被调用,纯 Rollup 场景下会被安静地忽略,不会报错,但也不会生效——这个坑我们内部工具库踩过一次,一个只在 Vite 里生效的 HTML 注入逻辑被复制到一个还在用纯 Rollup 打包的旧项目里,构建没报错,页面却始终没有被注入的内容,排查了一阵才想起这几个钩子压根不属于 Rollup 规范。

插件对象里最重要的字段是 name。它不只是给人看的标签,出错时 Vite 的报错信息里会带上插件名,构建日志、--debug 输出里也用它定位是哪个插件在起作用。团队内部插件我会统一在名字里加 internal: 前缀:

1export default function injectBuildTimePlugin() {
2  return {
3    name: "internal:inject-build-time",
4  };
5}

这样构建报错时能一眼分清问题来自业务插件、框架自带插件,还是第三方依赖里的插件——Vite 内置的插件本身也遵循一套命名约定,官方钩子和内建能力大多以 vite: 开头(比如处理 CSS 的、处理资源解析的),社区插件常用包名做前缀,团队内部再加一层 internal:,三层前缀放在一起看构建日志时非常好分辨来源,不会和框架自带的搞混。

钩子按执行阶段来记,别按字母记

Vite 的构建流程大致分几个阶段:启动阶段解析配置、开发阶段响应请求或构建阶段打包模块、转换阶段处理单个模块代码、产物阶段处理最终 bundle。钩子名字虽然多,但按这几个阶段归类,就没那么难记:

  • 配置阶段:config(修改原始配置)、configResolved(读取最终解析后的配置,只读不能改)
  • 开发服务器阶段:configureServer(扩展 dev server,加中间件)
  • 模块解析阶段:resolveId(决定某个 import 路径实际指向哪里)
  • 模块加载阶段:load(提供模块的实际内容)
  • 转换阶段:transform(对已有代码做改写)
  • HTML 处理:transformIndexHtml
  • 产物阶段:generateBundle(在写入磁盘前拿到最终产物列表)

这几个钩子里,resolveIdloadtransform 的执行顺序容易搞混。一次模块加载的实际顺序是:先过一遍所有插件的 resolveId 决定这个 import 到底对应哪个真实模块 id(第一个返回非空结果的插件"胜出",后面的插件不会再被问),确定 id 后再依次过 load 拿到源码内容(同样是第一个返回内容的插件生效),最后源码交给所有声明了 transform 的插件依次处理,前一个插件的输出是后一个插件的输入。这个顺序解释了不少新手疑惑:为什么两个插件都写了 resolveId 却只有一个生效——先注册的插件如果已经认领了这个 id,后面的压根不会被调用;为什么 transform 里拿到的代码可能已经不是原始源码——前面的插件可能已经改过一轮了。

排查插件相互干扰的问题时,这个顺序模型比死记 API 名字有用得多。

一个虚拟模块的例子

刚说的版本信息需求,实现方式是提供一个虚拟模块,业务代码里这样引用:

1import { buildTime } from "virtual:build-info";

插件实现:

1function createBuildInfo() {
2  return {
3    commit: process.env.GITHUB_SHA || "local",
4    time: new Date().toISOString(),
5  };
6}
7
8export default function buildInfoPlugin() {
9  var virtualModuleId = "virtual:build-info";
10  var resolvedVirtualModuleId = "\0" + virtualModuleId;
11
12  return {
13    name: "internal:build-info",
14    resolveId(id) {
15      if (id === virtualModuleId) {
16        return resolvedVirtualModuleId;
17      }
18    },
19    load(id) {
20      if (id === resolvedVirtualModuleId) {
21        return `export default ${JSON.stringify(createBuildInfo())}`;
22      }
23    },
24  };
25}

resolveId 告诉 Vite 这个模块由插件接管,返回值加 \0 前缀是 Rollup 生态里的约定,标记这是一个不对应真实文件系统路径的虚拟模块,避免和真实文件路径冲突,其他插件或者浏览器 devtools 里看到这个前缀也能立刻知道这不是常规文件。load 再根据这个内部 id 返回实际内容。

createBuildInfo 单独抽出来是有教训的。最早我图省事,resolveId/load 里直接内联生成逻辑,后来发现 dev 模式下我为了实现"文件变化自动刷新"又抄了一份类似逻辑放在别处,两处字段慢慢就不一致了——一处带了 branch 字段一处没带。核心生成逻辑只保留一个入口,是这类小插件最值得较真的地方。

commit 信息从 CI 环境变量读取,本地开发环境变量不存在时给个兜底值,避免本地跑起来时页面上出现 undefined

1const commit = process.env.GITHUB_SHA || "local";

这里有条红线要守住:写进前端产物里的东西都不是秘密,commit、构建时间、环境名可以放,但内部接口地址如果带鉴权 token、任何私钥、内部专用的密钥绝对不能出现在这类插件生成的内容里——产物是要发到 CDN 上被所有人下载到浏览器里的,和写在服务端配置文件里完全是两回事。

区分开发和构建

有些插件只该在开发时跑,有些只该在构建时跑。可以用 apply 字段控制:

1export default function buildOnlyPlugin() {
2  return {
3    name: "build-only-plugin",
4    apply: "build",
5  };
6}

也可以写成函数,根据命令和环境自己判断:

1export default function myPlugin() {
2  return {
3    name: "my-plugin",
4    apply(config, { command }) {
5      return command === "serve";
6    },
7  };
8}

开发服务器和生产构建的关注点不一样:dev 更在意中间件、热更新、调试体验;build 更在意产物大小、压缩、静态资源路径。混在一起写,容易出现"开发正常、构建失败"或者"构建正常、开发慢得离谱"这种两头不讨好的情况。

我们内部有个插件早期版本没做 apply 区分,dev 模式下也会执行一遍构建时才需要的目录全量扫描,页面一刷新就卡顿明显。后来把扫描逻辑限定在 apply: "build" 下,dev 模式改成只监听目标文件变化,卡顿才消失。插件代码运行在构建链路的关键路径上,性能问题会被团队里每个人每天反复感受到,不是可以将就的地方。

transform 一定要做过滤,且要处理好路径

transform(code, id) 会被几乎所有经过打包的模块调用一次。不做过滤的话,插件会处理大量无关文件,既影响构建速度,也可能误改第三方依赖里的代码:

1transform(code, id) {
2  if (!id.endsWith(".md")) {
3    return null;
4  }
5
6  return {
7    code: transformMarkdown(code),
8    map: null,
9  };
10}

返回 null 表示不处理,这种早返回是写 transform 时最基本的习惯。

id 常常带查询参数,比如 Vue 单文件组件编译过程中间产物的 id 长这样:

1/src/App.vue?vue&type=script

简单的 endsWith 判断在这类场景下会失手,稳妥的做法是先把 query 拆掉再判断:

1function normalizePath(id) {
2  return id.replace(/\\/g, "/");
3}
4
5transform(code, id) {
6  const cleanId = normalizePath(id.split("?")[0]);
7
8  if (!cleanId.endsWith("/src/routes.ts")) {
9    return null;
10  }
11
12  return {
13    code: transformRoutes(code),
14    map: null,
15  };
16}

normalizePath 这一步是给 Windows 准备的。内部插件常见的写作环境是 macOS,CI 跑在 Linux 上,但团队里总有同事在 Windows 上本地开发,id 在 Windows 下可能出现反斜杠分隔符,不提前标准化成 POSIX 风格,就会遇到"别人电脑上不生效"这种不好复现的问题。

source map 也不能完全无视。只做简单字符串替换、改动范围很小的插件,map: null 问题不大;一旦插件做了结构性的代码生成或改写,最好生成对应的 source map,否则线上报错栈或者本地调试时断点位置会对不上,排查会更费劲。字符串替换用于简单场景可以,涉及真实语法结构的改写,最好用 AST 操作,字符串替换在注释、模板字符串、正则字面量这些边界情况上很容易出问题。

插件顺序与 enforce

有些插件必须先于框架内置插件跑,有些必须最后跑。Vite 提供 enforce 字段控制这件事:

1export default function prePlugin() {
2  return {
3    name: "pre-plugin",
4    enforce: "pre",
5  };
6}

enforce: "pre" 的插件会排在没有声明 enforce 的普通插件之前执行,enforce: "post" 排在最后。需要在框架插件(比如处理 .vue 文件的插件)介入之前先改一遍源码,或者需要在所有转换结束后统计结果,就要用到它。不要滥用,插件顺序一旦复杂,出问题时不好排查。

遇到"单独启用这个插件没事,跟另一个插件一起用就出错"的情况,我现在第一反应是去查两个插件的 enforce 和各自处理的文件范围有没有重叠,而不是先怀疑代码逻辑本身。

插件之间也尽量别通过隐式状态通信。比如插件 A 往临时目录写一个文件,插件 B 再去读这个文件——这种设计只要执行顺序发生变化就会出问题,而顺序恰恰是最容易被后来者不小心打乱的东西。能通过 Vite/Rollup 提供的插件上下文(比如 this.getModuleInfo)传递信息,就不要依赖全局变量或者临时文件这类脱离框架掌控的通道。

开发阶段的热更新和缓存失效

插件生成的内容如果依赖一个外部文件,这个文件变化时要让 dev server 知道,否则页面显示的永远是旧数据。以版本信息为例,如果构建元数据来自一个 build-info.json,可以在 configureServer 里手动监听:

1configureServer(server) {
2  server.watcher.add("build-info.json");
3
4  server.watcher.on("change", (file) => {
5    if (file.endsWith("build-info.json")) {
6      const mod = server.moduleGraph.getModuleById("\0virtual:build-info");
7      if (mod) {
8        server.moduleGraph.invalidateModule(mod);
9      }
10      server.ws.send({ type: "full-reload" });
11    }
12  });
13}

这里手动调用了 invalidateModule 让模块图知道虚拟模块的内容已经过期,再通过 WebSocket 通知浏览器整页刷新。这不一定是最优雅的写法,局部热替换会更细腻,但对这种非组件类的元数据模块,整页刷新足够简单可靠。这个例子想说明的是:插件不是构建能跑通就算完工,dev 模式下缓存什么时候失效、页面什么时候该刷新,同样要设计清楚,否则调试的人会一直看着过期数据怀疑自己代码写错了。

错误处理和日志克制

插件内部同样需要清楚的错误信息。读取文件失败、解析配置失败、代码转换失败,都应该抛出明确说明来源的错误,而不是让调用者只看到一段看不出源头的底层异常堆栈:

1throw new Error("[internal:build-info] failed to read build metadata");

调试日志也要克制。开发阶段可以适当输出详情辅助排查,生产构建里如果每个模块都刷一行日志,CI 的构建输出会被淹没,真正有用的报错反而更难找到。团队内部的通行做法是用一个环境变量开关控制调试输出,默认关闭。

插件要能联调,不能只在想象里可用

写插件时最好准备一个示例项目:插件目录负责源码和打包,examples 目录负责真实引用这个插件跑一遍。常见搭配是插件源码用 TypeScript 写,用 tsup 之类的工具打包成 CJS/ESM 双格式,examples 里放一个 Vue 3 的最小示例,本地通过相对路径引用或者软链接的方式引用插件包。

除了示例项目,还要有一份最小测试清单,至少覆盖这几件事:

  • dev 模式下是否生效
  • build 模式下是否生效
  • Windows 和 macOS 路径是否都能正确处理
  • 目标文件不存在时报错信息是否够清楚
  • 反复启动、反复构建是否会有缓存或产物污染

工程化插件真正的坑往往不在最顺利的那条路径上,而在这些边界情况里。我现在写完一个内部插件,还会额外检查两件事:一是多次运行会不会生成重复内容(比如虚拟模块被重复注册),二是失败时抛出的错误里有没有带上插件名、出问题的文件和所处的阶段——这几个信息凑齐了,接手的人排查起来才不用先靠猜。

回到最初的判断标准

这段时间写的几个内部插件,回头看都符合最开始那条标准:跨 dev/build 两种模式共享逻辑、需要感知模块内容、需要在产物生成前后插入处理。如果只是想改一下 base 路径或者加一条 resolve.alias,用配置项写清楚就好,没必要包装成插件——多一层插件封装,对后来维护的人反而是多一层要理解的抽象。

写插件之前先把需求在构建流程里定位清楚:它到底是配置问题、模块解析问题、源码转换问题,还是产物处理问题,钩子选择自然就跟着定下来了。阶段定位准了,插件结构通常不会乱;阶段定位错了,再怎么调整代码细节也是别扭的。