Vue 基础体系 · 第 60/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vite 插件开发:Hook、虚拟模块、转换、HMR 和调试

Vite 插件本质上是一个对象:它通过一组约定好的 Hook 参与配置读取、模块解析、源码加载、源码转换、开发服务器和构建流程。插件并不是简单的“在构建前后执行一段代码”,而是可以介入浏览器请求模块时的完整数据流。

本文使用 Vue 3、Composition API、TypeScript 和现代 Vite 工具链说明以下机制:

  • Vite 插件的生命周期与 Hook 调度;
  • resolveIdloadtransform 的模块处理链;
  • 虚拟模块如何从“不可见的模块”变成可导入代码;
  • configureServerhandleHotUpdate 与 HMR 的关系;
  • 源码映射、插件顺序和 Vue SFC 转换边界;
  • 插件的错误处理、调试和生产取舍。

示例假设项目使用 Vite 创建,且已经安装 Vue 与 Vue 插件:

npm create vite@latest vite-plugin-demo -- --template vue-ts
cd vite-plugin-demo
npm install
npm run dev

Vue 项目的 Vite 配置通常类似下面这样:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

一、Vite 插件究竟扩展了什么

1. Hook 是什么

Hook 是 Vite 在特定时间点调用的插件函数。插件可以实现一个或多个 Hook:

import type { Plugin } from 'vite'

export default function examplePlugin(): Plugin {
  return {
    name: 'example-plugin',

    config() {
      // 读取或修改配置
    },

    transform(code, id) {
      // 转换模块源码
      return null
    },

    configureServer(server) {
      // 配置开发服务器
    },
  }
}

name 是插件名称,必须稳定且具有辨识度。它会出现在调试日志中,也方便定位插件顺序和报错来源。

一个 Hook 的返回值通常有明确语义:

  • nullundefined:当前插件不处理,继续交给后续插件;
  • 字符串:返回新的模块代码;
  • 对象 { code, map }:返回新的模块代码和源码映射;
  • 特定 Hook 的对象:例如 resolveId 可以返回模块 ID 及元数据;
  • 抛出异常:中断当前处理流程,通常会显示为构建或开发服务器错误。

因此,下面两个返回值并不等价:

transform(code) {
  return code
}

和:

transform() {
  return null
}

前者表示“我处理过,并且结果与输入相同”;后者表示“我没有处理”。大多数情况下二者最终代码相同,但插件链的调试信息、后续插件判断和性能行为可能不同。没有必要转换时,应返回 null

2. Vite 插件与 Rollup 插件的关系

Vite 插件接口大量兼容 Rollup 插件,尤其是以下模块相关 Hook:

  • resolveId:解析导入路径;
  • load:提供模块内容;
  • transform:转换模块内容;
  • buildStartbuildEndcloseBundle:构建生命周期。

但 Vite 还增加了开发服务器相关能力,例如:

  • configureServer
  • handleHotUpdate
  • transformIndexHtml
  • Vite 特有的模块图和 WebSocket 开发服务器对象。

这意味着:

  1. 一个只使用通用 Rollup Hook 的插件,通常也能参与 Vite 构建;
  2. 一个依赖 configureServer 或 Vite 模块图的插件,不能简单视为通用 Rollup 插件;
  3. 同一个插件在开发服务器和生产构建中的执行环境不同,不能假设 server 在所有阶段都存在。

3. 插件 Hook 的总体数据流

浏览器请求:

import { featureEnabled } from 'virtual:app-config'

Vite 开发服务器需要回答三个问题:

  1. 这个导入路径对应哪个内部模块 ID?
  2. 这个模块的源码是什么?
  3. 这个源码是否还需要被其他插件转换?

对应的处理链是:

flowchart LR
    A[源码中的 import] --> B[resolveId]
    B --> C[内部模块 ID]
    C --> D[load]
    D --> E[模块源码]
    E --> F[transform]
    F --> G[发送给浏览器或交给构建器]
    G --> H[模块图与 HMR]

以普通文件为例:

import App from './App.vue'

Vite 会解析 ./App.vue 对应的文件路径,然后读取文件,再交给 Vue 插件处理。以虚拟模块为例,磁盘上没有对应文件,插件必须自己实现解析和加载。

需要注意,插件 Hook 不是一个严格的“每个插件依次执行完全部 Hook,再进入下一个 Hook”的简单模型。Vite/Rollup 会按照 Hook 类型调度插件:

  • 某些 Hook 是顺序执行的;
  • 某些 Hook 允许并行执行;
  • resolveId 找到结果后,后续插件可能不再参与该次解析;
  • transform 通常会按照插件顺序形成转换链。

