首页
/ Vite Environment API for Plugins 实践:this.environment、Per-environment Hooks 与 applyToEnvironment

Vite Environment API for Plugins 实践:this.environment、Per-environment Hooks 与 applyToEnvironment

2026-09-06 17:38:54作者:瞿蔚英Wynne

Vite 的 Environment API 把原来围绕 client / ssr 两个环境的 ssr 布尔参数体系,升级为统一的“环境实例”模型:插件管线在应用层面共享,但钩子会针对每个环境(environment)分别执行,并通过 this.environment 暴露当前环境。本篇基于 Vite 仓库中的 docs/guide/api-environment-plugins.md 官方文档,结合 packages/vite/src/node/plugin.tspackages/vite/src/node/config.ts 等源码实现,完整讲解插件作者如何利用这套 API 编写多环境感知的插件——包括注册自定义环境、按环境配置选项、处理 HMR、维护 per-environment 状态,以及用 applyToEnvironment 把“不可共享”的插件变成按环境隔离的插件。

Vite 多环境模型示意图

一、Environment API 的当前状态:Release Candidate

docs/guide/api-environment-plugins.md 的说明,Environment API 目前处于 release candidate(发布候选)阶段:Vite 会在大版本之间保持这些 API 的稳定,以便生态进行试验和构建;但其中一部分具体 API仍被视为实验性。官方计划在未来某个大版本中(可能伴随破坏性变更)正式稳定这些 API。

这一点从源码中也能得到印证。packages/vite/src/node/plugin.ts 中,applyToEnvironmentperEnvironmentPluginsharedDuringBuildperEnvironmentStartEndDuringDev 等成员均标注了 @experimental(见 plugin.ts L176-L229)。此外,仓库提供了迁移期的“告警开关”:如果你正在从旧的 server.* API 迁移,可以在配置中设置 future 选项(如 removeServerModuleGraph: 'warn'removeServerTransformRequest: 'warn' 等)来识别自己代码中对旧 API 的引用,详见 迁移指南

适用前提Environment 实例自 Vite 6.0 引入。阅读本文时请以当前仓库的实际实现为准;对旧版本 Vite(6 之前),只能存在 clientssr 两个环境,相关 API 以 ssr 布尔参数标识环境。

二、Per-environment Hooks 与 Global Hooks 的区分

插件运行在一条共享的管线(shared pipeline)上,但钩子分为两类:

  • Global hooks(全局钩子):只调用一次,与配置了哪些环境无关。它们处理应用级的事务,比如解析配置(configconfigResolved)、搭建 dev 和 preview 服务器(configureServerconfigurePreviewServer 等)。对这类钩子而言,this.environment 没有意义——PluginContextExtension 虽给上下文注入了 environment 字段(plugin.ts L62-L67),但 Plugin.ts 头部注释明确说明:当前环境只在所有非全局钩子的上下文中可用,configconfigResolvedconfigureServer 等全局钩子里拿不到。
  • Per-environment hooks(每环境钩子):每个环境各调用一次,并在上下文中通过 this.environment 暴露当前环境实例。所有 Rolldown 钩子(resolveIdloadtransformrenderChunk 等)都属于此类,以及其他处理模块的 Vite 特有钩子。

有一个重要的例外需要注意:buildStartbuildEnd 默认只在 client 环境被调用一次,除非插件显式声明 perEnvironmentStartEndDuringDev: truewatchChange 同理,需要 perEnvironmentWatchChangeDuringDev: true 才会按环境调用。这两个 opt-in 标志的定义和注释见 plugin.ts L185-L200,注释说明这是为向后兼容而设计的渐进式迁移路径。

三、在钩子中访问当前环境:this.environment

在 Vite 6 之前只有 clientssr 两个环境,所以 Vite API 用一个 ssr 布尔值即可标识当前环境:插件钩子的最后一个 options 参数带 ssr 字段,许多 API 也接受一个可选的尾部 ssr 参数(如 server.moduleGraph.getModuleByUrl(url, { ssr }))。

