Vite 设计与实现

第4章 插件系统与 Hook 机制

作者 杨艺韬 · 10,940 字

第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

// 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 构建中直接使用。

classDiagram
    class RolldownPlugin {
        +name: string
        +options()
        +buildStart()
        +resolveId()
        +load()
        +transform()
        +buildEnd()
        +closeBundle()
        +watchChange()
    }
    class VitePlugin {
        +enforce: pre | post
        +apply: serve | build
        +applyToEnvironment()
        +config()
        +configEnvironment()
        +configResolved()
        +configureServer()
        +configurePreviewServer()
        +transformIndexHtml()
        +handleHotUpdate()
        +hotUpdate()
        +buildApp()
    }
    RolldownPlugin <|-- VitePlugin : extends

4.1.2 三个关键控制字段

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

/**
 * 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 则用返回的插件替代原插件:

// 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 类型表达:

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

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

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

// 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 可以按生命周期阶段分为四大类。理解这个分类是编写高质量插件的基础。

graph TB
    subgraph "配置阶段 Config Hooks"
        config["config()"]
        configEnv["configEnvironment()"]
        configResolved["configResolved()"]
    end
    subgraph "服务器阶段 Server Hooks"
        configureServer["configureServer()"]
        configurePreviewServer["configurePreviewServer()"]
    end
    subgraph "通用 Hook Universal Hooks"
        buildStart["buildStart()"]
        resolveId["resolveId()"]
        load["load()"]
        transform["transform()"]
        buildEnd["buildEnd()"]
        closeBundle["closeBundle()"]
    end
    subgraph "开发专有 Dev-Only Hooks"
        hotUpdate["hotUpdate()"]
        handleHotUpdate["handleHotUpdate()"]
        transformIndexHtml["transformIndexHtml()"]
    end

    config --> configEnv --> configResolved
    configResolved --> configureServer
    configureServer --> buildStart
    buildStart --> resolveId --> load --> transform
    transform -.-> hotUpdate
    transform -.-> transformIndexHtml
    transform --> buildEnd --> closeBundle

4.2.1 配置钩子(Config Hooks)

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

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

4.2.2 服务器钩子(Server Hooks)

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

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 最重要的架构决策之一——插件的顺序直接决定了模块的处理流水线。

// 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[]
}
graph LR
    subgraph "Layer 1: Pre-Processing"
        A1[optimizedDepsPlugin]
        A2[watchPackageDataPlugin]
        A3[preAliasPlugin]
        A4[aliasPlugin]
    end

    subgraph "Layer 2: User Pre Plugins"
        B1["...prePlugins (enforce: pre)"]
    end

    subgraph "Layer 3: Vite Core"
        C1[oxcResolvePlugin]
        C2[cssPlugin]
        C3[oxcPlugin]
        C4[jsonPlugin]
        C5[assetPlugin]
    end

    subgraph "Layer 4: User Normal"
        D1["...normalPlugins"]
    end

    subgraph "Layer 5: Post-Processing"
        E1[definePlugin]
        E2[cssPostPlugin]
        E3[dynamicImportVarsPlugin]
        E4[importGlobPlugin]
    end

    subgraph "Layer 6: User Post & Dev-Only"
        F1["...postPlugins (enforce: post)"]
        F2[importAnalysisPlugin]
    end

    A4 --> B1 --> C1 --> D1 --> E1 --> F1

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 字段被分为三个桶:

// 配置解析阶段的分桶逻辑(简化)
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 实现了这个双层排序:

// 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 的插件插入到正确的区段中,并保持区段内的相对顺序不变。

graph TD
    A["全部插件列表(按 resolvePlugins 的注册顺序)"] --> B{遍历每个插件}
    B -->|"hook.order === 'pre'"| C["插入到 pre 区段末尾"]
    B -->|"无 order 或 order === 'normal'"| D["插入到 normal 区段末尾"]
    B -->|"hook.order === 'post'"| E["插入到 post 区段末尾"]
    C --> F["最终排序:pre 区段 | normal 区段 | post 区段"]
    D --> F
    E --> F
    F --> G["区段内保持原始注册顺序"]

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

  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 实例。它在环境初始化时创建:

// 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 兼容的方式串联起来:

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 执行流程之一,因为它涉及递归解析和跳过逻辑:

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 不同——它是链式的,每个插件的输出成为下一个插件的输入:

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 标记强制串行:

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 层次结构

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

classDiagram
    class BasicMinimalPluginContext {
        +meta: PluginContextMeta
        +debug()
        +info()
        +warn()
        +error()
    }
    class MinimalPluginContext {
        +environment: Environment
    }
    class PluginContext {
        +_plugin: Plugin
        +_container: EnvironmentPluginContainer
        +resolve()
        +load()
        +parse()
        +addWatchFile()
        +getModuleInfo()
        +emitFile()
    }
    class TransformPluginContext {
        +sourcemapChain: SourceMap[]
        +_getCombinedSourcemap()
    }

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

4.6.2 meta 信息

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

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

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

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

4.6.3 this.resolve() 的跳过机制

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

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 调用,对大型项目的性能有显著影响。

// 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 只创建一次过滤器函数:

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 编写自定义插件

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

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 可以为不同环境提供不同的插件行为:

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.9.3 src/node/plugins/ 29 个文件 13984 行的真实尺寸分布

把内置插件目录按文件大小排序、前 5 大占比 52%——

文件占比角色
css.ts355225.4%全目录最重——PreProcessor 分派(Sass/Less/Stylus)、CSS Modules、PostCSS pipeline、@import 递归、HMR sourcemap
html.ts160511.5%HTML 入口解析、<script> / <link> 重写、虚拟 html proxy
resolve.ts12438.9%exports / imports / subpath / alias / .vue / .ts 隐式扩展名
importAnalysis.ts11268.1%开发模式改写所有 import 到 /@fs/ 绝对路径、SSR 重写、HMR 注入
importAnalysisBuild.ts5774.1%生产构建版的 import 改写(加 preload、chunk split hints)

末端——json.ts 19 行、reporter.ts 26 行、modulePreloadPolyfill.ts 37 行——三个文件加起来不到 100 行——插件内部极度不均衡

三点非显然的观察——

  1. css.ts 一个文件占 1/4——CSS 的复杂度远超直觉——因为要同时处理开发(原样提供 CSS 让浏览器解析 HMR)+ 生产(把 CSS 打包到 asset 并做 tree shaking)+ CSS Modules(scoped class 名生成)+ 3 种预处理器(Sass/Less/Stylus)+ PostCSS + @import——这是用户最常用但最少看到源码的部分
  2. resolve.ts 1243 行居然是第 3 大——Node.js 的模块解析算法(package.json exports 字段、subpath imports、condition branches)的完整实现就是这么重——Rolldown 有自己的 resolver、但 Vite 在 dev 模式下需要可覆盖的 JS 版本来配合 alias/preAlias/optimized deps
  3. index.ts 只有 262 行——就是本章 §4.3 讨论的 resolvePlugins 编排文件——29 个插件的注册顺序 + 条件分支 + 环境判定全在这 262 行里——再一次印证 “核心编排文件必须轻” 的设计纪律

4.9.4 内置插件的完整文件清单(截至 vitejs/vite main,2026-04-22)

§4.9.3 按体积排序了前 5 大文件。为了让读者对 “30+ 内置插件” 这句话有颗粒化的认识,把 packages/vite/src/node/plugins/29 个 .ts 文件按字母序列出,并给出每个文件在插件管线中的定位标签(pre/core/post/dev/build-only)——这样下次你在 issue 或 PR 里看到某个文件名时,能立刻在脑中对应到它在 §4.3 resolvePlugins 的哪一层。

文件定位一句话职责
asset.tscoreimport url from './logo.png' 的资源管线,产出 URL 字符串或 base64
assetImportMetaUrl.tspost识别 new URL('./x.png', import.meta.url) 模式,走 asset 管线
clientInjections.tsdev only注入 __BASE____HMR_PROTOCOL__ 等客户端运行时常量
css.tscoreCSS 全链路(预处理器 / Modules / PostCSS / @import),§4.9.3 单文件 25.4%
define.tspostprocess.env.NODE_ENV 等常量字面量替换进代码
dynamicImportVars.tspost动态 import(\./pages/${name}.js`)` 展开为 glob 候选集
esbuild.tscoreTS/JSX 语法降级(在未迁 OXC 的分支里)与 esbuild 转换兜底
esbuildBannerFooterCompatPlugin.tscore兼容 banner / footer 字段在 Rolldown 下的注入差异
forwardConsole.tscore开发态把浏览器 console 事件转发回终端(可配置)
html.tspreindex.html 入口解析、<script> 收集、虚拟 html proxy 模块
importAnalysis.tsdev post所有代码转换完成后扫 import,重写裸模块名 + 注入 HMR 客户端
importAnalysisBuild.tsbuild post生产构建版 import 扫描,产 preload 清单与 chunk hint
importMetaGlob.tspostimport.meta.glob('./pages/*.vue') 展开为具体 import 字面量
index.ts编排resolvePlugins 主编排,§4.3 的核心 262 行
json.tscore.json 文件作为 ES 模块导入,命名导出单字段
license.tsbuild only产物中收集第三方 license 注释,输出到独立 banner
manifest.tsbuild only产出 manifest.json 供服务端模板按 hash 文件名引用
modulePreloadPolyfill.tscore<link rel="modulepreload"> 在旧浏览器的 polyfill 注入
optimizedDeps.tspre接管 /@id/__vite-optimize-deps__/* 请求,对接依赖预构建
oxc.tscoreOXC 做 TS/JSX 转换(替代 esbuild 的新路径)
pluginFilter.ts工具getCachedFilterForPlugincreateIdFilter,§4.7 的实现
preAlias.tspre在 alias 之前,处理 optimized deps 的反向别名
prepareOutDir.tsbuild only构建前清理 outDir,并与 emptyOutDir 策略交互
reporter.tsbuild only终端打印产物体积、gzip/brotli 尺寸、时间统计
resolve.tscoreNode 风格解析,§4.9.3 单文件 8.9%
terser.tsbuild onlyTerser 压缩分支(与默认 esbuild 压缩并存)
wasm.tscore.wasm 文件作为模块(?init / 默认导出)处理
worker.tscorenew Worker(new URL(...))?worker 后缀的 worker 编译
workerImportMetaUrl.tspostworker 场景的 import.meta.url 改写

阅读建议——

  • 第一次读 Vite 源码,只需读这 29 个文件里的 3 个index.ts(编排)、importAnalysis.ts(dev 最关键)、resolve.ts(模块解析算法)。三个文件加起来约 2600 行——一个周末能读完
  • build only 的 5 个文件(license.ts / manifest.ts / prepareOutDir.ts / reporter.ts / terser.ts)——只在 vite build 命令下被 §4.3 的 resolvePlugins 拉入数组;开发模式根本不会执行,所以调试 dev 问题时可以跳过这 5 个
  • dev only 的 2 个(clientInjections.ts / importAnalysis.ts)——永远在数组最末端,调试 HMR 注入问题时首先看这两个

一个容易被忽略的事实——这 29 个 .tsplugins/ 目录当前的全部,但章节 §4.3 的 resolvePlugins 还从 ../server/../ssr/../build.tsbuildPlugins.pre / buildPlugins.post 引入额外插件。换言之,“内置插件”的真实数量 > 29——只是目录结构上被拆到了不同模块里。这也是为什么 §4.9.3 给出 29 个文件,但开篇引言里保守地写 “30 多个”。

4.9.5 Vite 插件 API vs Rollup 插件 API 对比表

Vite 插件兼容 Rollup,但兼容不等于重合。对比两边的 API 表面,能帮助你判断一个 Rollup 插件能不能直接拿来用,以及一个 Vite 专属插件能不能反向用于纯 Rollup 项目

Rollup 原生钩子(Vite 全部继承)——

钩子RollupVite说明
optionsyesyes修改 Rollup 输入选项,构建模式生效
buildStartyesyes构建开始,dev 模式在首次 resolveId 前触发
resolveIdyesyes模块解析,hookFirst
loadyesyes模块加载,hookFirst
transformyesyes模块代码转换,hookSequential
moduleParsedyesyes(build)AST 解析完成,dev 模式不调用
resolveDynamicImportyesyes动态 import 解析
buildEndyesyes所有模块加载完毕
renderStart / renderChunk / generateBundle / writeBundleyesbuild only输出阶段钩子,dev 模式完全跳过
closeBundleyesyes构建结束
watchChange / closeWatcheryesyeswatch 模式事件
banner / footer / intro / outroyesbuild only代码注入点

Vite 专属钩子(Rollup 没有)——

钩子调用次数this 上下文典型用途
config1 次ConfigPluginContext无 environment修改或扩展用户 UserConfig
configEnvironmentN 次(每环境 1 次)ConfigPluginContextclient / ssr / edge 注入环境特有配置
configResolved1 次MinimalPluginContextWithoutEnvironment读取最终 ResolvedConfig,通常存引用
configureServer1 次MinimalPluginContextWithoutEnvironment注入中间件、对接 server.ws
configurePreviewServer1 次同上vite preview 预览服务器的中间件
transformIndexHtml每请求 1 次特殊上下文改写 index.html,支持 { order, handler }
handleHotUpdate每次文件改动 1 次MinimalPluginContextWithoutEnvironment旧 HMR 钩子,Vite 7 仍兼容但标记 legacy
hotUpdate每环境每次改动 1 次MinimalPluginContext & { environment }新 HMR 钩子,环境感知
buildApp1 次特殊上下文Vite 7 的应用级构建编排(多环境协同)
applyToEnvironment每环境初始化 1 次N/A按环境决定插件是否注册或替换

三条迁移规则——

  1. 从 Rollup 到 Vite:90% 的纯 transform/resolve 类 Rollup 插件可以直接放进 vite.config.tsplugins 数组。不能直接用的强依赖 renderChunk / generateBundle 的插件——它们在 dev 模式下不会被调用,导致插件在开发时”失灵但不报错”。典型例子:注入版权 banner 的插件——dev 下根本看不到 banner
  2. 从 Vite 到 Rollup:只使用了 resolveId / load / transform 的 Vite 插件通常可以反向用于 Rollup。但是用了 config / configureServer / hotUpdate 的插件不行——Rollup 不认识这些钩子,会静默忽略——这是个大坑,因为没有报错但功能丢了
  3. handleHotUpdate vs hotUpdate:写新插件时永远选 hotUpdate——它的 this.environment 让你能区分是 client 还是 ssr 的 HMR 请求,避免把 SSR 的代码改动当成 client HMR 处理。handleHotUpdate 是 Vite 5 时代的遗留 API,Vite 7 保留兼容但不再是推荐路径

一张 this 类型对照速查——

// config: 配置阶段专用上下文,environment 尚不存在
type ConfigPluginContext = MinimalPluginContextWithoutEnvironment

// configResolved / configureServer / handleHotUpdate: 环境无关上下文
type MinimalPluginContextWithoutEnvironment = {
  meta: PluginContextMeta
  debug(): void; info(): void; warn(): void; error(): void
}

// resolveId / load / transform / hotUpdate: 完整 PluginContext + environment
type TransformPluginContext = PluginContext & {
  environment: Environment    // 关键:区分 client / ssr / edge
  sourcemapChain: SourceMap[]
}

一个实战经验——审查第三方 Vite 插件时,优先看它在 transform 里有没有引用 this.environment——没引用的插件在 SSR 或 edge 环境下大概率工作异常,因为它还是按”只有一个 client 环境”的旧心智写的。

4.9.6 与本书其他章节的交叉引用

插件系统是 Vite 的骨架——几乎每个其他子系统都是”一堆插件 + 容器调度”的组合。理解了本章后,阅读后续章节时可以带着以下跨章索引

与第 3 章《配置解析》——

  • §3.X 讨论 resolveConfig 的流程时,config / configEnvironment / configResolved 三个钩子就是配置解析管线的用户插入点——resolveConfig 在深度合并默认配置后,会getSortedPluginsByHook('config') 的顺序依次调用用户插件的 config 钩子,把返回值再次 mergeConfig 进去
  • 本章 §4.3 的 prePlugins / normalPlugins / postPlugins 三桶分类,其分桶动作发生在 resolveConfig 的尾部——用户 vite.config.ts 里写的 plugins: [a(), b(), { enforce: 'pre', ... }] 就在那里按 enforce 字段被拆开

与第 5 章《开发服务器》——

  • §4.5 的 EnvironmentPluginContainer 是 dev-server 的发动机——第 5 章讨论的 /@fs/ 文件请求、依赖预构建拦截、模块热替换全部container.resolveId() -> container.load() -> container.transform() 的三段式流水线
  • configureServer 返回的”后置钩子”(§4.2.2)是理解第 5 章中间件注册时序的关键——内部中间件(transformMiddleware / staticMiddleware / spaFallback)都在这些后置钩子之前安装;所以用户在 configureServer 的顶层写的 server.middlewares.use()跑在内部中间件之前,而返回的函数里写的会跑在之后。这个区别决定了 middleware 的匹配优先级
  • 第 5 章的 importAnalysisPlugin 实战讨论必然涉及本章 §4.3 “为什么它必须在最后”——两章对读能消除 “为什么我的 transform 看不到被改写的 import” 这类困惑

与第 6 章《构建管线》——

  • 本章 §4.3 末尾的 buildPlugins.pre / buildPlugins.post,实体定义在 src/node/build.ts——第 6 章会展开这批 build-only 插件:terser / license / manifest / reporter / prepareOutDir 正是 §4.9.4 表格里 build only 行的那 5 个
  • Rollup 输出阶段钩子(renderChunk / generateBundle / writeBundle)在本章 §4.9.5 表格里标 build only——第 6 章讨论 chunk 切分、asset hash 命名、Dynamic Import preload 清单生成时,会具体追到这些钩子的 Rolldown 调用点

与第 7 章《HMR 机制》——

  • §4.9.5 对比表里 hotUpdate vs handleHotUpdate 的迁移讨论,是第 7 章第一节的前置知识——第 7 章会把 hotUpdateHotUpdateOptions 参数拆开,讲 modules / read() / type / timestamp / file 每个字段的语义
  • 本章 §4.3 最后一段——“importAnalysis 永远排末尾”——与第 7 章的 HMR boundary 计算有因果关系:HMR boundary 的判定依赖于 importAnalysisPlugin 写入的 client import graph,所以 HMR 必然在 import 分析之后才能运行

与第 8 章《Environment API 深潜》——

  • §4.1.2 的 applyToEnvironment、§4.2.4 的 hotUpdate 环境感知、§4.5.2 “每个环境一个独立容器”——这三处是 Environment API 在插件系统中的三个切面。第 8 章会反过来从 Environment 的角度看:一个环境实例持有一份 EnvironmentPluginContainer + 一份 EnvironmentModuleGraph + 一组过滤后的插件(经 applyToEnvironment 筛选)——插件系统和环境系统是双向耦合的

阅读顺序建议——

如果你的目标是理解 dev 模式本章 -> 第 5 章 -> 第 7 章(插件容器 -> 开发服务器 -> HMR); 如果是理解 生产构建本章 -> 第 6 章(跳过 dev-only 内容,直接读 Rollup 输出阶段); 如果是理解 SSR / edge本章 -> 第 8 章 -> 第 5 章(先把 Environment API 吃透,再看它怎么嵌入 dev server)。

4.9.7 源码行号账本(截至 vitejs/vite main,2026-04-22)

本章引用了大量源码片段。为了方便读者带着本章走进源码,这里汇总所有引用点的行号锚。打开对应文件跳到行号,就是本章引用的位置。

本章节引用源文件关键符号 / 行区间
§4.1.1 Plugin 接口packages/vite/src/node/plugin.tsexport interface Plugin<A> 定义及其 Vite 专属钩子字段块
§4.1.2 resolveEnvironmentPluginspackages/vite/src/node/plugin.tsexport async function resolveEnvironmentPlugins 函数体
§4.1.2 enforce 字段的 JSDoc 调用顺序注释packages/vite/src/node/plugin.tsenforce?: 'pre' | 'post' 上方的 Plugin invocation order 注释块
§4.1.3 getHookHandlerpackages/vite/src/node/plugins/index.tsexport function getHookHandler
§4.3 resolvePluginspackages/vite/src/node/plugins/index.tsexport async function resolvePlugins 主体——262 行里的核心编排
§4.3 buildPlugins.pre / buildPlugins.postpackages/vite/src/node/build.ts由 build.ts 导出的 buildPlugins 对象
§4.4 getSortedPluginsByHookpackages/vite/src/node/plugins/index.tsexport function getSortedPluginsByHook
§4.5.2 EnvironmentPluginContainerpackages/vite/src/node/server/pluginContainer.tsclass EnvironmentPluginContainer 及其字段与构造器
§4.5.3 resolveId 执行流程packages/vite/src/node/server/pluginContainer.tsasync resolveId(rawId, importer, options)
§4.5.4 transform 链式packages/vite/src/node/server/pluginContainer.tsasync transform(code, id, options) + TransformPluginContext 构造
§4.5.5 hookParallelpackages/vite/src/node/server/pluginContainer.tsprivate async hookParallel 实现
§4.6.3 PluginContext.resolvepackages/vite/src/node/server/pluginContainer.tsasync resolve(id, importer, options)skipSelf / skipCalls 分支
§4.7 getCachedFilterForPluginpackages/vite/src/node/plugins/pluginFilter.tsfilterForPlugin WeakMap 及其存取函数

使用建议——

  1. 读类型前先读编排:推荐阅读顺序是 plugin.ts(类型)-> plugins/index.ts(编排与排序)-> server/pluginContainer.ts(容器与执行)—— 这三个文件是 Vite 插件系统的铁三角
  2. 不要试图一次读完 css.ts:§4.9.3 提到它有 3552 行——建议按 resolveCssPlugin / resolvePostcssConfig / preprocessCSS / compileCSSPreprocessors 四个入口分次阅读,每次只看一个主题
  3. 搜索关键符号而非行号:GitHub 主干持续演进,行号会飘移,但 class EnvironmentPluginContainerexport async function resolvePlugins 这类符号名相对稳定——后续复查时用符号搜索比记行号更鲁棒

4.9.8 常见陷阱清单

基于前述源码路径的分析,这里列出编写 Vite 插件时最容易踩的 8 个陷阱——每条都标注了源于插件管线的哪个机制,方便你从源头纠正而不是”打补丁”。

陷阱 1——在 transform 里修改了 id 或 importer

transform 的职责是输入代码、输出代码,不应该对 id 做任何假设式改写。如果你要改写模块 ID,唯一的地方是 resolveId。原因见 §4.5.4:transform 的签名是 (code, id) => { code, map }——没有 id 返回字段——你的任何 id 修改都会被静默丢弃。

陷阱 2——在 resolveId 里用 path.resolve 返回相对路径

EnvironmentPluginContainer.resolveId 最后会调用 normalizePath(id) 将其规范化为绝对路径。但如果你在 Windows 上返回了带反斜杠的路径,或者返回了不存在的相对路径,normalizePath 只做字符串转换,不校验文件是否存在——后续 load 拿到不存在的路径会抛 ENOENT,且错误堆栈不会指向你的插件。所以在 resolveId 里永远用 path.resolve + normalizePath 主动返回绝对 POSIX 路径。

陷阱 3——config 钩子返回值里写 plugins

config 钩子返回的 UserConfig 会被 mergeConfig 合并,但**plugins 字段在此阶段的合并行为是”追加到末尾”——不参与 §4.3 的 enforce 分桶。这意味着你在 config 里动态加的插件会永远出现在用户 post 插件之后**,不能通过 enforce: 'pre' 前置。要动态增加 pre 插件,必须让用户把你的母插件写成一个数组(Vite 支持 plugins: [[a, b]] 的嵌套扁平化)。

陷阱 4——在 configureServer 同步顶层注册中间件期望它跑在最后

§4.2.2 已说明:configureServer 里同步注册的 middleware 在内部 middleware 之前,返回的函数里注册的在之后。最常见的症状是”我的 fallback middleware 被 spaFallback 内部中间件抢先了”——修复方法是server.middlewares.use 放进 return 的函数里,而不是放在顶层。

陷阱 5——HMR 里调用 ws.send,忘了环境独立

Vite 6 之后每个环境有独立的 hot 通道。handleHotUpdateserver.ws.send 只会发到 client 环境,对 ssr 环境无效。用 hotUpdate 钩子时,正确写法是 this.environment.hot.send(...)——这样消息会发到当前环境的 HMR 通道。

陷阱 6——过滤器 regex 里忘了虚拟模块的 \0 前缀

§4.9 提到虚拟模块 ID 以 \0 开头。如果你的过滤器是 filter: { id: /\.my$/ },它会匹配 foo.my 也会匹配 \0virtual:xxx.my——但通常你只想要真实文件。最稳的做法是 filter: { id: { include: /\.my$/, exclude: /^\0/ } },或者在 handler 内部 if (id.startsWith('\0')) return 早退。

陷阱 7——在 buildStart 里同步做重活

§4.5.5 的 hookParallel并行调用所有插件的 buildStart,但如果某个插件在 buildStart 里做了大量同步 CPU 计算(比如解析一个大 JSON),它会阻塞 Node 事件循环,导致其他插件的 buildStart Promise 也被拖慢。修复:把重活推到 loadtransform,那里有请求级别的 lazy 调用。

陷阱 8——生产构建下仍然依赖 server.ws

server.wsvite build不存在。插件如果想在 build 阶段复用 dev 的通信逻辑,会在 build 时拿到 undefined,然后抛 TypeError。保护写法是在 configureServer 里缓存 server 引用,在使用前加 if (!server) return

这 8 个陷阱合起来覆盖了生产环境大部分的 Vite 插件 bug 模式。写插件时把本节当 checklist 过一遍,能避免 90% 的社区 issue。

4.9.9 插件生命周期时间轴与 Vite 5 -> 7 迁移

一次 vite dev 启动里,插件钩子的精确触发时序——本章前面的表格是按钩子分类的静态视图,下面给按时间顺序的动态视图,配合 §4.9.7 的源码锚点可以逐步调试。

sequenceDiagram
    participant CLI as vite CLI
    participant RC as resolveConfig
    participant Env as Environment.init
    participant PC as PluginContainer
    participant Req as HTTP 请求

    CLI->>RC: 调用 resolveConfig
    RC->>RC: 合并默认 + 用户 config
    RC->>RC: 调用每个插件的 config()
    RC->>RC: 按 enforce 分桶 prePlugins/normal/post
    RC->>RC: resolvePlugins 生成有序列表
    RC-->>CLI: ResolvedConfig
    CLI->>Env: 为每个环境 new DevEnvironment
    Env->>Env: applyToEnvironment 过滤插件
    Env->>Env: configEnvironment(每环境 1 次)
    Env->>PC: createEnvironmentPluginContainer
    Env->>Env: configResolved(全局 1 次)
    Env->>Env: configureServer(dev 专属)
    Env->>PC: 首次 resolveId 前触发 buildStart
    Req->>PC: GET /src/main.ts
    PC->>PC: resolveId hookFirst
    PC->>PC: load hookFirst
    PC->>PC: transform hookSequential
    PC-->>Req: 转换后的代码
    Note over Env,PC: 文件改动时
    Env->>Env: hotUpdate(每环境)

重点观察——

  • configapplyToEnvironment 之前——所以 config 钩子的 this没有 environment。如果你需要按环境读配置,必须用 configEnvironment
  • configureServer 在每个环境的 configResolved 之后、首次请求之前——是获取 server 引用的唯一时机
  • buildStart惰性触发的——直到第一次 resolveId 调用才启动——这意味着空服务器启动只调用 config / configResolved / configureServer,不触发 buildStart。有些插件在 buildStart 里做 IO 准备,用户报”服务器启动很快但第一次请求很慢”,根因就在这里

Vite 5 -> 6 -> 7 插件 API 的关键变化——

变化点Vite 5Vite 6/7迁移动作
插件容器全局单例 PluginContainer每环境一份 EnvironmentPluginContainerthis.environment 代替 this 上的 ssr 标志
SSR 标记transform(code, id, { ssr })this.environment.name === 'ssr'从参数读改为从上下文读
HMR 钩子handleHotUpdatehotUpdate(推荐)+ 兼容保留新插件用 hotUpdate,老插件不必立即迁
模块图server.moduleGraphenvironment.moduleGraph按环境分别取;server.moduleGraph 在 Vite 7 仍存在但等价于 client 环境的
配置钩子config(config, env)额外 configEnvironment(name, config, env)需要区分环境的逻辑拆到 configEnvironment
插件筛选没有 applyToEnvironment需要按环境替换/禁用时启用
ssrTransform专用方法通过 environment.name === 'ssr'transform 分支合并为单一 transform

迁移的”最小改动”路径——如果你维护一个 Vite 5 的插件想兼容 Vite 7:

  1. 不需要立刻引入 hotUpdate——handleHotUpdate 在 Vite 7 仍工作,只是不再推荐
  2. 不需要立刻引入 applyToEnvironment——默认所有环境都会注册你的插件,与 Vite 5 的行为一致
  3. 必须处理的transform(code, id, { ssr }) 里的 ssr 参数——Vite 7 仍传递 options.ssr 作为兼容层,但更稳妥的写法是 const isSSR = this.environment?.name === 'ssr' ?? options?.ssr——这样代码同时兼容 5 和 7
  4. 不应该configureServer 里硬编码 server.moduleGraph ——应该切到 server.environments.client.moduleGraph,并用 ?. 做版本探测

何时必须升级而非兼容——如果你的插件功能本质上与 SSR / edge / worker 多环境相关(例如一个为 edge runtime 替换 Node polyfill 的插件),那必须重写为 Environment API——因为 Vite 5 的单容器架构根本表达不出”在 edge 环境替换但在 client 不替换”的能力。这种重写不是兼容问题,而是能力边界问题。

4.9.10 插件性能审计速查

一个插件写对了功能,下一步是判断它有没有拖慢开发服务器。基于 §4.5 到 §4.7 的管线知识,可以给出一份可操作的性能审计清单

三个最容易拖慢 dev 的模式——

  1. transform 没加 filter 又做了昂贵的 regex / AST 解析——每个 JS/TS/CSS/JSON 文件请求都会依次跑过所有插件的 transform。如果你的 transform 里有 @babel/parse 或者大体量 regex,又没有用 filter: { id: /\.xx$/ } 提前过滤,就会对每个 .css 文件也跑一遍,代价极高。修复:默认所有 transform 都加 filter
  2. resolveId 里做 fs.access / fs.stat——resolveId 是 hookFirst 管线,每次解析都会从第一个插件开始依序问。如果某个靠前的插件用同步 fs 调用校验文件存在性,每个 import 都要多一次 syscall。修复:this.resolve() 委托给核心 resolve 插件,或者把 stat 结果按 id cache 起来
  3. buildStartconfigureServer 里读了大文件同步解析——§4.9.8 陷阱 7 已提过。推论:任何耗时 > 100ms 的插件初始化,必须改成 lazy(等 load / transform 首次调用时再做)

两个度量手段——

  • env DEBUG=vite:plugin* 启动 dev server——EnvironmentPluginContainer 内部打印每个 hook 的耗时;会非常啰嗦但最准确
  • pluginContainer.getCachedFilterForPlugin 命中率——§4.7 的缓存 WeakMap 在理论上 100% 命中。如果你给同一个 hook 反复传入新对象(例如 transform: (code, id) => {...} 被每次重建),缓存会失效。保证过滤器对象引用稳定是性能基本功——否则缓存等于白做

一个反直觉的优化——不要把所有逻辑塞进一个超大插件。Vite 对插件数量的开销是 O(n) 线性的,但每个插件的 filter 是 O(1) 短路的;把 3 个功能拆成 3 个小插件、每个都带精准 filter,总开销通常比 1 个无 filter 的大插件低。

4.10 小结

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

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