插件文档中的“生命周期”描述的是处理阶段,不应理解为所有 Hook 都共享一个固定的线性调用栈。

二、插件顺序决定结果

1. enforce 与插件分组

插件可以通过 enforce 指定大致顺序:

export default {
  name: 'my-plugin',
  enforce: 'pre',
}

可用值是:

  • pre:常规插件之前;
  • 默认值:常规阶段;
  • post:常规插件之后。

这不是任意插件之间的完整排序系统,而是将插件分为几个阶段。相同阶段内通常还会受配置中的插件顺序影响。

例如,Vue 插件需要识别和转换 .vue 单文件组件。如果一个自定义插件要在 Vue SFC 转换前修改原始内容,可能使用 enforce: 'pre';如果要处理 Vue 插件生成的结果,则可能使用 post。但转换后的 .vue 子模块 ID 往往包含查询参数,例如:

/src/App.vue?vue&type=script&lang.ts

因此不能只通过是否以 .vue 结尾来判断所有 Vue 模块。

2. apply 区分开发与构建

插件可以只在开发服务器或构建阶段启用:

import type { Plugin } from 'vite'

export function devOnlyPlugin(): Plugin {
  return {
    name: 'dev-only-plugin',
    apply: 'serve',

    configureServer(server) {
      server.config.logger.info('开发服务器插件已启用')
    },
  }
}

apply: 'serve' 表示开发服务器,apply: 'build' 表示生产构建。也可以使用函数根据配置判断:

export default {
  name: 'conditional-plugin',
  apply(config, { command }) {
    return command === 'serve'
  },
}

如果插件使用了 configureServerhandleHotUpdate 等开发服务器 API,明确限制 apply: 'serve' 通常比在构建阶段判断 server 是否存在更清晰。

三、resolveIdloadtransform:模块处理的三步

1. resolveId:把导入路径映射为内部 ID

resolveId(source, importer) 接收:

  • source:源码中的导入字符串;
  • importer:发起导入的模块 ID,入口模块可能没有 importer。

例如:

import { x } from 'virtual:app-config'

此时 source 是:

virtual:app-config

插件可以返回一个绝对文件路径,也可以返回一个虚拟模块 ID。

resolveId(source) {
  if (source === 'virtual:app-config') {
    return '\0virtual:app-config'
  }

  return null
}

这里的 \0 是 JavaScript 字符串中的 NUL 字符,不是两个可见字符 \0。它是插件生态中用于标记“内部模块”的约定,使这个 ID 不会被当成普通磁盘路径继续解析。

在源码中经常使用两个常量,避免误写:

const PUBLIC_ID = 'virtual:app-config'
const RESOLVED_ID = '\0virtual:app-config'

2. load:为 ID 提供模块内容

resolveId 返回内部 ID 后,Vite 会调用:

load(id) {
  if (id === RESOLVED_ID) {
    return `
      export const featureEnabled = true
    `
  }

  return null
}

load 返回的代码会被当成 JavaScript 或 TypeScript 模块继续处理。它不要求模块必须存在于磁盘上。

3. transform:修改已经获得的代码

transform(code, id) 接收当前模块代码和模块 ID:

transform(code, id) {
  if (!id.endsWith('.ts')) {
    return null
  }

  return {
    code: code.replace('__APP_VERSION__', '1.0.0'),
    map: null,
  }
}

转换链可以抽象为:

Ci+1=Ti(Ci,id)C_{i+1} = T_i(C_i, id)

其中:

  • C0C_0 是原始模块内容;
  • TiT_i 是第 ii 个插件的转换函数;
  • Ci+1C_{i+1} 是该插件交给下一个插件的代码;
  • id 是模块标识。

如果插件顺序发生变化,转换结果可能改变:

T2(T1(C0))T1(T2(C0))T_2(T_1(C_0)) \neq T_1(T_2(C_0))

例如,插件 A 把模板语法转换成 JavaScript,插件 B 只识别 JavaScript 中的某个模式,那么 A 必须先于 B;反过来 B 可能根本匹配不到。

四、完整示例:构建一个虚拟配置模块

下面实现一个名为 vite-plugin-app-config 的插件。它读取项目根目录下的 app.config.json,然后生成一个可被 Vue 应用直接导入的虚拟模块。

1. 配置文件

在项目根目录创建:

{
  "apiBaseUrl": "/api",
  "features": {
    "newDashboard": true,
    "experimentalEditor": false
  }
}

2. 插件实现

创建 plugins/appConfig.ts

import fs from 'node:fs'
import path from 'node:path'
import type { Plugin, ViteDevServer } from 'vite'

const PUBLIC_ID = 'virtual:app-config'
const RESOLVED_ID = '\0virtual:app-config'