引入可配置环境后,Vite 提供了一套统一方式来访问环境选项和实例:

  • 插件钩子的上下文现在暴露 this.environment
  • 之前接受 ssr 布尔的 API,现在被收敛到各自的环境实例上,例如 environment.moduleGraph.getModuleByUrl(url),无需再传 ssr

Vite 服务器拥有一条共享插件管线,但模块被处理时始终处于某个具体环境的上下文中。插件可以用环境实例(其配置可通过 environment.config 访问)来改变模块处理方式:

transform(code, id) {
  console.log(this.environment.config.resolve.conditions)
}

从源码结构看,这正是 Plugin 接口继承 Rolldown 插件接口并在 PluginContext 上扩展了 environment: Environment 字段的直接结果(plugin.ts L62-L67L85-L89 中对 rolldown 模块类型的扩展声明)。插件还可以用 this.environment.mode === 'dev' 之类的判断来守护 dev 专属逻辑。

四、在 config 钩子中注册新环境

插件可以在 config 钩子里添加新环境。文档中的例子是 RSC 支持(@vitejs/plugin-rsc):通过一个额外的环境获得独立的模块图,并使用 react-server 解析条件:

config(config: UserConfig) {
  return {
    environments: {
      rsc: {
        resolve: {
          conditions: ['react-server', ...defaultServerConditions],
        },
      },
    },
  }
}

只需要返回一个空对象即可注册环境,其余配置会回落到根级别环境配置的默认值。也就是说,environments 是一个“名字 → EnvironmentOptions”的记录,clientssr 是内置环境,其余名字都可以由插件或用户配置按需添加。

五、configEnvironment 钩子:按环境配置选项

  • 类型: (name: string, config: EnvironmentOptions, env: { mode: string, command: 'build' | 'serve', isSsrBuild?: boolean, isPreview?: boolean, isSsrTargetWebworker?: boolean }) => EnvironmentOptions | null | void
  • 种类: asyncsequential
  • 作用域: Per-environment

config 钩子运行时,完整的环境列表还未知,环境既可能受到根级环境配置默认值的影响,也可能被 config.environments 记录显式指定。因此官方给出的分工是:config 钩子设置默认值,用 configEnvironment 钩子配置每个环境——后者针对每个环境各调用一次,拿到的是部分解析(含最终默认值解析)后的配置。

configEnvironment(name: string, options: EnvironmentOptions) {
  // add "workerd" condition to the rsc environment
  if (name === 'rsc') {
    return {
      resolve: {
        conditions: ['workerd'],
      },
    }
  }
}

源码实现位于 config.ts 的 runConfigEnvironmentHook:它先取所有环境名,然后按 getSortedPluginsByHook('configEnvironment', plugins) 的插件顺序依次执行;对每个插件,遍历所有环境名调用钩子,若返回非空结果则用 mergeConfig 深合并回该环境的选项。这也解释了“每个环境各调用一次”的语义:外层是插件顺序(sequential),内层是环境遍历。

六、hotUpdate 钩子:按环境的 HMR 更新处理

  • 类型: (this: { environment: DevEnvironment }, options: HotUpdateOptions) => Array<EnvironmentModuleNode> | void | Promise<Array<EnvironmentModuleNode> | void>
  • 种类: asyncsequential
  • 作用域: Per-environment
  • 相关文档: HMR API

hotUpdate 钩子让插件针对指定环境执行自定义的 HMR 更新处理。当文件变更时,HMR 算法会按 server.environments 中的顺序依次为每个环境运行,所以 hotUpdate 会被多次调用(每个支持的环境一次)。钩子接收的上下文对象签名如下:

interface HotUpdateOptions {
  type: 'create' | 'update' | 'delete'
  file: string
  timestamp: number
  modules: Array<EnvironmentModuleNode>
  read: () => string | Promise<string>
  server: ViteDevServer
}

各字段要点:

  • this.environment:当前正在处理文件更新的那个模块执行环境;
  • modules:该环境中受此文件变更影响的模块数组。之所以是数组,是因为一个文件可能映射到多个被服务的模块(例如 Vue SFC);
  • read:异步读取文件内容的函数。之所以提供它,是因为在部分系统上文件变更回调可能触发得过快——编辑器还没写完文件,直接 fs.readFile 会读到空内容。这个 read 函数对该行为做了归一化。

