Skip to content

第4章 插件系统与 Hook 机制

开篇引言

如果说配置系统定义了 Vite "做什么",那么插件系统就定义了 Vite "怎么做"。Vite 的核心能力——模块解析、代码转换、CSS 处理、HTML 注入、HMR 更新——全部通过插件实现。这不是修辞上的夸张:打开 src/node/plugins/ 目录,你会发现 30 多个内置插件文件,它们组成了 Vite 的全部处理管线。

Vite 的插件系统脱胎于 Rollup,但做了关键性的扩展。Rollup 插件只需要处理构建阶段的事务,而 Vite 插件需要同时覆盖开发服务器和生产构建两个截然不同的场景。开发阶段,模块按需处理,没有打包过程;构建阶段,所有模块被打包成最终产物。这种双模态运行的需求催生了 Vite 独有的 Hook 分类体系:配置钩子、服务器钩子、通用钩子、构建钩子。

更深层的挑战来自 Vite 6 引入的 Environment API。一个插件可能需要在 clientssredge-worker 等多个运行环境中分别执行,每个环境有独立的模块图和插件容器。这意味着插件容器不再是单例,而是每个环境独立一份。

本章将从类型定义出发,沿着"定义 -> 注册 -> 排序 -> 执行"的完整链路,逐层剖析 Vite 的插件系统。我们会深入 pluginContainer.ts——这个灵感来源于 WMR 项目的核心文件,理解它如何在开发服务器中模拟 Rollup 的插件执行环境。

本章要点

  1. Vite 插件类型 Plugin 在 Rolldown 插件基础上扩展了 configconfigureServerhotUpdate 等 Vite 专有钩子
  2. 30+ 内置插件按 pre -> core -> normal -> post 的严格顺序注册
  3. 每个 Hook 独立排序,支持 order: 'pre' | 'post' 控制同名 Hook 的执行优先级
  4. 插件容器 EnvironmentPluginContainer 为每个环境提供独立的 Rollup 兼容执行上下文
  5. enforce 字段将插件分为 'pre'、正常、'post' 三个层级,决定插件在全局管线中的位置
  6. applyToEnvironment 允许插件按环境动态启用或替换

4.1 插件类型定义

4.1.1 从 Rolldown 到 Vite

Vite 插件的类型定义位于 src/node/plugin.ts。核心接口 Plugin 继承自 Rolldown 的 RolldownPlugin

typescript
// src/node/plugin.ts
export interface Plugin<A = any> extends RolldownPlugin<A> {
  enforce?: 'pre' | 'post'
  apply?: 'serve' | 'build' | ((this: void, config: UserConfig, env: ConfigEnv) => boolean)
  applyToEnvironment?: (environment: PartialEnvironment) => boolean | Promise<boolean> | PluginOption

  // Vite 独有的 Hooks
  config?: ObjectHook<(this: ConfigPluginContext, config: UserConfig, env: ConfigEnv) => ...>
  configEnvironment?: ObjectHook<(this: ConfigPluginContext, name: string, config: EnvironmentOptions, env: ConfigEnv) => ...>
  configResolved?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, config: ResolvedConfig) => void | Promise<void>>
  configureServer?: ObjectHook<ServerHook>
  configurePreviewServer?: ObjectHook<PreviewServerHook>
  transformIndexHtml?: IndexHtmlTransform
  handleHotUpdate?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, ctx: HmrContext) => ...>
  hotUpdate?: ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment }, options: HotUpdateOptions) => ...>
  buildApp?: ObjectHook<BuildAppHook>
}

这个继承关系揭示了一个重要的设计决策:Vite 没有发明新的插件格式,而是在 Rolldown 的基础上做增量扩展。这意味着所有 Rolldown/Rollup 插件天然兼容 Vite(前提是它们不强耦合于打包阶段的输出钩子),而 Vite 插件也可以在 Rolldown 构建中直接使用。

4.1.2 三个关键控制字段

enforce 决定插件在全局管线中的位置层级。源码中对此有明确注释:

typescript
/**
 * Plugin invocation order:
 * - alias resolution
 * - `enforce: 'pre'` plugins
 * - vite core plugins
 * - normal plugins
 * - vite build plugins
 * - `enforce: 'post'` plugins
 * - vite build post plugins
 */
enforce?: 'pre' | 'post'