interface AppConfig {
  apiBaseUrl: string
  features: Record<string, boolean>
}

function readAppConfig(file: string): AppConfig {
  const source = fs.readFileSync(file, 'utf8')
  const value: unknown = JSON.parse(source)

  if (
    typeof value !== 'object' ||
    value === null ||
    typeof (value as Record<string, unknown>).apiBaseUrl !== 'string' ||
    typeof (value as Record<string, unknown>).features !== 'object'
  ) {
    throw new Error(`Invalid app config: ${file}`)
  }

  return value as AppConfig
}

export function appConfigPlugin(): Plugin {
  let configFile = ''

  return {
    name: 'vite-plugin-app-config',
    apply: 'serve',

    configResolved(config) {
      configFile = path.resolve(config.root, 'app.config.json')
    },

    resolveId(source) {
      if (source === PUBLIC_ID) {
        return RESOLVED_ID
      }

      return null
    },

    load(id) {
      if (id !== RESOLVED_ID) {
        return null
      }

      const appConfig = readAppConfig(configFile)

      return `
        export const apiBaseUrl = ${JSON.stringify(appConfig.apiBaseUrl)}
        export const features = ${JSON.stringify(appConfig.features)}
        export default {
          apiBaseUrl,
          features,
        }
      `
    },

    configureServer(server) {
      watchConfigFile(server, configFile)
    },

    handleHotUpdate({ file, server }) {
      if (path.resolve(file) !== configFile) {
        return
      }

      // 确认新配置能够被解析,避免把坏配置推送给浏览器。
      readAppConfig(configFile)

      const module = server.moduleGraph.getModuleById(RESOLVED_ID)

      if (!module) {
        return
      }

      // 让下一次加载使用新内容,并把该虚拟模块作为 HMR 更新目标。
      server.moduleGraph.invalidateModule(module)

      return [module]
    },
  }
}

function watchConfigFile(server: ViteDevServer, file: string): void {
  server.watcher.add(file)
}

这里有几个关键因果关系。

configResolved

configResolved 在 Vite 配置已经合并完成后执行。此时 config.root 已经确定,因此插件可以把相对路径解析为绝对路径:

configFile = path.resolve(config.root, 'app.config.json')

不能在插件工厂函数执行时直接假设当前工作目录就是 Vite 项目根目录,因为用户可能通过 root 配置改变项目根目录。

resolveId

用户代码看见的是:

import appConfig from 'virtual:app-config'

Vite 内部使用的是:

\0virtual:app-config

这种“公开导入名”和“内部解析名”分离的方式,可以避免普通代码直接依赖内部 ID,也避免插件之间混淆。

load

load 每次被请求时重新读取配置文件。因此 HMR 使虚拟模块重新加载时,读取到的是最新内容。

生成代码时使用 JSON.stringify 而不是手工拼接字符串:

JSON.stringify(appConfig.apiBaseUrl)

因为配置中可能包含引号、换行或反斜杠。手工生成:

`export const apiBaseUrl = '${appConfig.apiBaseUrl}'`

在配置值包含单引号时可能生成非法 JavaScript,甚至造成代码注入风险。

handleHotUpdate

handleHotUpdate 接收一个上下文对象,其中的 file 是发生变化的文件,server 是当前开发服务器。

配置文件不是普通的 JavaScript 模块,Vite 的默认模块依赖图不会自动知道它对应 virtual:app-config。因此插件必须建立这条关系:

  1. 判断变化的是 app.config.json
  2. 重新解析文件,提前发现格式错误;
  3. 从模块图中找到虚拟模块;
  4. 使虚拟模块失效;
  5. 返回该模块作为 HMR 更新目标。

如果返回 undefined,插件没有声明额外的模块更新目标,Vite 不会因为这个 Hook 自动把虚拟模块推送给客户端。

3. 在 Vue 组件中使用虚拟模块

src/App.vue 中:

<script setup lang="ts">
import appConfig from 'virtual:app-config'

const enabled = appConfig.features.newDashboard
</script>

<template>
  <main>
    <h1>Dashboard</h1>
    <p>API: {{ appConfig.apiBaseUrl }}</p>
    <p>New dashboard: {{ enabled ? 'enabled' : 'disabled' }}</p>
  </main>
</template>

vite.config.ts 中注册:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { appConfigPlugin } from './plugins/appConfig'

export default defineConfig({
  plugins: [
    appConfigPlugin(),
    vue(),
  ],
})

运行:

npm run dev

修改 app.config.json

{
  "apiBaseUrl": "/api-v2",
  "features": {
    "newDashboard": false,
    "experimentalEditor": true
  }
}