钩子有三种典型处理方式:

1. 过滤并收窄受影响的模块列表,让 HMR 更精准——返回过滤后的模块数组即可。

2. 返回空数组并执行整页刷新(full reload)

hotUpdate({ modules, timestamp }) {
  if (this.environment.name !== 'client')
    return

  // Invalidate modules manually
  const invalidatedModules = new Set()
  for (const mod of modules) {
    this.environment.moduleGraph.invalidateModule(
      mod,
      invalidatedModules,
      timestamp,
      true
    )
  }
  this.environment.hot.send({ type: 'full-reload' })
  return []
}

注意这里的模块失效操作走的是 this.environment.moduleGraph.invalidateModule,即环境实例上的模块图 API,而不是旧的 server.moduleGraph

3. 返回空数组并完全自定义 HMR 处理——向客户端发送自定义事件:

hotUpdate() {
  if (this.environment.name !== 'client')
    return

  this.environment.hot.send({
    type: 'custom',
    event: 'special-update',
    data: {}
  })
  return []
}

客户端代码需要注册对应的事件处理器(可以注入到同插件的 transform 钩子中),使用 HMR API

if (import.meta.hot) {
  import.meta.hot.on('special-update', (data) => {
    // perform custom update
  })
}

七、插件的 Per-environment 状态

由于同一个插件实例会被用于不同环境,插件内部的状态必须以 this.environment 为键,避免各环境的状态互相污染。这正是生态此前用 ssr 布尔作键来区分 client/ssr 模块状态的同一模式的推广:可以用 Map<Environment, State> 为每个环境维护独立状态。再强调一次:buildStart / buildEnd 默认只在 client 环境调用(除非 perEnvironmentStartEndDuringDev: true),watchChange 同理(perEnvironmentWatchChangeDuringDev: true)。

文档给出的完整示例是统计每个环境被 transform 的模块数量:

function PerEnvironmentCountTransformedModulesPlugin() {
  const state = new Map<Environment, { count: number }>()
  return {
    name: 'count-transformed-modules',
    perEnvironmentStartEndDuringDev: true,
    buildStart() {
      state.set(this.environment, { count: 0 })
    },
    transform(id) {
      state.get(this.environment).count++
    },
    buildEnd() {
      console.log(this.environment.name, state.get(this.environment).count)
    }
  }
}

这个示例同时演示了 perEnvironmentStartEndDuringDev: true 的用法:声明后 buildStart/buildEnd 会在 dev 期间按环境调用,使状态初始化与收尾逻辑能覆盖所有环境,而不仅仅是 client。

八、applyToEnvironment 钩子与 perEnvironmentPlugin 助手

  • 类型: (environment: PartialEnvironment) => boolean | PluginOption | Promise<boolean>
  • 种类: asyncsequential
  • 作用域: Per-environment

插件可以用 applyToEnvironment 函数声明自己应作用于哪些环境:

const UnoCssPlugin = () => {
  // shared global state
  return {
    buildStart() {
      // init per-environment state with WeakMap<Environment,Data>
      // using this.environment
    },
    configureServer() {
      // use global hooks normally
    },
    applyToEnvironment(environment) {
      // return true if this plugin should be active in this environment,
      // or return a new plugin to replace it.
      // if the hook is not used, the plugin is active in all environments
    },
    resolveId(id, importer) {
      // only called for environments this plugin apply to
    },
  }
}

返回值的三种语义:

  • 返回 true:插件在该环境激活;
  • 返回 false(或 falsy):插件在该环境不激活;
  • 返回一个 PluginOption(可以是数组、false 等):用一个新插件替换原插件。若没有定义 applyToEnvironment,插件默认在所有环境激活。

源码实现 resolveEnvironmentPlugins 与上述语义一一对应:遍历顶层配置的插件,对有 applyToEnvironment 的插件先 await 其返回值;falsycontinue 跳过;非 trueasyncFlatten(arraify(applied)) 展开后压入该环境的插件列表;true 则保留原插件。