apply 控制插件在哪个命令阶段激活。它支持两种形式:字符串字面量 'serve''build',以及一个接收完整配置的判断函数。这个函数形式让插件可以根据任意条件决定是否启用——比如仅在特定环境变量存在时激活。

applyToEnvironment 是 Environment API 引入的新字段。它在每个环境初始化时被调用,返回 false 则该插件在此环境中不注册,返回 true 则正常注册,返回一个 PluginOption 则用返回的插件替代原插件:

typescript
// src/node/plugin.ts
export async function resolveEnvironmentPlugins(
  environment: PartialEnvironment,
): Promise<Plugin[]> {
  const environmentPlugins: Plugin[] = []
  for (const plugin of environment.getTopLevelConfig().plugins) {
    if (plugin.applyToEnvironment) {
      const applied = await plugin.applyToEnvironment(environment)
      if (!applied) {
        continue  // 跳过此插件
      }
      if (applied !== true) {
        // 用返回的插件替代原插件
        environmentPlugins.push(
          ...((await asyncFlatten(arraify(applied))).filter(Boolean) as Plugin[]),
        )
        continue
      }
    }
    environmentPlugins.push(plugin)
  }
  return environmentPlugins
}

这种设计允许框架(如 Nuxt、Astro)为不同的运行环境提供完全不同的插件实现,而用户无需感知这种差异。

4.1.3 Hook 的两种形态

每个 Hook 都支持两种形态:函数形式和对象形式。对象形式通过 ObjectHook 类型表达:

typescript
// 函数形式
{
  name: 'my-plugin',
  resolveId(source, importer) { ... }
}

// 对象形式——可以指定 order 和 filter
{
  name: 'my-plugin',
  resolveId: {
    order: 'pre',
    filter: { id: /\.custom$/ },
    handler(source, importer) { ... }
  }
}

getHookHandler 工具函数负责统一这两种形态:

typescript
// src/node/plugins/index.ts
export function getHookHandler<T extends ObjectHook<Function>>(
  hook: T,
): HookHandler<T> {
  return (typeof hook === 'object' ? hook.handler : hook) as HookHandler<T>
}

4.2 Hook 分类体系

Vite 的 Hook 可以按生命周期阶段分为四大类。理解这个分类是编写高质量插件的基础。

4.2.1 配置钩子(Config Hooks)

钩子调用时机作用
config配置解析前修改或扩展用户配置
configEnvironment每个环境配置解析时修改特定环境的配置
configResolved配置解析完成后读取最终配置,通常用于存储引用

配置钩子在所有其他操作之前执行,且只执行一次(不按环境重复)。config 钩子的返回值会与现有配置深度合并,这使得插件可以安全地注入默认值而不覆盖用户的显式设置。

4.2.2 服务器钩子(Server Hooks)

configureServer 在开发服务器创建后、内部中间件安装前调用。它的独特之处在于返回值:

typescript
export type ServerHook = (
  this: MinimalPluginContextWithoutEnvironment,
  server: ViteDevServer,
) => (() => void) | void | Promise<(() => void) | void>

如果返回一个函数,该函数会被收集为"后置钩子"(post hook),在所有内部中间件安装完成后执行。这个机制让插件可以选择在内部中间件之前或之后注入自定义中间件。

4.2.3 通用钩子(Universal Hooks)

通用钩子在开发和构建中都会执行,它们直接来自 Rollup/Rolldown 的插件接口:

  • resolveId:将模块标识符解析为文件路径。采用 hookFirst 策略——第一个返回非空结果的插件获胜
  • load:加载模块内容。同样是 hookFirst
  • transform:转换模块代码。采用链式调用——每个插件的输出作为下一个插件的输入

4.2.4 开发专有钩子

hotUpdate 是 Environment API 中新增的钩子,替代旧的 handleHotUpdate。关键区别在于 hotUpdatethis 上下文包含 environment 属性,可以访问当前环境的信息。这使得同一个插件可以为不同环境实现不同的 HMR 策略。

4.3 内置插件的注册顺序

src/node/plugins/index.ts 中的 resolvePlugins 函数定义了所有内置插件和用户插件的精确注册顺序。这是 Vite 最重要的架构决策之一——插件的顺序直接决定了模块的处理流水线。