如果当前页面已经导入了虚拟模块,Vite 会重新加载这个模块并尝试执行 HMR。若模块边界不适合热更新,最终可能退化为页面刷新;这取决于模块依赖关系和客户端是否接受该更新。

4. 给虚拟模块补充 TypeScript 类型

TypeScript 不知道 virtual:app-config 的类型,因此应添加声明文件,例如 src/vite-env.d.ts

declare module 'virtual:app-config' {
  interface AppConfig {
    apiBaseUrl: string
    features: Record<string, boolean>
  }

  const config: AppConfig

  export const apiBaseUrl: string
  export const features: Record<string, boolean>
  export default config
}

这里有两个独立系统:

  • Vite 在运行时通过 resolveIdload 提供模块;
  • TypeScript 在类型检查时通过 declare module 理解模块。

只实现其中一边都会导致问题:没有运行时实现会在浏览器中失败,没有类型声明会在编辑器或 vue-tsc 中报模块找不到。

五、虚拟模块的边界与安全性

1. 虚拟模块不是“假的文件路径”

虚拟模块有自己的模块 ID 和模块图节点。它可以:

  • 被普通模块导入;
  • 被其他插件转换;
  • 参与依赖失效;
  • 触发 HMR;
  • 在构建阶段被打包。

但它通常不能直接通过操作系统文件 API 读取,也不应假设存在同名磁盘文件。

错误写法:

load(id) {
  if (id === 'virtual:app-config') {
    return 'export default {}'
  }

  return null
}

如果 resolveId 已经把公开 ID 转成了 \0virtual:app-config,那么 load 收到的是内部 ID。此时判断公开 ID 会导致 load 永远不命中。

2. 不要把未经验证的用户输入直接生成代码

错误写法:

return `export const name = '${config.name}'`

更安全的写法是:

return `export const name = ${JSON.stringify(config.name)}`

如果需要生成复杂 JavaScript 表达式,还应限制数据类型和字段集合,而不是把任意配置字符串当成源码执行。

3. 环境变量与虚拟模块的取舍

Vite 自带 import.meta.env 机制,适合暴露简单的构建时环境变量:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

虚拟模块适合以下场景:

  • 数据需要从多个文件聚合;
  • 需要生成多个导出;
  • 需要自定义类型;
  • 需要在文件变化时精确触发 HMR;
  • 需要隐藏配置读取和生成逻辑。

二者都不是安全边界。任何被打包到浏览器的值都可以被用户查看,因此不能通过虚拟模块或 import.meta.env 暴露密钥。

六、transform:转换源码而不是替换文本那么简单

1. 一个最小的版本替换插件

src/version.ts 中写入:

export const appVersion = '__APP_VERSION__'

实现插件:

import type { Plugin } from 'vite'

export function replaceAppVersion(version: string): Plugin {
  return {
    name: 'vite-plugin-replace-app-version',

    transform(code, id) {
      if (!id.endsWith('/src/version.ts')) {
        return null
      }

      if (!code.includes('__APP_VERSION__')) {
        return null
      }

      return {
        code: code.replaceAll('__APP_VERSION__', JSON.stringify(version)),
        map: null,
      }
    },
  }
}

注册:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { replaceAppVersion } from './plugins/replaceAppVersion'

export default defineConfig({
  plugins: [
    replaceAppVersion('1.2.3'),
    vue(),
  ],
})

转换前:

export const appVersion = '__APP_VERSION__'

转换后:

export const appVersion = '"1.2.3"'

这里的结果实际上有问题:原始代码已经包含字符串引号,而替换值又通过 JSON.stringify 生成了带引号的字符串,最终会出现双重引号语义。

正确做法取决于占位符位置。对于已经位于字符串字面量内部的占位符,应替换成经过转义但不带外层引号的内容:

function escapeStringContent(value: string): string {
  return value
    .replaceAll('\\', '\\\\')
    .replaceAll("'", "\\'")
}

export function replaceAppVersion(version: string): Plugin {
  return {
    name: 'vite-plugin-replace-app-version',

    transform(code, id) {
      if (!id.endsWith('/src/version.ts')) {
        return null
      }

      if (!code.includes('__APP_VERSION__')) {
        return null
      }

      return {
        code: code.replaceAll(
          '__APP_VERSION__',
          escapeStringContent(version),
        ),
        map: null,
      }
    },
  }
}

如果源代码改成:

export const appVersion = __APP_VERSION__

那么才应使用:

code.replaceAll('__APP_VERSION__', JSON.stringify(version))

这个例子说明:源码转换必须理解占位符所在的语法上下文。盲目字符串替换可能产生非法代码或改变原有语义。

2. id 可能包含查询参数