如果某个插件不感知环境、且其内部状态没有按当前环境分键,applyToEnvironment 提供了一个简单的“按环境实例化”手段——每个环境用不同的构造参数拿到独立的插件实例:

import { nonShareablePlugin } from 'non-shareable-plugin'

export default defineConfig({
  plugins: [
    {
      name: 'per-environment-plugin',
      applyToEnvironment(environment) {
        return nonShareablePlugin({ outputName: environment.name })
      },
    },
  ],
})

对于“只需按环境替换、不需要其他钩子”的这类场景,Vite 导出了 perEnvironmentPlugin 助手函数简化写法:

import { nonShareablePlugin } from 'non-shareable-plugin'

export default defineConfig({
  plugins: [
    perEnvironmentPlugin('per-environment-plugin', (environment) =>
      nonShareablePlugin({ outputName: environment.name }),
    ),
  ],
})

它的实现非常薄,见 plugin.ts L431-L441:就是构造一个只带 nameapplyToEnvironment 两个键的对象,并从 vite 包入口导出(index.ts L35)。值得注意的是,Vite 自己内部也大量使用了这套机制——例如 reportermanifestwasm-helpernative:import-analysis-build 等内置插件均通过 perEnvironmentPlugin 定义(见 plugins/reporter.tsplugins/manifest.ts),而 cssresolvehtmldefineesbuild 等核心内置插件则使用 applyToEnvironment 控制自身在不同环境的启用条件。

时序上有一个注意点:applyToEnvironment 钩子在配置解析期(config time)调用,目前位于 configResolved 之后(因为生态项目可能在那之前修改插件列表);从源码结构看,环境插件解析在未来可能被移到 configResolved 之前。

九、应用与插件的通信:environment.hot

environment.hot 允许插件与“给定环境”中运行在应用侧的代码通信。它是 Client-server Communication 特性的对应物,但支持 client 以外的环境。

注意:该特性仅对支持 HMR 的环境可用。

管理应用实例

同一个环境中可能同时运行多个应用实例。例如浏览器里开了多个标签页,每个标签页都是一个独立的应用实例,各自与服务端保持一条独立连接。

  • 当新连接建立时,环境 hot 实例上会发出 vite:client:connect 事件;
  • 当连接关闭时,发出 vite:client:disconnect 事件。

每个事件处理器接收 NormalizedHotChannelClient 作为第二个参数。client 是一个带 send 方法的对象,可用于向该特定应用实例发送消息。同一连接的 client 引用始终相同,因此可以保存它来追踪连接。

使用示例

插件侧:

configureServer(server) {
  server.environments.ssr.hot.on('my:greetings', (data, client) => {
    // do something with the data,
    // and optionally send a response to that application instance
    client.send('my:foo:reply', `Hello from server! You said: ${data}`)
  })

  // broadcast a message to all application instances
  server.environments.ssr.hot.send('my:foo', 'Hello from server!')
}

应用侧与 Client-server Communication 特性相同:用 import.meta.hot 对象向插件发送消息、注册事件处理。

十、构建时的环境:Build Hooks 与共享插件管线

Build Hooks 中的环境

与 dev 一样,构建期插件钩子也会收到环境实例,取代了原来的 ssr 布尔。这对 renderChunkgenerateBundle 等仅在构建期存在的钩子同样适用——它们同样以 this.environment 访问当前构建环境。

Shared Plugins During Build 的演进

Vite 6 之前,插件管线在 dev 和 build 中的工作方式不同:

  • Dev 期间: 插件共享(一条管线跑完所有环境);
  • Build 期间: 插件按环境隔离(在不同进程中运行:先 vite build,再 vite build --ssr)。

这迫使框架通过写入文件系统的 manifest 文件来在 client 构建与 ssr 构建之间共享状态。Vite 6 改为在单个进程中构建所有环境,使插件管线与环境间通信方式能够与 dev 对齐。未来大版本中可能做到完全对齐:

  • dev 与 build 均: 插件共享,并支持按环境过滤
  • 构建期间只有一个共享的 ResolvedConfig 实例,从而可以在“整个应用构建流程”级别做缓存——就像 dev 期间用 WeakMap<ResolvedConfig, CachedData> 做的那样。