typescript
// src/node/plugins/index.ts
export async function resolvePlugins(
  config: ResolvedConfig,
  prePlugins: Plugin[],
  normalPlugins: Plugin[],
  postPlugins: Plugin[],
): Promise<Plugin[]> {
  const isBuild = config.command === 'build'
  const isBundled = config.isBundled
  const isWorker = config.isWorker

  return [
    // ======= 预处理阶段 =======
    !isBundled ? optimizedDepsPlugin() : null,
    !isWorker ? watchPackageDataPlugin(config.packageCache) : null,
    !isBundled ? preAliasPlugin(config) : null,
    aliasPlugin(...),                    // 路径别名解析

    // ======= 用户 pre 插件 =======
    ...prePlugins,

    // ======= Vite 核心插件 =======
    modulePreloadPolyfillPlugin(config),
    ...oxcResolvePlugin(...),            // 模块解析(核心)
    htmlInlineProxyPlugin(config),
    cssPlugin(config),
    esbuildBannerFooterCompatPlugin(config),
    oxcRuntimePlugin(),
    oxcPlugin(config),                   // OXC 转换
    nativeJsonPlugin(...),               // JSON 处理
    wasmHelperPlugin(),
    webWorkerPlugin(config),
    assetPlugin(config),
    forwardConsolePlugin(...),

    // ======= 用户 normal 插件 =======
    ...normalPlugins,

    // ======= 后处理阶段 =======
    nativeWasmFallbackPlugin(),
    definePlugin(config),
    cssPostPlugin(config),
    buildHtmlPlugin(config),
    workerImportMetaUrlPlugin(config),
    assetImportMetaUrlPlugin(config),
    ...buildPlugins.pre,
    dynamicImportVarsPlugin(config),
    importGlobPlugin(config),

    // ======= 用户 post 插件 =======
    ...postPlugins,

    // ======= 构建后处理 =======
    ...buildPlugins.post,

    // ======= 开发服务器专有(永远在最后)=======
    ...(isBundled ? [] : [
      clientInjectionsPlugin(config),
      cssAnalysisPlugin(config),
      importAnalysisPlugin(config),
    ]),
  ].filter(Boolean) as Plugin[]
}

4.3.1 注册顺序的设计逻辑

这个顺序背后有严密的逻辑:

  1. 别名解析最先aliasPlugin 在所有其他插件之前,确保 @/components 这样的别名在进入解析管线前被替换为真实路径

  2. 用户 pre 插件紧随其后enforce: 'pre' 的插件在核心插件之前,可以拦截和修改模块标识符

  3. 核心插件群居中间oxcResolvePlugin 是模块解析的核心实现,cssPlugin 处理 CSS 的 @importurl() 引用,oxcPlugin 负责 TypeScript/JSX 转换

  4. 用户 normal 插件在核心之后:普通用户插件能看到经过核心处理后的模块,但在后处理之前

  5. definePlugin 在后段process.env.NODE_ENV 等全局常量替换放在后面,避免干扰其他插件的匹配逻辑

  6. import 分析永远最后importAnalysisPlugin(仅开发模式)扫描所有 import 语句并注入 HMR 客户端代码,必须在所有代码转换完成后执行

4.3.2 用户插件的分桶

用户在 vite.config.ts 中配置的 plugins 数组,会在 resolveConfig 阶段根据 enforce 字段被分为三个桶:

typescript
// 配置解析阶段的分桶逻辑(简化)
const prePlugins: Plugin[] = []
const normalPlugins: Plugin[] = []
const postPlugins: Plugin[] = []

for (const plugin of userPlugins) {
  if (plugin.enforce === 'pre') prePlugins.push(plugin)
  else if (plugin.enforce === 'post') postPlugins.push(plugin)
  else normalPlugins.push(plugin)
}

这三个桶分别插入到上面 resolvePlugins 返回数组的对应位置,实现了用户插件与内置插件的有序交织。

4.4 Hook 执行排序机制

插件的全局注册顺序决定了基础执行序列,但每个 Hook 还可以通过 order 属性做进一步调整。getSortedPluginsByHook 实现了这个双层排序:

typescript
// src/node/plugins/index.ts
export function getSortedPluginsByHook<K extends keyof Plugin>(
  hookName: K,
  plugins: readonly Plugin[],
): PluginWithRequiredHook<K>[] {
  const sortedPlugins: Plugin[] = []
  let pre = 0, normal = 0, post = 0

  for (const plugin of plugins) {
    const hook = plugin[hookName]
    if (hook) {
      if (typeof hook === 'object') {
        if (hook.order === 'pre') {
          sortedPlugins.splice(pre++, 0, plugin)
          continue
        }
        if (hook.order === 'post') {
          sortedPlugins.splice(pre + normal + post++, 0, plugin)
          continue
        }
      }
      sortedPlugins.splice(pre + normal++, 0, plugin)
    }
  }
  return sortedPlugins as PluginWithRequiredHook<K>[]
}