Vite 处理 Vue 单文件组件时,可能生成带查询参数的模块 ID:

/src/App.vue?vue&type=script&lang.ts

因此常见判断方式是先去除查询部分:

const cleanId = id.split('?')[0]

if (!cleanId.endsWith('.ts')) {
  return null
}

但对于只想处理某个具体文件的插件,使用绝对路径比较更可靠:

import path from 'node:path'

const target = path.resolve(process.cwd(), 'src/version.ts')

transform(code, id) {
  const cleanId = id.split('?')[0]

  if (path.normalize(cleanId) !== path.normalize(target)) {
    return null
  }

  // ...
}

在跨平台插件中,应注意 Windows 路径分隔符、大小写和 URL 编码。不要简单依赖 Unix 风格的字符串前缀。

3. 源码映射为什么重要

transform 返回对象时可以提供 map

return {
  code: transformedCode,
  map: null,
}

map: null 表示插件没有提供源码映射。对于简单替换,开发时可能仍能工作,但断点、错误行号和堆栈可能指向转换后的代码,而不是原始源码。

生产级转换通常使用 magic-string

import MagicString from 'magic-string'
import type { Plugin } from 'vite'

export function replaceWithSourceMap(): Plugin {
  return {
    name: 'replace-with-source-map',

    transform(code, id) {
      if (!id.endsWith('.ts')) {
        return null
      }

      const s = new MagicString(code)
      const index = code.indexOf('__APP_VERSION__')

      if (index === -1) {
        return null
      }

      s.overwrite(index, index + '__APP_VERSION__'.length, '1.2.3')

      return {
        code: s.toString(),
        map: s.generateMap({ hires: true }),
      }
    },
  }
}

源码映射建立了“生成代码位置 → 原始代码位置”的关系。没有映射时,转换插件越复杂,调试成本越高。

七、HMR:模块更新如何到达浏览器

1. HMR 的定义

HMR,即 Hot Module Replacement,是开发服务器在源码变化后,只更新受影响模块而不重新加载整个页面的机制。

一次典型的 HMR 流程包括:

  1. 文件系统报告文件变化;
  2. Vite 找到受影响的模块;
  3. Vite 使旧模块缓存或转换结果失效;
  4. Vite 通过 WebSocket 向浏览器发送更新;
  5. 浏览器请求新的模块代码;
  6. 模块自身或框架运行时决定如何应用更新。

HMR 不是“插件调用一个函数就自动完成”。插件必须正确声明变化影响了哪些模块,客户端模块也必须具备可接受更新的边界。

2. Vue 组件为什么通常可以热更新

Vue SFC 经由 Vue 插件处理后,会生成组件模块、模板模块、样式模块等模块。Vue 的运行时和 Vite 注入的 HMR 代码共同维护组件状态和更新边界。

例如:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

修改模板时,通常只替换渲染相关逻辑;修改脚本时,可能触发组件状态重建。具体保留哪些状态取决于 Vue 的 HMR 处理和修改内容。

3. 自定义模块接受 HMR

对于普通 JavaScript/TypeScript 模块,可以使用 Vite 注入的 import.meta.hot

export let currentMessage = 'initial'

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      currentMessage = newModule.currentMessage
    }
  })
}

import.meta.hot 只在 Vite 开发服务器环境中存在,生产构建中通常会被移除或替换为不可用分支,因此必须进行条件判断。

accept 的含义是:当前模块声明自己可以接收更新。没有这类边界时,Vite 会沿导入链向上寻找可以接受更新的模块;如果找不到,可能执行整页刷新。

4. HMR 与虚拟模块的完整路径

对于前面的 app.config.json

sequenceDiagram
    participant FS as 文件系统
    participant V as Vite 开发服务器
    participant MG as ModuleGraph
    participant WS as WebSocket
    participant B as 浏览器

    FS->>V: app.config.json 发生变化
    V->>V: 调用 handleHotUpdate
    V->>MG: 找到 \0virtual:app-config
    V->>MG: invalidateModule
    V->>WS: 发送虚拟模块更新
    WS->>B: 通知 HMR 客户端
    B->>V: 请求新的虚拟模块代码
    V->>V: load 重新读取 JSON
    V-->>B: 返回新模块
    B->>B: 执行模块更新或触发页面刷新

其中最容易遗漏的是模块图失效。即使 load 每次都会读取最新文件,如果旧模块仍被缓存,浏览器或 Vite 可能继续使用旧结果。invalidateModule 的作用就是告诉开发服务器:这个模块的旧处理结果不再可靠。

5. 什么时候应该整页刷新

如果模块状态复杂,或更新影响了全局初始化逻辑,整页刷新可能更可靠:

handleHotUpdate({ file, server }) {
  if (file.endsWith('app.config.json')) {
    server.ws.send({ type: 'full-reload' })
  }
}

这仍然是 Vite 的开发更新机制,但严格说不是精细的模块 HMR,而是 full reload。

取舍如下:

  • 返回具体模块:更新范围小,状态保留可能更多,但插件必须正确维护模块图;
  • full-reload:实现简单、行为稳定,但页面状态会丢失;
  • 自定义 WebSocket 事件:适用于不希望重新导入模块、而是让应用自行响应配置变化的场景。

自定义事件示例:

server.ws.send({
  type: 'custom',
  event: 'app-config-updated',
  data: { source: 'app.config.json' },
})

客户端:

if (import.meta.hot) {
  import.meta.hot.on('app-config-updated', (data) => {
    console.log('配置已变化', data)
  })
}

自定义事件不是模块替换。它只传递消息,应用必须自己决定如何更新状态。

八、生产构建与开发服务器不是同一个环境

1. configureServer 只存在于开发服务器

configureServer(server) {
  server.middlewares.use('/__health', (_req, res) => {
    res.statusCode = 200
    res.end('ok')
  })
}

这个 Hook 可以访问开发服务器中间件、文件监听器、模块图和 WebSocket。但生产构建时没有同一个开发服务器对象,因此不能在 build 阶段依赖它完成关键产物生成。

2. 构建阶段仍然需要 resolveIdload

前面的虚拟模块如果只使用 apply: 'serve',那么 npm run build 时不会存在:

import appConfig from 'virtual:app-config'

要让生产构建也能导入该模块,应移除:

apply: 'serve'

并让 load 在构建阶段读取配置。此时应考虑:

  • 配置文件是否存在;
  • 配置是否合法;
  • 构建时配置是否与部署环境一致;
  • 是否把敏感信息打进前端产物。

开发服务器 HMR 逻辑可以保留在 configureServerhandleHotUpdate 中,因为这些 Hook 只在开发环境有意义。

3. closeBundle 不等于“每次请求结束”

closeBundle() {
  // 构建结束后执行一次清理
}

它属于构建生命周期,不是开发服务器每次模块请求后的回调。若插件创建了文件句柄、子进程或临时目录,应在适当生命周期中释放资源;不能把 closeBundle 当作 HTTP 请求级别的清理钩子。

九、错误处理:在正确的阶段失败

插件错误最好尽早、明确地失败。

1. 配置解析错误

前面的 handleHotUpdate 中先调用:

readAppConfig(configFile)

这样做有两个原因:

  1. JSON 格式错误时,更新不会被静默推送;
  2. 错误可以直接指向配置文件,而不是等浏览器执行生成代码时才出现模糊错误。

错误可以包含文件路径和字段信息:

function readAppConfig(file: string): AppConfig {
  let value: unknown

  try {
    value = JSON.parse(fs.readFileSync(file, 'utf8'))
  } catch (error) {
    throw new Error(`无法解析 ${file}:${String(error)}`)
  }

  if (
    typeof value !== 'object' ||
    value === null ||
    typeof (value as Record<string, unknown>).apiBaseUrl !== 'string'
  ) {
    throw new Error(`配置字段 apiBaseUrl 必须是字符串:${file}`)
  }

  return value as AppConfig
}

2. 不应吞掉异常

错误写法:

try {
  return readAppConfig(configFile)
} catch {
  return {}
}

这会让插件继续生成一个看似合法但实际上错误的模块,最终导致应用以错误配置运行。除非产品明确要求“配置不可用时使用安全默认值”,否则应抛出带上下文的异常。

3. 开发环境和构建环境可以采用不同策略

开发时配置文件暂时不存在,可能希望显示清晰错误:

throw new Error(
  `缺少配置文件 ${configFile},请创建该文件后重试`,
)

生产构建时则通常应直接失败,避免生成一个无法正确运行的前端产物。是否使用默认值必须是明确的产品行为,而不是插件为了避免报错而擅自决定。

十、调试插件的具体方法

1. 使用 Vite 调试日志

可以运行:

npm run dev -- --debug

也可以针对某类调试信息:

npm run dev -- --debug plugin

不同 Vite 版本对调试分类和输出细节可能存在变化,因此不要把某一条日志格式当成稳定 API。调试日志适合观察:

  • 插件是否被加载;
  • 插件名称和顺序;
  • 配置解析结果;
  • 模块请求和转换过程;
  • HMR 更新是否被触发。

2. 在 Hook 中记录关键状态

configResolved(config) {
  config.logger.info(
    `[app-config] root=${config.root}, command=${config.command}`,
  )
},