对 Vite 6 而言,为了向后兼容采取了一个较小的步骤:生态插件目前普遍用 config.build(而非 environment.config.build)访问构建配置,所以默认仍会为每个环境创建新的 ResolvedConfig。项目可以通过设置 builder.sharedConfigBuild: true 选择共享完整的配置与插件管线。由于该选项初期只适用于少数项目,插件作者也可以为特定插件选择共享:设置 sharedDuringBuild: true,即可让普通插件在所有环境共享状态:

function myPlugin() {
  // Share state among all environments in dev and build
  const sharedState = ...
  return {
    name: 'shared-plugin',
    transform(code, id) { ... },

    // Opt-in into a single instance for all environments
    sharedDuringBuild: true,
  }
}

源码中 sharedDuringBuild 的注释(plugin.ts L176-L184)补充了背景:为向后兼容,插件在 vite build --app 期间仍会为每个环境重新创建;目前是“每插件 opt-in + 全局 builder.sharedPlugins”的组合,未来大版本会把默认值翻转为“默认共享”。

十一、配套迁移:从 server.* 到 environment.*

写多环境插件时,还会碰到旧 API 的迁移问题。docs/changes/per-environment-apis.md 说明了动机与对照关系:Vite 5 及以前,一个 dev server 固定有 clientssr 两个环境,server.moduleGraph 混合了两种环境的模块,节点通过 clientImportedModules / ssrImportedModules 列表关联,被转换的模块用 id + ssr 布尔表示;Vite 6 允许创建任意数量的自定义环境(clientssredge 等),单个 ssr 布尔不再够用。因此这些方法被移到环境实例上,使它们可以不依赖 Vite dev server 实例直接调用。迁移对照:

旧 API 新 API
server.moduleGraph environment.moduleGraph
server.reloadModule(module) environment.reloadModule(module)
server.pluginContainer environment.pluginContainer
server.transformRequest(url, ssr) environment.transformRequest(url)
server.warmupRequest(url, ssr) environment.warmupRequest(url)
server.hot server.client.environment.hot

该文档同时提醒:旧方法的弃用计划在未来大版本执行,官方暂不推荐立刻全面迁移;可以通过上文提到的 future 配置项(如 removeServerModuleGraph: 'warn'removeServerReloadModule: 'warn'removeServerPluginContainer: 'warn'removeServerHot: 'warn'removeServerTransformRequest: 'warn'removeServerWarmupRequest: 'warn')先以告警方式识别代码中的旧用法。

十二、小结:插件作者的多环境心智模型

  • 钩子分两类:全局钩子(config/configResolved/configureServer 等,调用一次,无 this.environment)与 per-environment 钩子(Rolldown 钩子等,每环境一次,带 this.environment);
  • 注册新环境用 config 钩子返回 environments 记录;按环境微调解析后的选项用 configEnvironment 钩子(插件顺序 × 环境遍历,返回值深合并);
  • HMR 处理用 hotUpdate + this.environment.hot / this.environment.moduleGraph
  • 插件状态以 Environment 为键(Map/WeakMap),必要时用 perEnvironmentStartEndDuringDev / perEnvironmentWatchChangeDuringDev opt-in 全量环境调用;
  • 按环境启用/替换插件用 applyToEnvironmenttrue 保留、falsy 跳过、返回插件则替换)或 perEnvironmentPlugin 助手;
  • 应用侧通信用 environment.hotsend / onvite:client:connect / vite:client:disconnect 事件;
  • 构建期默认每环境独立插件实例与 ResolvedConfig,可用 sharedDuringBuild(插件级)与 builder.sharedConfigBuild(项目级)逐步走向与 dev 一致的共享管线。

这些机制的共同目标是:让“环境”成为插件世界的一等概念,使 RSC、边缘运行时等多环境场景下的插件开发与单一环境场景同样直接。

登录后查看全文
热门项目推荐
相关项目推荐