这段代码使用三个计数器 prenormalpost 维护三个区段的边界,通过 splice 将每个拥有该 Hook 的插件插入到正确的区段中,并保持区段内的相对顺序不变。

这意味着排序是两层嵌套的:

  1. 外层enforce 决定插件在全局管线中的位置
  2. 内层:每个 Hook 的 order 在已排好的插件列表中做进一步重排

一个 enforce: 'pre' 的插件的 transform 钩子设置了 order: 'post',它的 transform 依然会在 enforce: 'post' 的插件之前执行——因为 order 的排序作用域是全局插件列表,而非某个 enforce 层级内部。

4.4.1 Hook 执行策略

不同的 Hook 采用不同的执行策略:

策略说明代表 Hook
hookFirst第一个返回非空结果的胜出resolveId
hookSequential串行执行,前一个的输出传给下一个transform
hookParallel并行执行,忽略返回值buildStart, buildEnd

resolveId 的 hookFirst 策略意味着一旦某个插件成功解析了模块 ID,后续插件的 resolveId 不再被调用。这也是为什么别名插件必须排在最前面——它需要在模块解析管线的入口处将别名替换为真实路径。

4.5 插件容器(PluginContainer)

4.5.1 设计起源

src/node/server/pluginContainer.ts 文件头部的注释揭示了它的起源:

This file is refactored into TypeScript based on
https://github.com/preactjs/wmr/blob/main/packages/wmr/src/lib/rollup-plugin-container.js

WMR(Web Modules Runtime)是 Preact 团队的 bundleless 开发工具。Vite 团队从 WMR 中提取了"在开发服务器中模拟 Rollup 插件执行环境"的核心思想,并将其发展为成熟的 EnvironmentPluginContainer

4.5.2 EnvironmentPluginContainer 类

每个 DevEnvironment 拥有一个独立的 EnvironmentPluginContainer 实例。它在环境初始化时创建:

typescript
// src/node/server/environment.ts
async init(options?: { watcher?: FSWatcher; previousInstance?: DevEnvironment }): Promise<void> {
  this._pluginContainer = await createEnvironmentPluginContainer(
    this,
    this.config.plugins,
    options?.watcher,
  )
}

EnvironmentPluginContainer 的核心职责是将插件的 resolveIdloadtransform 等通用 Hook 以 Rollup 兼容的方式串联起来:

typescript
class EnvironmentPluginContainer<Env extends Environment = Environment> {
  private _pluginContextMap = new Map<Plugin, PluginContext>()
  private _resolvedRollupOptions?: InputOptions
  private _processesing = new Set<Promise<any>>()
  private _seenResolves: Record<string, true | undefined> = {}

  getSortedPluginHooks: PluginHookUtils['getSortedPluginHooks']
  getSortedPlugins: PluginHookUtils['getSortedPlugins']
  moduleGraph: EnvironmentModuleGraph | undefined
  watchFiles: Set<string> = new Set()

  constructor(
    public environment: Env,
    public plugins: readonly Plugin[],
    public watcher?: FSWatcher,
    autoStart = true,
  ) {
    this.minimalContext = new MinimalPluginContext(
      { ...basePluginContextMeta, watchMode: true },
      environment,
    )
    const utils = createPluginHookUtils(plugins)
    this.getSortedPlugins = utils.getSortedPlugins
    this.getSortedPluginHooks = utils.getSortedPluginHooks
    this.moduleGraph = environment.mode === 'dev' ? environment.moduleGraph : undefined
  }
}

关键设计细节:

  • _pluginContextMap:每个插件拥有独立的 PluginContext 实例,通过 WeakMap 缓存,避免重复创建
  • _processesing:追踪所有未完成的 Hook Promise,在服务器关闭时等待它们全部完成
  • _seenResolves:去重已打印的 resolve 调试日志,避免控制台被淹没
  • moduleGraph:只有 mode === 'dev' 的环境才有模块图

4.5.3 resolveId 执行流程

resolveId 是最复杂的 Hook 执行流程之一,因为它涉及递归解析和跳过逻辑:

typescript
async resolveId(
  rawId: string,
  importer: string | undefined,
  options?: { skip?: Set<Plugin>; skipCalls?: readonly SkipInformation[]; ... },
): Promise<PartialResolvedId | null> {
  // 确保 buildStart 已执行
  if (!this._started) {
    this.buildStart()
    await this._buildStartPromise
  }

  const mergedSkip = new Set<Plugin>(options?.skip)
  for (const call of options?.skipCalls ?? []) {
    if (call.called || (call.id === rawId && call.importer === importer)) {
      mergedSkip.add(call.plugin)
    }
  }

  for (const plugin of this.getSortedPlugins('resolveId')) {
    if (this._closed) throwClosedServerError()
    if (mergedSkip?.has(plugin)) continue

    // 检查过滤器
    const filter = getCachedFilterForPlugin(plugin, 'resolveId')
    if (filter && !filter(rawId)) continue

    const result = await handler.call(ctx, rawId, importer, normalizedOptions)
    if (!result) continue

    id = typeof result === 'string' ? result : result.id
    break  // hookFirst: 第一个非空结果即返回
  }
  return id ? { ...partial, id: normalizePath(id) } : null
}

skipCalls 机制是为了防止无限递归:当插件 A 的 resolveId 内部调用 this.resolve()(即再次触发 resolveId 管线)时,需要跳过插件 A 自身,否则会陷入死循环。

4.5.4 transform 链式执行

transform Hook 的执行策略与 resolveId 不同——它是链式的,每个插件的输出成为下一个插件的输入:

typescript
async transform(code: string, id: string, options?: { inMap?: SourceMap; moduleType?: string }): Promise<...> {
  const ctx = new TransformPluginContext(this, id, code, inMap)

  for (const plugin of this.getSortedPlugins('transform')) {
    if (this._closed) throwClosedServerError()

    const filter = getCachedFilterForPlugin(plugin, 'transform')
    if (filter && !filter(id, code, optionsWithSSR.moduleType)) continue

    const result = await handler.call(ctx, code, id, optionsWithSSR)
    if (!result) continue

    if (isObject(result)) {
      if (result.code !== undefined) {
        code = result.code           // 更新 code 传给下一个插件
        if (result.map) {
          ctx.sourcemapChain.push(result.map)  // 收集 sourcemap
        }
      }
    } else {
      code = result
    }
  }

  return { code, map: ctx._getCombinedSourcemap() }
}

注意 sourcemapChain 的处理:每个插件产生的 sourcemap 被推入链中,最后通过 _getCombinedSourcemap 合并为一个完整的 sourcemap。这保证了即使模块经过多个插件的多次转换,调试时仍然能准确映射回原始源码。

4.5.5 hookParallel 并行执行

buildStartbuildEnd 等钩子使用并行执行策略,但也支持 sequential 标记强制串行:

typescript
private async hookParallel<H extends AsyncPluginHooks & ParallelPluginHooks>(
  hookName: H,
  context: (plugin: Plugin) => ThisType<FunctionPluginHooks[H]>,
  args: (plugin: Plugin) => Parameters<FunctionPluginHooks[H]>,
): Promise<void> {
  const parallelPromises: Promise<unknown>[] = []
  for (const plugin of this.getSortedPlugins(hookName)) {
    const hook = plugin[hookName]
    const handler = getHookHandler(hook)
    if ((hook as { sequential?: boolean }).sequential) {
      await Promise.all(parallelPromises)
      parallelPromises.length = 0
      await handler.apply(context(plugin), args(plugin))
    } else {
      parallelPromises.push(handler.apply(context(plugin), args(plugin)))
    }
  }
  await Promise.all(parallelPromises)
}

这种"默认并行,按需串行"的设计在性能和正确性之间取得了平衡。

4.6 插件上下文(PluginContext)

4.6.1 层次结构

插件上下文遵循清晰的继承层次:

  • BasicMinimalPluginContext:最基础的上下文,只提供日志方法和 meta 信息
  • MinimalPluginContext:增加了 environment 属性,让插件能访问当前环境
  • PluginContext:完整的插件上下文,实现了 Rollup 的 PluginContext 接口,提供 resolveloadparseaddWatchFile 等方法
  • TransformPluginContext:在 PluginContext 基础上增加 sourcemap 链管理

4.6.2 meta 信息