resolveId(source, importer) {
  if (source === PUBLIC_ID) {
    console.debug('[app-config] resolve', { source, importer })
    return RESOLVED_ID
  }

  return null
},

load(id) {
  if (id === RESOLVED_ID) {
    console.debug('[app-config] load', id)
    // ...
  }

  return null
}

日志应记录“输入 ID、命中的条件、返回结果”,而不是只写“插件执行了”。对于模块插件,最有价值的信息通常是:

source: virtual:app-config
resolved: \0virtual:app-config
importer: /project/src/App.vue

3. 检查 ID 是否被查询参数污染

如果 transform 没有命中,先打印完整的 id

console.log('[transform]', id)

常见实际 ID 可能是:

/src/main.ts
/src/App.vue?vue&type=script&setup=true&lang.ts

这能解释为什么下面的判断失败:

id.endsWith('.vue')

因为真实 ID 以 &lang.ts 结尾,而不是 .vue

4. 检查返回的源码

对于虚拟模块和转换插件,可以临时输出:

const generated = `
  export const value = ${JSON.stringify(value)}
`

console.log('[generated module]', generated)
return generated

重点检查:

  • 字符串是否正确转义;
  • 导出名称是否与类型声明一致;
  • 生成代码是否是合法 JavaScript;
  • 是否意外把密钥或内部路径发送到浏览器。

5. 使用模块图定位 HMR 问题

在开发服务器中可以查看指定模块:

const module = server.moduleGraph.getModuleById(RESOLVED_ID)

server.config.logger.info(
  `[app-config] module exists=${Boolean(module)}`,
)

如果 moduleundefined,通常表示:

  • 页面还没有导入该虚拟模块;
  • resolveId 没有命中;
  • 模块 ID 常量不一致;
  • 模块还没有被 Vite 请求过。

如果模块存在但页面没有更新,应继续检查:

  1. handleHotUpdate 是否被调用;
  2. file 是否是预期的绝对路径;
  3. 是否调用了 invalidateModule
  4. 是否返回了正确模块;
  5. 浏览器控制台是否存在 HMR 接受边界或运行时错误。

6. 使用插件检查工具

Vite 生态中有用于查看插件处理结果的检查类插件,例如 vite-plugin-inspect。它可以展示模块经过哪些插件、每个阶段的源码变化。此类工具适合定位:

  • 插件顺序错误;
  • 某个插件没有命中;
  • 某个转换改变了预期源码;
  • Vue SFC 被拆解后的子模块内容。

它们属于额外开发工具,不是 Vite 核心规范的一部分,使用时应按对应版本文档配置。

十一、常见失败表现与原因

失败一:浏览器报模块找不到

Failed to resolve import "virtual:app-config"

通常原因是 resolveId 没有返回结果:

resolveId(source) {
  if (source === PUBLIC_ID) {
    return RESOLVED_ID
  }

  return null
}

应检查导入字符串是否完全一致,包括大小写和前缀。

失败二:resolveId 命中了,但模块内容为空

通常是 load 判断了错误的 ID:

resolveId() {
  return '\0virtual:app-config'
}

load(id) {
  if (id === 'virtual:app-config') {
    return 'export default {}'
  }

  return null
}

解析阶段返回的是内部 ID,加载阶段也必须判断内部 ID。

失败三:修改文件后没有更新

可能的因果链是:

文件没有被 watcher 监听
    ↓
handleHotUpdate 没有执行
    ↓
模块图没有失效
    ↓
浏览器继续使用旧模块

如果配置文件位于项目根目录外,必须显式监听:

configureServer(server) {
  server.watcher.add(externalConfigFile)
}

如果文件已经被 Vite 监听,仍然可能需要 handleHotUpdate 将它映射到虚拟模块。

失败四:修改配置后页面直接刷新

这不一定是插件错误。可能是:

  • 返回的模块没有被客户端 HMR 接受;
  • 更新沿依赖图向上寻找时找不到边界;
  • Vue 组件或其依赖发生了不适合局部替换的变化;
  • 插件主动发送了 full-reload

应区分“更新没有发生”和“更新发生但最终选择整页刷新”。

失败五:断点位置偏移

如果 transform 返回:

{
  code: transformedCode,
  map: null,
}

复杂转换会导致源码映射缺失。应使用能生成 Source Map 的转换工具,并确保转换链继续传递已有映射,而不是无条件丢弃上游 map。

失败六:插件在构建时访问 server

buildStart() {
  this.server.ws.send(/* ... */)
}

这类写法错误地假设所有阶段都有开发服务器。buildStart 可以在生产构建中运行,不能访问仅由 configureServer 提供的对象。开发服务器逻辑应放在 configureServerhandleHotUpdate 中,并根据 apply 限制执行环境。