PluginContext.meta 携带了运行时版本信息:

typescript
export const basePluginContextMeta = {
  viteVersion,
  rollupVersion,
  rolldownVersion,
}

这使得插件可以在运行时检测 Vite 版本并做适配:

typescript
// 插件中的版本检测
transform(code, id) {
  if (this.meta.viteVersion) {
    // Vite 环境特有的处理
  }
}

4.6.3 this.resolve() 的跳过机制

PluginContext.resolve() 的核心复杂性在于 skipSelfskipCalls 的处理:

typescript
async resolve(id: string, importer?: string, options?: { skipSelf?: boolean }): Promise<ResolvedId | null> {
  let skipCalls: readonly SkipInformation[] | undefined
  if (options?.skipSelf === false) {
    skipCalls = this._resolveSkipCalls  // 不跳过自己
  } else if (this._resolveSkipCalls) {
    const skipCallsTemp = [...this._resolveSkipCalls]
    // 查找当前插件对相同 id+importer 的调用记录
    const sameCallIndex = this._resolveSkipCalls.findIndex(
      (c) => c.id === id && c.importer === importer && c.plugin === this._plugin,
    )
    if (sameCallIndex !== -1) {
      skipCallsTemp[sameCallIndex] = { ...skipCallsTemp[sameCallIndex], called: true }
    } else {
      skipCallsTemp.push({ id, importer, plugin: this._plugin })
    }
    skipCalls = skipCallsTemp
  } else {
    skipCalls = [{ id, importer, plugin: this._plugin }]
  }
  return this._container.resolveId(id, importer, { skipCalls, ... })
}

SkipInformation 不仅记录了要跳过的插件,还记录了 idimporter 的组合。只有当 idimporter 都匹配时才跳过,这允许同一个插件在处理不同模块时互不干扰。

4.7 Hook 过滤器(Plugin Filter)

Vite 引入了 Hook 过滤器机制,让插件可以声明式地指定它只关心哪些模块。这避免了不必要的 Hook 调用,对大型项目的性能有显著影响。

typescript
// resolveId 过滤器:只处理 .custom 后缀的模块
{
  name: 'custom-resolve',
  resolveId: {
    filter: { id: /\.custom$/ },
    handler(source) { ... }
  }
}

// transform 过滤器:支持 id、code、moduleType 三个维度
{
  name: 'custom-transform',
  transform: {
    filter: {
      id: /\.vue$/,
      code: /defineComponent/,
      moduleType: ['js', 'ts'],
    },
    handler(code, id) { ... }
  }
}

过滤器通过 getCachedFilterForPlugin 做了缓存优化——每个插件的每个 Hook 只创建一次过滤器函数:

typescript
const filterForPlugin = new WeakMap<Plugin, FilterForPluginValue>()

export function getCachedFilterForPlugin(plugin: Plugin, hookName: string) {
  let filters = filterForPlugin.get(plugin)
  if (filters && hookName in filters) {
    return filters[hookName]  // 缓存命中
  }
  // 创建过滤器并缓存
  const rawFilter = extractFilter(plugin[hookName])
  filters[hookName] = createIdFilter(rawFilter)
  return filters[hookName]
}

4.8 设计决策

4.8.1 为什么不直接用 Rollup 在开发模式执行插件?

Rollup 的插件执行假设"先打包所有模块,再生成输出"。开发服务器是按需处理单个模块,不存在完整的打包阶段。如果直接用 Rollup,要么每次请求都重新打包(太慢),要么放弃 Rollup 的输出阶段钩子(信息缺失)。

Vite 的方案是实现一个轻量级的"伪 Rollup 环境"——EnvironmentPluginContainer。它只实现输入阶段的钩子(resolveIdloadtransform),跳过输出阶段(renderChunkgenerateBundle),这恰好匹配了开发服务器的需求。

4.8.2 为什么每个环境需要独立的插件容器?

考虑一个场景:服务器端需要将 import.meta.env.SSR 替换为 true,客户端需要替换为 false。如果共享一个插件容器,transform Hook 无法区分当前请求来自哪个环境。

独立的插件容器意味着每个环境有独立的 moduleGraph、独立的 PluginContext、独立的 watchFiles 集合。当 environment.pluginContainer.transform() 被调用时,插件通过 this.environment 明确知道自己在哪个环境中运行。

4.8.3 为什么 importAnalysisPlugin 必须排在最后?