十二、一个插件的设计边界

插件可以做很多事,但每种能力都对应不同的稳定性和维护成本。

适合使用 transform 的情况

  • 把一种源码语法转换成另一种语法;
  • 注入编译时常量;
  • 为特定文件增加导出;
  • 生成源码映射并保留调试能力。

不适合通过简单正则替换完成完整语言级转换。遇到嵌套语法、注释、字符串和模板字符串时,应使用 AST 或专用转换工具。

适合使用虚拟模块的情况

  • 将配置、元数据或目录扫描结果暴露给应用;
  • 生成固定的模块 API;
  • 将构建时信息集中封装;
  • 让调用方使用普通 import,而不直接依赖 Node 文件系统。

虚拟模块的接口应尽量稳定。例如:

export interface AppConfig {
  apiBaseUrl: string
  features: Record<string, boolean>
}

比生成一个结构随意变化的默认对象更容易被多个应用模块依赖。

适合使用 HMR 的情况

  • 数据变化可以映射到少量模块;
  • 模块重新执行不会破坏全局状态;
  • 应用能够定义明确的更新边界。

如果更新会影响路由注册、全局依赖注入或启动时初始化,整页刷新可能是更可控的方案。HMR 的目标是缩小反馈范围,不是无条件避免刷新。

十三、把完整插件拆成明确的状态和路径

一个实际插件通常需要维护少量状态:

export function plugin(): Plugin {
  let root = ''
  let configFile = ''
  let resolvedConfig: ResolvedConfig

  return {
    name: 'example',

    configResolved(config) {
      resolvedConfig = config
      root = config.root
      configFile = path.resolve(root, 'app.config.json')
    },

    load(id) {
      // 使用已经解析完成的 configFile
      return null
    },

    configureServer(server) {
      // 使用 server 和 configFile 建立监听
    },
  }
}

状态变化顺序应满足:

插件创建
  ↓
configResolved 设置 root/configFile
  ↓
resolveId/load 使用稳定路径
  ↓
configureServer 建立监听
  ↓
handleHotUpdate 根据路径更新模块
  ↓
closeBundle 或服务器关闭时清理资源

如果在 configResolved 之前读取 configFile,得到的可能是错误路径;如果在 configureServer 中创建监听器却不清理,测试或重复启动时可能产生重复监听。

对于异步 Hook,应避免多个并发调用互相覆盖状态。例如配置文件快速连续保存时,可能出现多次变更事件。插件应保证:

  • 每次 load 都能独立读取并验证当前内容;
  • 不依赖一次事件中的全局临时变量;
  • 发生解析错误时不把半成品写入缓存;
  • 必要时对重复更新进行去重或串行化。

十四、规范保证、常见实现与版本边界

以下行为属于插件接口的核心约定:

  • resolveIdloadtransform 用于参与模块解析、加载和转换;
  • null 表示当前插件不处理;
  • 虚拟模块通常使用公开 ID 加 \0 内部 ID;
  • configureServer 用于开发服务器配置;
  • handleHotUpdate 可以返回受影响模块;
  • import.meta.hot 是 Vite 开发环境中的 HMR 客户端接口。

以下属于常见实现或经验选择,不应当当成所有版本都完全相同的内部细节:

  • 调试日志的具体分类和文本格式;
  • 模块图内部字段的完整结构;
  • 插件之间全部 Hook 的精确并发调度细节;
  • 某些开发服务器内部方法的可用性;
  • Vue SFC 拆分后的查询参数形式。

因此,插件应尽量依赖公开的 Plugin API,而不是依赖未文档化的内部字段。若必须访问 server.moduleGraph 等 Vite 开发服务器能力,应在项目支持的 Vite 版本范围内验证,并在升级 Vite 时重新测试:

npm run type-check
npm run build
npm run dev

同时至少验证:

  1. 普通模块导入是否正常;
  2. 虚拟模块首次加载是否正常;
  3. 配置文件错误是否得到清晰提示;
  4. 修改配置后 HMR 或 full reload 是否符合预期;
  5. 生产构建是否误包含开发服务器逻辑;
  6. 浏览器产物中是否出现不应暴露的配置。

Vite 插件开发的核心不是记住更多 Hook 名称,而是准确维护一条模块数据流:

公开导入名
  → resolveId 得到内部 ID
  → load 产生源码
  → transform 形成最终代码
  → module graph 记录依赖
  → 文件变化触发 invalidate 和 HMR
  → 浏览器重新请求并应用更新

只要每一步的输入、输出、缓存和失败路径都明确,虚拟模块、源码转换和 HMR 就可以组合成可维护的工程能力,而不是依赖“开发服务器碰巧能工作”。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。