importAnalysisPlugin 负责扫描 ESM 的 import 语句,将裸模块名重写为浏览器可访问的 URL(如 import React from 'react' -> import React from '/node_modules/.vite/deps/react.js'),并注入 HMR 客户端代码。如果它不在最后执行,后续的 transform 插件可能会修改已重写的 import 路径,或在 HMR 注入代码中触发意外的转换。

4.9 编写自定义插件

理解了内部机制后,编写自定义插件就有了清晰的心智模型。以下是一个完整的插件示例,展示了常用模式:

typescript
import type { Plugin } from 'vite'

export function myPlugin(): Plugin {
  let config: ResolvedConfig

  return {
    name: 'vite-plugin-example',
    enforce: 'pre',  // 在核心插件之前执行

    // 1. 配置阶段:注入默认配置
    config(userConfig) {
      return {
        define: {
          __MY_PLUGIN__: JSON.stringify(true),
        },
      }
    },

    // 2. 存储最终配置
    configResolved(resolved) {
      config = resolved
    },

    // 3. 配置开发服务器
    configureServer(server) {
      // 返回后置钩子——在内部中间件之后注入
      return () => {
        server.middlewares.use((req, res, next) => {
          if (req.url === '/my-plugin-api') {
            res.writeHead(200, { 'Content-Type': 'application/json' })
            res.end(JSON.stringify({ ok: true }))
          } else {
            next()
          }
        })
      }
    },

    // 4. 模块解析——虚拟模块模式
    resolveId(id) {
      if (id === 'virtual:my-module') {
        return '\0virtual:my-module'  // \0 前缀标记虚拟模块
      }
    },

    // 5. 加载虚拟模块的内容
    load(id) {
      if (id === '\0virtual:my-module') {
        return `export const version = ${JSON.stringify(config.viteVersion)}`
      }
    },

    // 6. 转换——使用过滤器提升性能
    transform: {
      filter: { id: /\.(ts|js)$/ },
      handler(code, id) {
        if (code.includes('__TIMESTAMP__')) {
          return code.replace(/__TIMESTAMP__/g, String(Date.now()))
        }
      },
    },

    // 7. HMR 处理
    hotUpdate({ modules, file }) {
      if (file.endsWith('.custom')) {
        // 自定义 HMR 逻辑
        this.environment.hot.send({ type: 'full-reload' })
        return []  // 阻止默认 HMR
      }
    },
  }
}

4.9.1 虚拟模块模式

上面示例中的 \0 前缀是 Rollup 的约定:以 \0 开头的模块 ID 表示虚拟模块(不对应文件系统中的文件)。Vite 的 resolveIdload 钩子遵循这个约定,其他插件和中间件看到 \0 前缀会自动跳过。

4.9.2 环境感知插件

利用 applyToEnvironment 可以为不同环境提供不同的插件行为:

typescript
export function envAwarePlugin(): Plugin {
  return {
    name: 'env-aware',
    applyToEnvironment(environment) {
      if (environment.name === 'ssr') {
        // 为 SSR 环境返回一个定制插件
        return {
          name: 'env-aware:ssr',
          transform(code, id) {
            return code.replace(/import\.meta\.env\.SSR/g, 'true')
          },
        }
      }
      return true  // 其他环境使用原始插件
    },
    transform(code, id) {
      return code.replace(/import\.meta\.env\.SSR/g, 'false')
    },
  }
}

4.10 小结

Vite 的插件系统是一个精心设计的分层架构。在类型层面,它通过继承 Rolldown 的 Plugin 接口并扩展 Vite 专有钩子,实现了与 Rollup 生态的广泛兼容。在注册层面,resolvePlugins 将 30 多个内置插件与用户插件编织成严格有序的处理管线。在排序层面,enforce 做粗粒度分层,order 做细粒度调整,双层排序覆盖了绝大多数排序需求。在执行层面,EnvironmentPluginContainer 为每个环境提供独立的 Rollup 兼容上下文,通过 hookFirst、hookSequential、hookParallel 三种策略驱动不同类型的 Hook。

这套体系的核心智慧在于:不发明新标准,而是站在 Rollup 的肩膀上做最小化扩展。这让 Vite 从诞生之日起就拥有了整个 Rollup 插件生态的资产,同时通过 enforceapplyapplyToEnvironment 等少数几个字段优雅地解决了开发服务器和多环境的独特需求。

基于 VitePress 构建