Vite 设计与实现

第16章 Environment API

作者 杨艺韬 · 11,334 字

第16章 Environment API

开篇引言

在 Vite 6 之前,一个 Vite 服务器实例只有一个统一的模块图、插件管线和依赖优化器。当项目需要同时处理客户端代码和 SSR 代码时,这些共享的基础设施不得不通过参数(如 ssr: boolean)来区分行为。这种方式简单但脆弱 — 当需要支持更多的运行环境(如 RSC、Service Worker、Edge Runtime)时,布尔参数无法扩展。

Vite 6 引入的 Environment API 从根本上改变了这一架构。每个”环境”拥有独立的模块图、插件容器和依赖优化器,它们通过共享的顶层配置保持协调。这不是一次简单的重构,而是 Vite 向”通用构建编排器”角色演进的关键一步。

本章将从 environment.tsbaseEnvironment.tsserver/environment.tsbuild.tsoptimizer/scan.ts 等源码文件出发,深入分析 Environment API 的类型体系、生命周期管理和多环境协作机制。

本章要点

  • 理解 Environment API 的设计动机与架构目标
  • 掌握 PartialEnvironment -> BaseEnvironment -> DevEnvironment/BuildEnvironment/ScanEnvironment 的类型层次
  • 分析 perEnvironmentPluginperEnvironmentState 的多环境插件适配模式
  • 理解每环境独立的模块图、插件容器和依赖优化器
  • 掌握环境配置的 Proxy 合并策略

16.1 设计动机

16.1.1 从 ssr 布尔值到多环境

在 Vite 5 及更早版本中,SSR 支持是通过在 API 中传递 ssr: boolean 参数来实现的:

// Vite 5 风格
server.transformRequest(url, { ssr: true })
server.moduleGraph.getModuleByUrl(url, true)

这种方式存在几个问题:

  1. 不可扩展:当需要支持第三种环境(如 React Server Components 的 RSC 环境)时,布尔值无法表达
  2. 共享污染:客户端和 SSR 共享同一个模块图,模块的 transformResultssrTransformResult 混存在同一个节点上
  3. 优化冲突:客户端和 SSR 可能需要不同的依赖优化策略,共享的优化器无法同时满足
  4. 插件歧义:插件需要在运行时检查 ssr 参数来决定行为,增加了认知负担

16.1.2 目标架构

Environment API 的目标是将”环境”提升为一等公民:

graph TB
    subgraph "Vite 5 (共享模型)"
        A["ViteDevServer"] --> B["共享 ModuleGraph<br/>(transformResult + ssrTransformResult)"]
        A --> C["共享 PluginContainer"]
        A --> D["共享 DepsOptimizer"]
    end

    subgraph "Vite 6+ (环境隔离模型)"
        E["ViteDevServer"] --> F["client Environment"]
        E --> G["ssr Environment"]
        E --> H["rsc Environment (自定义)"]

        F --> F1["ModuleGraph"]
        F --> F2["PluginContainer"]
        F --> F3["DepsOptimizer"]

        G --> G1["ModuleGraph"]
        G --> G2["PluginContainer"]
        G --> G3["DepsOptimizer"]

        H --> H1["ModuleGraph"]
        H --> H2["PluginContainer"]
        H --> H3["DepsOptimizer"]
    end

    style F fill:#e3f2fd
    style G fill:#e8f5e9
    style H fill:#fff3e0

16.1.3 源码核对:为什么环境名称限制为 [\w$]+

PartialEnvironment 构造函数的第一段校验(baseEnvironment.ts:39-43):

if (!/^[\w$]+$/.test(name)) {
  throw new Error(
    `Invalid environment name "${name}". Environment names must only contain alphanumeric characters and "$", "_".`,
  )
}

注释里点明了原因(baseEnvironment.ts:37-38):

only allow some characters so that we can use name without escaping for directory names and make users easier to access with environments.*

两条硬约束:

1、directory 名安全:Vite 在 .vite/deps_<env_name>/ 这种目录下存每个环境的依赖预构建产物。如果环境名能含 /\...、空格等字符——要么路径被误解成多级目录、要么被解析成上级目录跳转——安全和正确性都不保。限制成 [\w$]+ 后直接当目录名使——零转义、零歧义。

2、JavaScript 标识符兼容config.environments.xxx 里的 .xxx 是对象属性访问。如果名字有 -、空格等字符,就只能用 config.environments["foo-bar"] 这种字符串索引——破坏 TS 类型推导、IDE 补全失效。限制在 [\w$]+environments.clientenvironments.ssr 等写法 TypeScript 能完整推导类型。

这两条合起来让 environment name 成为**“同时合法作为目录名和 JS 标识符”**的最小集。Vite 许多其他地方的命名限制(比如 §17 的 Worker 文件标识)都遵循同一原则——限制越紧、不变量越强、用户越不容易踩雷

16.1.4 源码核对:Proxy 配置合并的空间优化

§16.1.1 讲了 Proxy 配置合并的语义。真实 Proxy 的 get handler(baseEnvironment.ts:47-60)只有 13 行,但每次属性访问都会被触发——值得展开它的性能和内存权衡

this.config = new Proxy(
  options as ResolvedConfig & ResolvedEnvironmentOptions,
  {
    get: (target, prop: keyof ResolvedConfig) => {
      if (prop === 'logger') {
        return this.logger
      }
      if (prop in target) {
        return this._options[prop as keyof ResolvedEnvironmentOptions]
      }
      return this._topLevelConfig[prop]
    },
  },
)

三条深层设计:

1、不做配置 merge、只做 lookup 代理。另一种实现是”构造时把 topLevelConfig 深度复制 + 覆盖 options 里的字段”——一个完整 merged config 对象。但 Vite 有 10-30 个环境的潜力(client/ssr/rsc/worker/edge…),每个环境都 deep-copy 几 MB 的 config 对象——N 倍内存膨胀。Proxy 在访问时查找——所有环境共享底层 config 对象、只额外存 options override——内存从 O(N × config_size) 降到 O(N + config_size)。

2、prop in target——精确判断”environment options 是否覆盖了这个字段”。注意这不是 target[prop] !== undefined——后者会在 options 显式把某字段设为 undefined 时错误地回退到 topLevelConfig。in 操作符只看 key 是否存在、忽略值——用户显式设 { ssr: undefined } 时 environment.config.ssr 也返回 undefined,不会意外回退到 topLevelConfig.ssr。

3、logger 特判if (prop === 'logger') return this.logger——logger 总是返回 PartialEnvironment 构造时创建的带环境颜色的 logger回退到 topLevelConfig.logger(那是全局 logger)。这让 config.logger.info("..") 在每个环境都显示带环境名的彩色前缀——多环境日志可读性的关键。

这三条设计(lookup 代理、in 判断、logger 特判)加起来是一份值得抄的 Proxy 使用模板——当你需要”共享底层数据 + 局部覆盖 + 特殊字段处理”时,这个模式几乎一定是对的

16.2 类型体系

16.2.1 类继承层次

Environment API 定义了一个精心设计的类继承层次:

classDiagram
    class PartialEnvironment {
        +name: string
        +config: ResolvedConfig & ResolvedEnvironmentOptions
        +logger: Logger
        +getTopLevelConfig(): ResolvedConfig
        #_topLevelConfig: ResolvedConfig
        #_options: ResolvedEnvironmentOptions
    }

    class BaseEnvironment {
        +plugins: readonly Plugin[]
        #_initiated: boolean
    }

    class UnknownEnvironment {
        +mode: "unknown"
    }

    class DevEnvironment {
        +mode: "dev"
        +moduleGraph: EnvironmentModuleGraph
        +depsOptimizer: DepsOptimizer
        +pluginContainer: PluginContainer
        +hot: NormalizedHotChannel
        +fetchModule()
        +transformRequest()
        +warmupRequest()
        +init()
        +listen()
        +close()
    }

    class BuildEnvironment {
        +mode: "build"
        +isBuilt: boolean
        +init()
    }

    class ScanEnvironment {
        +mode: "scan"
        +pluginContainer: PluginContainer
        +init()
    }

    PartialEnvironment <|-- BaseEnvironment
    BaseEnvironment <|-- UnknownEnvironment
    BaseEnvironment <|-- DevEnvironment
    BaseEnvironment <|-- BuildEnvironment
    BaseEnvironment <|-- ScanEnvironment

16.2.2 PartialEnvironment:配置层

PartialEnvironment 是整个层次的基础,负责环境名称验证、配置合并和日志初始化:

export class PartialEnvironment {
  name: string
  config: ResolvedConfig & ResolvedEnvironmentOptions
  logger: Logger

  constructor(
    name: string,
    topLevelConfig: ResolvedConfig,
    options: ResolvedEnvironmentOptions = topLevelConfig.environments[name],
  ) {
    // 环境名称只允许字母数字和 $ _
    if (!/^[\w$]+$/.test(name)) {
      throw new Error(
        `Invalid environment name "${name}". Environment names must only contain alphanumeric characters and "$", "_".`,
      )
    }
    this.name = name
    this._topLevelConfig = topLevelConfig
    this._options = options

    // 核心设计:通过 Proxy 实现配置合并
    this.config = new Proxy(
      options as ResolvedConfig & ResolvedEnvironmentOptions,
      {
        get: (target, prop: keyof ResolvedConfig) => {
          if (prop === 'logger') return this.logger
          if (prop in target) {
            return this._options[prop as keyof ResolvedEnvironmentOptions]
          }
          return this._topLevelConfig[prop]
        },
      },
    )

    // 为每个环境配置带颜色标记的 logger
    const environment = colors.dim(`(${this.name})`)
    const colorIndex =
      [...this.name].reduce((acc, c) => acc + c.charCodeAt(0), 0) %
      environmentColors.length
    // ...
  }
}

Proxy 配置合并是 Environment API 最精妙的设计之一。environment.config 是一个 Proxy 对象:

  • 当访问的属性存在于环境选项(_options)中时,返回环境特定的值
  • 当访问的属性不存在于环境选项中时,回退到顶层配置(_topLevelConfig

这种设计使得环境配置可以选择性覆盖顶层配置,同时共享大部分通用配置,避免了完整配置的拷贝。

日志颜色化:每个环境根据名称的字符编码计算一个颜色索引,使得日志输出中不同环境的信息可以通过颜色直观区分:

const environmentColors = [
  colors.blue,    // 蓝
  colors.magenta, // 品红
  colors.green,   // 绿
  colors.gray,    // 灰
]

16.2.3 BaseEnvironment:插件层

BaseEnvironmentPartialEnvironment 基础上增加了插件访问能力和初始化状态追踪:

export class BaseEnvironment extends PartialEnvironment {
  get plugins(): readonly Plugin[] {
    return this.config.plugins
  }

  _initiated: boolean = false

  constructor(
    name: string,
    config: ResolvedConfig,
    options: ResolvedEnvironmentOptions = config.environments[name],
  ) {
    super(name, config, options)
  }
}

plugins 属性通过 getter 从配置中读取,由于 config 是 Proxy,这意味着每个环境可以拥有独立的插件列表。_initiated 标志用于确保 init() 方法只被调用一次。

16.2.4 UnknownEnvironment:扩展保护

export class UnknownEnvironment extends BaseEnvironment {
  mode = 'unknown' as const
}

UnknownEnvironment 的设计目的体现在源码注释中:

This class discourages users from inversely checking the mode to determine the type of environment, e.g.

const isDev = environment.mode !== 'build' // bad
const isDev = environment.mode === 'dev'   // good

如果未来 Vite 添加新的环境类型,反向检查(!== 'build')会错误地将新类型归类为 dev。UnknownEnvironment 作为”未知类型占位符”,迫使开发者使用正向检查(=== 'dev'),从而保证代码的前向兼容性。

16.3 DevEnvironment

16.3.1 核心组件

DevEnvironment 是开发阶段最重要的环境类型,它拥有完整的运行时基础设施:

export class DevEnvironment extends BaseEnvironment {
  mode = 'dev' as const

  // 每环境独立的模块图
  moduleGraph: EnvironmentModuleGraph

  // 每环境独立的依赖优化器
  depsOptimizer?: DepsOptimizer

  // 每环境独立的插件容器
  get pluginContainer(): EnvironmentPluginContainer<DevEnvironment> {
    if (!this._pluginContainer)
      throw new Error(`${this.name} environment.pluginContainer called before initialized`)
    return this._pluginContainer
  }

  // 热更新通道
  hot: NormalizedHotChannel
}

16.3.2 初始化流程

DevEnvironment 的初始化是一个两阶段过程:

sequenceDiagram
    participant Server as ViteDevServer
    participant Env as DevEnvironment
    participant PC as PluginContainer
    participant DOpt as DepsOptimizer

    Note over Server,DOpt: 阶段 1: init()
    Server->>Env: init({ watcher, previousInstance })
    Env->>PC: createEnvironmentPluginContainer(env, plugins, watcher)
    PC-->>Env: pluginContainer 就绪

    Note over Server,DOpt: 阶段 2: listen()
    Server->>Env: listen(server)
    Env->>Env: hot.listen() -- 开始接收 HMR 消息
    Env->>DOpt: depsOptimizer.init()
    DOpt-->>Env: 依赖扫描和预打包完成
    Env->>Env: warmupFiles() -- 预热指定文件

构造函数中的关键初始化:

constructor(name, config, context) {
  super(name, config, options)

  // 创建独立的模块图
  this.moduleGraph = new EnvironmentModuleGraph(name, (url: string) =>
    this.pluginContainer!.resolveId(url, undefined),
  )

  // 配置热更新通道
  this.hot = context.transport
    ? normalizeHotChannel(context.transport, context.hot)
    : normalizeHotChannel({}, context.hot)

  // 注册 fetchModule 和 getBuiltins 远程调用
  this.hot.setInvokeHandler({
    fetchModule: (id, importer, options) =>
      this.fetchModule(id, importer, options),
    getBuiltins: async () =>
      this.config.resolve.builtins.map(/* 序列化 */),
  })

  // 创建依赖优化器
  if (!context.disableDepsOptimizer) {
    const { optimizeDeps } = this.config
    if (context.depsOptimizer) {
      this.depsOptimizer = context.depsOptimizer
    } else if (!isDepOptimizationDisabled(optimizeDeps)) {
      this.depsOptimizer = (
        optimizeDeps.noDiscovery
          ? createExplicitDepsOptimizer
          : createDepsOptimizer
      )(this)
    }
  }
}

16.3.1-bis 源码核对:pluginContainer 的 getter 保护——“未初始化时访问立刻抛错”

DevEnvironment 把 pluginContainer 做成 getter(server/environment.ts:65-70):

get pluginContainer(): EnvironmentPluginContainer<DevEnvironment> {
  if (!this._pluginContainer)
    throw new Error(
      `${this.name} environment.pluginContainer called before initialized`,
    )
  return this._pluginContainer
}

这是一条**“编程契约”**——plugin container 必须在 init() 之后才能访问。如果用户的代码在 init() 之前就尝试 environment.pluginContainer.resolveId(...)——立刻抛错 "xxx environment.pluginContainer called before initialized"

这种”早失败”设计比让用户拿到 undefined 后在某处 null pointer exception 要好得多——错误发生点和错误原因直接关联,不需要追踪调用栈找根因。

_pluginContainer 是内部字段,类型上是 EnvironmentPluginContainer<DevEnvironment> | undefined——TypeScript 强制 getter 里必须 narrow(throw 或 return)。如果直接 return this._pluginContainer——类型错误、编译都过不去。这是用 TypeScript 来强制运行时校验的一个典型套路。

16.3.1-ter 源码核对:_pendingRequests 的请求去重 + abort

DevEnvironment 的模块级状态(server/environment.ts:80-95)有一对字段:

_closing: boolean = false
_pendingRequests: Map<
  string,
  {
    request: Promise<TransformResult | null>
    timestamp: number
    abort: () => void
  }
>
_crawlEndFinder: CrawlEndFinder

_pendingRequestsURL → 进行中的 transform Promise 的映射。当浏览器并发请求同一个 URL(比如 HMR 导致多个模块同时请求 shared utility)——第二次请求不会启动新的 transform、而是等第一次的 Promise——相同的 URL 永远只有一次 transform 在跑。

abort 函数的存在说明这些 transform 是可中断的——当 server 关闭时、或者检测到文件已变化需要重新 transform 时,Vite 会 abort 旧的进行中 transform、启动新的。这对大型项目的 dev server 性能至关重要——如果一个 slow transform(比如一个大 SCSS 文件)被触发两次、没有 abort 机制、两次都会跑完、浪费 CPU。

_closing 标志配合 abort——server 关闭时把 _closing 置 true、所有 pending 的 abort 都被调用、transform 早退避免阻塞关闭流程。这让 vite --forceCtrl+C瞬间响应而不是等完所有进行中的编译。

请求去重 + 可中断这对机制是 Vite dev server “又快又可靠” 的隐形支柱——用户永远感觉不到它们存在,但少了它们的话 dev server 在中大型项目里会卡顿、慢关闭、重复工作。

16.3.2-bis 源码核对:init() 的幂等保护和 listen() 的执行顺序契约

DevEnvironment.init(server/environment.ts:181-199)里有一段简短但关键的保护:

async init(options?: {
  watcher?: FSWatcher
  previousInstance?: DevEnvironment
}): Promise<void> {
  if (this._initiated) {
    return
  }
  this._initiated = true
  this._pluginContainer = await createEnvironmentPluginContainer(
    this, this.config.plugins, options?.watcher,
  )
}

_initiated 布尔锁——init 可重复调用、但只有第一次真正初始化。为什么要做这个保护?因为Vite 内部多个路径都可能触发 init——restart、watch mode、远程 runner 接入等——用户代码如果不知道自己在做重复触发、框架代码如果不兜住就会重复构造 plugin container、得到两个互相不知道对方存在的 container。

listen 方法上方的注释(第 201-206 行)揭示了另一条契约:

When the dev server is restarted, the methods are called in the following order: - new instance init - previous instance close - new instance listen

dev server restart 时的生命周期序列是**“new init → old close → new listen”——不是”old close → new init”。为什么这样?因为 new 的 plugin container 初始化可能需要读取一些旧 server 还在持有的资源**(比如 FSWatcher、optimizer 缓存)——新 init 先完成再让旧 close,保证过渡期有一段时间两个 environment 同时存在、资源无缝接力。

这条顺序看起来违反直觉(“为什么不是先关旧再开新”)——但它是Vite 实现零 downtime restart 的关键。HMR 没中断、浏览器 websocket 没断、用户几乎感觉不到 server 重启——靠这条”先开新、再关旧”的顺序撑住。

16.3.2-ter 源码核对:warmupRequest 的错误分级

server/environment.ts:238-258 的 warmupRequest 也值得完整引用:

async warmupRequest(url: string): Promise<void> {
  try {
    await this.transformRequest(url)
  } catch (e) {
    if (
      e?.code === ERR_OUTDATED_OPTIMIZED_DEP ||
      e?.code === ERR_CLOSED_SERVER
    ) {
      // these are expected errors
      return
    }
    // Unexpected error, log the issue but avoid an unhandled exception
    this.logger.error(
      buildErrorMessage(e, [`Pre-transform error: ${e.message}`], false),
      {
        error: e,
        timestamp: true,
      },
    )
  }
}

warmup 是后台预热——用户没直接请求的文件被 Vite 猜测性地提前 transform。其中两种错误是预期的(不是 bug):

1、ERR_OUTDATED_OPTIMIZED_DEP:预构建依赖在 warmup 期间被 invalidate(比如用户改了 import 导致新的 dep 加入)——warmup 的请求失效了、静默丢弃、等下次真实请求来时重跑。 2、ERR_CLOSED_SERVER:server 已经关了(dev server 重启的过渡期)——warmup 请求到达时 server 已不存在、静默丢弃。

其他错误用 logger.error 打印——但不 throw——warmup 是 background 任务、不应该让任何错误阻塞主流程。buildErrorMessage 还会把 e.message 前缀成 “Pre-transform error:“——让用户一眼看出这是 warmup 阶段的错误、而不是主请求路径的错误。

这种错误分级 + 静默吞掉预期错误的模式是 Vite 后台任务的典型实现——不让”可能发生的正常失败”污染错误日志、但也不让真 bug 被吞掉。用户的错误日志始终是有信号价值的——这是 DX 的隐形投入。

16.3.3 模块图隔离

每个 DevEnvironment 拥有独立的 EnvironmentModuleGraph,这意味着:

  • 客户端环境中的 import './style.css' 和 SSR 环境中的 import './style.css' 会产生不同的模块节点
  • 每个环境的模块可以有不同的 transformResult(因为插件可能根据环境产生不同的输出)
  • HMR 失效在环境内传播,不会跨环境影响
graph TB
    subgraph "client 环境"
        A1["./App.vue<br/>transformResult: 客户端 SFC"]
        A2["./style.css<br/>transformResult: CSS Module"]
        A1 --> A2
    end

    subgraph "ssr 环境"
        B1["./App.vue<br/>transformResult: SSR SFC"]
        B2["./style.css<br/>transformResult: 空 (SSR 不处理 CSS)"]
        B1 --> B2
    end

    style A1 fill:#e3f2fd
    style B1 fill:#e8f5e9

16.3.4 热更新与失效传播

DevEnvironment 的 invalidateModule 方法处理从客户端发来的模块失效消息:

protected invalidateModule(m, _client) {
  const mod = this.moduleGraph.urlToModuleMap.get(m.path)
  if (
    mod &&
    mod.isSelfAccepting &&
    mod.lastHMRTimestamp > 0 &&
    !mod.lastHMRInvalidationReceived
  ) {
    mod.lastHMRInvalidationReceived = true
    this.logger.info(
      colors.yellow(`hmr invalidate `) + colors.dim(m.path),
      { timestamp: true },
    )
    const file = getShortName(mod.file!, this.config.root)
    updateModules(
      this,
      file,
      [...mod.importers].filter((imp) => imp !== mod),
      mod.lastHMRTimestamp,
      m.firstInvalidatedBy,
    )
  }
}

lastHMRInvalidationReceived 标志防止同一模块在单次 HMR 周期内被重复失效。失效传播只沿着当前环境的模块图进行,不会跨环境。

16.4 BuildEnvironment

16.4.1 构建环境的简洁性

相比 DevEnvironment 的丰富功能,BuildEnvironment 非常简洁:

export class BuildEnvironment extends BaseEnvironment {
  mode = 'build' as const
  isBuilt = false

  constructor(
    name: string,
    config: ResolvedConfig,
    setup?: { options?: EnvironmentOptions },
  ) {
    let options = config.environments[name]
    if (!options) {
      throw new Error(`Environment "${name}" is not defined in the config.`)
    }
    if (setup?.options) {
      options = mergeConfig(options, setup.options) as ResolvedEnvironmentOptions
    }
    super(name, config, options)
  }

  async init(): Promise<void> {
    if (this._initiated) return
    this._initiated = true
  }
}

BuildEnvironment 不需要模块图(构建使用 Rolldown 的内部图)、不需要 HMR、不需要依赖优化器。它的主要职责是:

  1. 持有环境特定的配置
  2. 提供环境特定的插件列表
  3. 作为构建函数的上下文对象

16.4.1-bis 源码核对:BuildEnvironment.init() 几乎是空的——对比 DevEnvironment

BuildEnvironment 的 init(build.ts:1722-1727)只有三行:

async init(): Promise<void> {
  if (this._initiated) {
    return
  }
  this._initiated = true
}

不像 DevEnvironment 那样还要 create pluginContainer。为什么?因为 build 阶段的 plugin container 是由 Rolldown 自己管理的——Vite 把 plugin 列表交给 Rolldown、Rolldown 在 bundle/generate 过程中自己按需调用 plugin 钩子。BuildEnvironment 只作为”带配置的身份牌”存在——提供给 plugin 看 this.environment.config 用。

这是两个环境的本质区别:

维度DevEnvironmentBuildEnvironment
模块图管理自己的 EnvironmentModuleGraph由 Rolldown 管理
插件容器自己的 EnvironmentPluginContainer由 Rolldown 管理
依赖优化自己的 depsOptimizer不需要(Rolldown 整体 bundle)
生命周期长驻、warm cache短命、一次性构建
中断能力支持 transform abort不支持(bundle 是原子操作)

DevEnvironment 是 Vite 自己调度的 runtime;BuildEnvironment 是给 Rolldown 传配置的壳——这条区分理清后,为什么 BuildEnvironment init 这么简单就合情合理。

16.4.1-ter 源码核对:ViteBuilder 的 sharedConfigBuild + sharedPlugins

build.ts:1740-1756 的 BuilderOptions 定义两个 @experimental 开关:

export interface BuilderOptions {
  sharedConfigBuild?: boolean
  sharedPlugins?: boolean
  buildApp?: (builder: ViteBuilder) => Promise<void>
}

默认值都是 false——每个环境构建时 config 和 plugin 实例是独立的——互不影响。但这带来一个代价:

  • 一个 plugin 实例只服务一个环境 → 如果用户在 plugin 里用 new Map() 做缓存、缓存被 N 个环境实例共用不了
  • config 独立 → 一个环境更新 config.something 另一个看不到。

sharedConfigBuildsharedPlugins 让这俩行为和 dev server 对齐——dev server 里 plugin 实例是 shared 的(每 env 的 pluginContainer 包同一个 plugin 对象),build 时默认不是——这是一个历史遗留的不一致。加这两个 flag 让用户能opt-in 对齐 dev 行为——避免”dev 里好用的 plugin 到 build 时行为变了”的谜题。

这条设计透露了Vite 团队的自觉:Environment API 在 dev 和 build 下的行为不完全对称——他们知道这是技术债、用 experimental flag 铺路、未来版本可能默认对齐。把”不完美的现状”显式暴露成配置——比假装完美一致更诚实。

16.4.2 ViteBuilder:多环境构建编排

export interface ViteBuilder {
  environments: Record<string, BuildEnvironment>
  config: ResolvedConfig
  buildApp(): Promise<void>
  build(environment: BuildEnvironment): Promise<RolldownOutput>
}

ViteBuilder 管理所有 BuildEnvironment 实例,并通过 buildApp() 方法编排它们的构建顺序。框架可以通过 builder.buildApp 配置自定义构建逻辑,例如先构建 SSR 环境再构建客户端环境。

export interface BuilderOptions {
  sharedConfigBuild?: boolean  // 是否在环境间共享配置实例
  sharedPlugins?: boolean      // 是否在环境间共享插件实例
  buildApp?: (builder: ViteBuilder) => Promise<void>
}

sharedConfigBuildsharedPlugins 选项允许控制环境间的共享程度,在构建性能和隔离性之间做出权衡。

16.5 ScanEnvironment

16.5.1 依赖扫描的专用环境

ScanEnvironmentoptimizer/scan.ts)是一个特殊的环境类型,专用于依赖预打包的扫描阶段:

export class ScanEnvironment extends BaseEnvironment {
  mode = 'scan' as const

  get pluginContainer(): EnvironmentPluginContainer {
    if (!this._pluginContainer)
      throw new Error(`${this.name} environment.pluginContainer called before initialized`)
    return this._pluginContainer
  }

  _pluginContainer: EnvironmentPluginContainer | undefined

  async init(): Promise<void> {
    if (this._initiated) return
    this._initiated = true
    this._pluginContainer = await createEnvironmentPluginContainer(
      this,
      this.plugins,
      undefined,
      false, // watcher 为 undefined,不监听文件变化
    )
  }
}

与 DevEnvironment 不同,ScanEnvironment:

  • 没有模块图(扫描不需要持久化模块信息)
  • 没有 HMR 通道(扫描是一次性过程)
  • 没有依赖优化器(扫描本身就是优化器的前置步骤)
  • 没有文件监听器(扫描在启动时执行一次)

16.5.2 开发环境到扫描环境的降级

Vite 提供了一个 devToScanEnvironment 工具函数,将 DevEnvironment “降级”为 ScanEnvironment 的行为:

export function devToScanEnvironment(
  environment: DevEnvironment,
): ScanEnvironment {
  return {
    mode: 'scan',
    get name() { return environment.name },
    getTopLevelConfig() { return environment.getTopLevelConfig() },
    get config() { return environment.config },
    get logger() { return environment.logger },
    get pluginContainer() { return environment.pluginContainer },
    get plugins() { return environment.plugins },
  } as unknown as ScanEnvironment
}

这个函数创建了一个 ScanEnvironment 的”视图”,它复用了 DevEnvironment 的配置和插件容器,但限制了对模块图和其他运行时设施的访问。这种”限制性代理”模式确保了扫描阶段不会意外修改开发服务器的运行时状态。

16.5.3 源码核对:devToScanEnvironment 的 “duck-typed” 视图转换

ScanEnvironment 有一个伴生函数 devToScanEnvironment(scan.ts:71-95)——从 DevEnvironment 创建一个 ScanEnvironment 视图

// Restrict access to the module graph and the server while scanning
export function devToScanEnvironment(
  environment: DevEnvironment,
): ScanEnvironment {
  return {
    mode: 'scan',
    get name() { return environment.name },
    getTopLevelConfig() { return environment.getTopLevelConfig() },
    get config() { return environment.config },
    get logger() { return environment.logger },
    get pluginContainer() { return environment.pluginContainer },
    get plugins() { return environment.plugins },
  } as unknown as ScanEnvironment
}

这段代码做的事情用一句话概括:造一个 DevEnvironment 的”阉割版视图”——只暴露 scan 需要的字段、隐藏 moduleGraph/depsOptimizer/hot channel 等 scan 不应该碰的属性

注释 “Restrict access to the module graph and the server while scanning” 直接点出动机——scan 阶段不能访问 moduleGraph。为什么?因为 scan 在 Vite 启动早期跑——用户代码还没被完整 transform、moduleGraph 还没稳定——scan 要独立于主 pipeline 的状态。如果让 scanner 能摸到 moduleGraph、它可能读到半成品、产出错误的依赖列表。

这种”限制视图”的实现方式有三条亮点:

1、用 Proxy 风格的 getter 而不是复制字段get name() { return environment.name }——dev 的字段变了(比如 name 更新),scan 视图自动同步。不是快照、是引用。

2、as unknown as ScanEnvironment——TypeScript 知道这个对象不是真正 new 出来的 ScanEnvironment 实例(少了 _pluginContainer 等字段)。用 as unknown as 两步断言强制告诉 TS “相信我这就是”——打破类型安全的边界、用注释说明。这种写法在 Vite 源码里少见——通常被认为是 “欠优雅”——但在这种需要”同一类实例的两种视角”的场景下,用 as 强转比写一个新类干净。

3、view 不是 extend 而是 “deferred access”。如果改成 “ScanEnvironment 是 DevEnvironment 的子类”——DevEnvironment 的所有字段都暴露出去、限制访问的目的就失败了。用 proxy-like view 才能实现”只暴露一部分字段”的语义。

这是访问控制在 TypeScript 类型系统里的工程实现——没有 Java 那种 private 字段 + 接口暴露机制,只能用对象组合 + as 断言。读懂后你在自己的 TS 代码里遇到类似需求(“这个对象的大部分字段要暴露、小部分要隐藏”)时知道怎么处理。

16.6 Environment 联合类型

environment.ts 定义了 Environment 的联合类型:

export type Environment =
  | DevEnvironment
  | BuildEnvironment
  | /** @internal */ ScanEnvironment
  | UnknownEnvironment

注意 ScanEnvironment 被标记为 @internal,表示它不是公开 API 的一部分。这种类型设计使得插件开发者可以通过 environment.mode 进行类型缩窄:

function handleEnvironment(environment: Environment) {
  if (environment.mode === 'dev') {
    // TypeScript 知道这里是 DevEnvironment
    environment.moduleGraph.getModuleById(id)
  } else if (environment.mode === 'build') {
    // TypeScript 知道这里是 BuildEnvironment
    console.log(environment.isBuilt)
  }
}

16.7 perEnvironmentPlugin

16.7.1 环境感知的插件适配

perEnvironmentPlugin 是一个高阶函数,将环境感知的插件工厂转换为标准的 Vite 插件:

export function perEnvironmentPlugin(
  name: string,
  factory: (environment: Environment) => Plugin | Plugin[] | false | undefined,
): Plugin

它允许插件根据环境返回不同的实现,或者通过返回 false 来声明某个环境不需要该插件。例如 Manifest 插件:

return perEnvironmentPlugin('native:manifest', (environment) => {
  if (!environment.config.build.manifest) return false

  return [
    { name: 'native:manifest-envs', /* ... */ },
    nativeManifestPlugin({ /* ... */ }),
    { name: 'native:manifest-compatible', /* ... */ },
  ]
})

16.7.1.5 源码核对:perEnvironmentState 的完整实现——一行 WeakMap 背后的设计含义

perEnvironmentState(environment.ts:20-33)只有 13 行,但它是 Vite 6 插件 API 的新 idiom——值得逐行拆解:

export function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {
  const stateMap = new WeakMap<Environment, State>()
  return function (context: PluginContext) {
    const { environment } = context
    let state = stateMap.get(environment)
    if (!state) {
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

这个函数返回一个 getter 函数——每次被调用时:

  1. 从 context 拿到当前 environment
  2. 查 WeakMap 找这个 environment 对应的 state
  3. 没有就 lazy init、存进 WeakMap
  4. 返回 state

用法示例:

const getState = perEnvironmentState((env) => ({
  parsedCount: 0,
  cache: new Map(),
}))

export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    transform(code, id) {
      const state = getState(this)  // ← 自动隔离到当前 environment
      state.parsedCount++
    },
  }
}

关键洞察:client/ssr/rsc 每个 environment 看到的是自己独立的 state——即使它们共享同一个 plugin 实例。插件作者写代码时像是在写”global state”、但框架自动按 environment 做了分片。

三条深层机制:

1、WeakMap 让 state 跟 environment 的生命周期绑定。environment 被 GC、对应的 state 自动清理。如果用普通 Map、每个 environment 的 state 会永远驻留在 map 里——多环境开发时内存不断堆积。

2、lazy init(initial() 在第一次访问时才调)。plugin 初始化时不知道会和几个 environment 交互、强求提前初始化所有 state 不现实。lazy 策略让只有真正被用到的 environment 才产生 state

3、类型签名 (context: PluginContext) => State 让 getter 直接接 this——插件钩子里的 this 就是 PluginContext。用法是 const state = getState(this)——简洁到看起来像魔法。其实只是 TypeScript 泛型 + Proxy 式设计的组合。

这个函数值得被 AI 系统的插件框架抄走——LangChain/LangGraph 未来如果做多环境支持(比如一个 agent 跨多个 model provider),完全可以复用这个 idiom。“Environment-scoped state”是一个在多种工具生态里都能用上的抽象。

16.7.2 perEnvironmentState

perEnvironmentState 提供了跨 Hook 的环境级状态管理:

export function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {
  const stateMap = new WeakMap<Environment, State>()
  return function (context: PluginContext) {
    const { environment } = context
    let state = stateMap.get(environment)
    if (!state) {
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}
graph LR
    subgraph "perEnvironmentState"
        A["WeakMap&lt;Environment, State&gt;"]
    end

    B["PluginContext (client)"] -->|"getState(this)"| A
    C["PluginContext (ssr)"] -->|"getState(this)"| A

    A --> D["State for client"]
    A --> E["State for ssr"]

    style D fill:#e3f2fd
    style E fill:#e8f5e9

关键设计点:

  1. WeakMap:当环境实例被垃圾回收时,关联的状态也会被自动清理
  2. 惰性初始化:状态在第一次访问时创建,避免了不必要的初始化开销
  3. 类型安全:通过泛型 <State> 确保类型正确性
  4. 上下文绑定:状态通过 PluginContext 中的 environment 引用获取,插件无需手动管理环境映射

使用示例(Manifest 插件):

const getState = perEnvironmentState(() => ({
  manifest: {} as Manifest,
  outputCount: 0,
  reset() {
    this.manifest = {}
    this.outputCount = 0
  },
}))

// 在 Hook 中使用
generateBundle(_, bundle) {
  const state = getState(this) // this 是 PluginContext
  state.outputCount++
  // ...
}

16.7.3 源码核对:resolveEnvironmentPlugins 的四种 applyToEnvironment 返回值

§16.7 讲了 applyToEnvironment 钩子。真实的 resolveEnvironmentPlugins(plugin.ts:390-412)按返回值分 4 种处理:

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              // ← 返回 false:跳过这个插件
      }
      if (applied !== true) {
        environmentPlugins.push(
          ...((await asyncFlatten(arraify(applied))).filter(
            Boolean,
          ) as Plugin[]),     // ← 返回插件/插件数组:替换成这些插件
        )
        continue
      }
    }
    environmentPlugins.push(plugin)   // ← 返回 true 或无 applyToEnvironment:使用原 plugin
  }
  return environmentPlugins
}

applyToEnvironment 的返回值有 4 种可能语义:

  1. true——使用原 plugin。这是 “confirm use” 的信号。
  2. false / 假值——跳过这个 plugin。这个 environment 不用它。
  3. 单个 plugin——替换原 plugin。用一个新 plugin 替代。用于”client/ssr 用不同实现但同名插件”。
  4. plugin 数组——展开成多个。一个原 plugin 变成多个 environment-specific plugin。

arraify + asyncFlatten + .filter(Boolean) 的组合处理嵌套数组 + 异步 Promise + 假值——用户能返回 [pluginA, Promise<pluginB>, [pluginC, pluginD]] 这种复杂结构——框架自动铺平。API 最大限度容忍用户写法、实现内部做归一

这个设计的工程价值:插件作者能写 “conditional plugin”——同一个 plugin 对象根据 environment 不同产出完全不同的行为。比如 @vitejs/plugin-react 的客户端版本和 SSR 版本可能共享同一个入口 plugin、内部通过 applyToEnvironment 分叉——用户只 plugins: [react()] 一行,Vite 在不同 environment 下实际使用两套 plugin。

16.7.4 源码核对:perEnvironmentPlugin 的一行实现——极简但表达力强

plugin.ts:417-427 的 perEnvironmentPlugin 只有 10 行:

export function perEnvironmentPlugin(
  name: string,
  applyToEnvironment: (
    environment: PartialEnvironment,
  ) => boolean | Promise<boolean> | PluginOption,
): Plugin {
  return {
    name,
    applyToEnvironment,
  }
}

就是把 applyToEnvironment 函数包装成一个只有 name 和 applyToEnvironment 字段的 Plugin 对象——没有 transform/resolveId 等钩子。这个 plugin 本身”什么也不做”、但它是一个插件产出器——在 resolveEnvironmentPlugins 里被调用时,它产出的实际 plugin(或插件数组)才是真正注册进 pipeline 的东西。

Vite 源码里有 5 个地方用到 perEnvironmentPlugin——dynamicImportVars.tsoxc.tsimportAnalysisBuild.tswasm.tsmodulePreloadPolyfill.ts——全部是条件性功能:根据 environment 的 consumer/mode/target 决定要不要生成对应 plugin。

比如 oxc.ts:212 的用法:

return perEnvironmentPlugin('native:transform', (environment) => {
  return nativeTransformPlugin({ root: environment.config.root, ... })
})

——对每个 environment 产出一个定制了 root 的 nativeTransformPlugin。没有 perEnvironmentPlugin 的话,这段代码要在 Vite 启动时针对每个 environment 手动做、逻辑会散落在多处。perEnvironmentPlugin 让**“per-env 插件产出”的模式**成为一等公民、和普通 plugin 一样通过 plugins: [] 注册。

16.8 环境配置体系

16.8.1 配置声明

环境通过 config.environments 对象声明:

// vite.config.ts
export default {
  environments: {
    client: {
      // 客户端环境配置
      build: { outDir: 'dist/client' },
    },
    ssr: {
      // SSR 环境配置
      build: { outDir: 'dist/server' },
      resolve: {
        conditions: ['node'],
        externalConditions: ['node', 'module-sync'],
      },
    },
    rsc: {
      // React Server Components 环境(自定义)
      build: { outDir: 'dist/rsc' },
      resolve: {
        conditions: ['react-server'],
      },
    },
  },
}

16.8.2 Proxy 配置合并的实际效果

graph TB
    subgraph "顶层配置 (topLevelConfig)"
        A["root: '/project'"]
        B["base: '/'"]
        C["mode: 'development'"]
        D["plugins: [vue(), ...]"]
    end

    subgraph "环境选项 (_options)"
        E["build.outDir: 'dist/ssr'"]
        F["resolve.conditions: ['node']"]
    end

    subgraph "environment.config (Proxy)"
        G["root -> '/project' (回退到顶层)"]
        H["base -> '/' (回退到顶层)"]
        I["build.outDir -> 'dist/ssr' (环境覆盖)"]
        J["resolve.conditions -> ['node'] (环境覆盖)"]
    end

    A --> G
    B --> H
    E --> I
    F --> J

这种 Proxy 方式的优势在于:

  • 零拷贝:不需要深拷贝整个配置对象
  • 惰性求值:只在属性被访问时才进行查找
  • 自动回退:未覆盖的属性自动使用顶层值
  • getTopLevelConfig():当确实需要访问顶层配置时,可以绕过 Proxy

16.9 多环境协作模式

16.9.1 applyToEnvironment 过滤

插件可以通过 applyToEnvironment Hook 声明自己适用于哪些环境:

// 只在有 minify 配置的环境中生效
applyToEnvironment(environment) {
  return !!environment.config.build.minify
}

// 只在客户端环境中生效
applyToEnvironment(environment) {
  return environment.config.consumer === 'client'
}

// 只在生成 SSR Manifest 的环境中生效
applyToEnvironment(environment) {
  return !!environment.config.build.ssrManifest
}

16.9.2 开发服务器中的多环境

sequenceDiagram
    participant Client as 浏览器
    participant Server as ViteDevServer
    participant CEnv as client Environment
    participant SEnv as ssr Environment

    Client->>Server: GET /src/App.vue
    Server->>CEnv: transformRequest('/src/App.vue')
    CEnv->>CEnv: pluginContainer.transform (客户端插件链)
    CEnv-->>Server: 客户端版本的 App.vue

    Server->>SEnv: transformRequest('/src/App.vue')
    SEnv->>SEnv: pluginContainer.transform (SSR 插件链)
    SEnv->>SEnv: ssrTransform
    SEnv-->>Server: SSR 版本的 App.vue

    Note over CEnv,SEnv: 两个环境独立处理同一文件,<br/>产生不同的转换结果

16.9.3 consumer 属性

环境的 consumer 属性标识了代码的运行目标:

// 在环境配置中
consumer: 'client' | 'server'

这个属性影响多个插件的行为:

  • CSS 插件在 consumer === 'server' 时不注入样式代码
  • Module Preload Polyfill 在 consumer !== 'client' 时返回空
  • WASM 插件在 consumer === 'server' 时使用文件系统读取而非 fetch

16.10 设计决策分析

16.10.1 为什么用 Proxy 而非深拷贝

Proxy 方式相比深拷贝有明显优势:

方面Proxy深拷贝
内存只存储覆盖的属性完整拷贝所有属性
一致性顶层配置修改自动反映拷贝后脱离同步
性能属性访问有微小开销一次性开销,后续无开销
动态性支持运行时配置变更不支持

对于 Vite 的场景,配置对象属性众多但每个环境只覆盖少量属性,Proxy 方式在内存效率和一致性上都更优。

16.10.2 WeakMap 状态管理

graph TB
    A["perEnvironmentState"] --> B["WeakMap&lt;Environment, State&gt;"]
    B --> C["优势 1: 自动垃圾回收"]
    B --> D["优势 2: 避免内存泄漏"]
    B --> E["优势 3: 无需手动清理"]

    F["对比: Map&lt;string, State&gt;"] --> G["需要手动 delete"]
    F --> H["环境名称可能冲突"]
    F --> I["环境销毁后状态残留"]

    style B fill:#e8f5e9
    style F fill:#ffcdd2

16.10.3 模式正向检查

UnknownEnvironment 的引入迫使开发者使用正向模式检查:

// 这段代码在添加新环境类型后仍然正确
if (env.mode === 'dev') {
  // DevEnvironment 特有逻辑
} else if (env.mode === 'build') {
  // BuildEnvironment 特有逻辑
} else {
  // 未知类型,安全的默认行为
}

// 这段代码在添加新环境类型后可能出错
if (env.mode !== 'build') {
  // 错误:新的环境类型也会进入这里
}

16.3.4-bis 源码核对:close() 的 Promise.allSettled 并行关闭

DevEnvironment.close(server/environment.ts:293-312)是一个优雅的并行资源释放

async close(): Promise<void> {
  this._closing = true

  this._crawlEndFinder.cancel()
  await Promise.allSettled([
    this.pluginContainer.close(),
    this.depsOptimizer?.close(),
    // WebSocketServer is independent of HotChannel and should not be closed on environment close
    isWebSocketServer in this.hot ? Promise.resolve() : this.hot.close(),
    (async () => {
      while (this._pendingRequests.size > 0) {
        await Promise.allSettled(
          [...this._pendingRequests.values()].map(
            (pending) => pending.request,
          ),
        )
      }
    })(),
  ])
}

三条设计:

1、Promise.allSettled 不是 Promise.all。差别是:allSettled 在任何子 promise reject 时也会等所有其他 promise 完成——close 路径上哪怕某个资源关闭失败,其他资源也能正常释放。如果用 Promise.all,一个 reject 就 short-circuit、其他 Promise 的 finally 逻辑不跑——资源泄漏。

2、WebSocketServer is independent of HotChannel and should not be closed on environment close——这条注释是一条关键的 ownership 说明:WebSocket server 的生命周期和整个 dev server 绑定、不是单个 environment。environment.close() 不应该关 WebSocket——否则多 environment 下关一个 env 就把 ws 关了、其他 env 的浏览器连接断了。

3、while (this._pendingRequests.size > 0) 的 wait-for-empty 循环——这是一个进行中 request 的 drain 机制。close 时不能立刻返回——要等所有 in-flight transform 结束、避免 “server 关了但 transform 还在跑、拿着已关的 pluginContainer” 的悬空状态。while 循环 + allSettled 等完所有 pending、再检查 size 是经典的”drain queue”模式。

close 方法不过 20 行、但它处理了 4 种资源(plugin container、deps optimizer、hot channel、pending requests)的并行释放 + ownership 区分。设计简洁但完备——资源关闭最容易出 bug 的地方(顺序错、漏关、关错)都被兜住。

16.3.4-ter 源码核对:_crawlEndFinder 的 “请求空闲” 检测

DevEnvironment 还有一个少人知道的 API——waitForRequestsIdle(server/environment.ts:322-324):

waitForRequestsIdle(ignoredId?: string): Promise<void> {
  return this._crawlEndFinder.waitForRequestsIdle(ignoredId)
}

对应的 CrawlEndFinder 机制(第 334-339 行):

const callCrawlEndIfIdleAfterMs = 50
interface CrawlEndFinder {
  registerRequestProcessing: (id: string, done: () => Promise<any>) => void
  waitForRequestsIdle: (ignoredId?: string) => Promise<void>
  cancel: () => void
}

waitForRequestsIdle 返回一个 Promise——当所有 static import 都被处理完、模块图稳定了(超过 50ms 无新请求)才 resolve。这是给 Vite 内部机制用的——比如第 7 章讲过的 depsOptimizer 需要”等首批 import 爬完再开始优化”——提前优化会 miss 深层依赖。

对外也是 @experimental API——插件作者能拿来做”等页面主要 JS 都 load 完再做某件事”——比如生成 manifest、打印统计。

ignoredId 参数解决死锁问题:如果一个 transform 钩子里调 waitForRequestsIdle()——这个 transform 自己就是 in-flight request——永远不会 idle。ignoredId 让你传入当前的 module id、这个 id 不算在 pending 集合里——打破”自己等自己”的死锁。这个细节是 Vite 对插件作者的周到——文档里不写这种边角、但真正写插件时踩坑了看源码注释就能找到解决方案。

这套机制和第 7 章讲过的 depsOptimizer “crawl end” 流程是一对——本章在 environment 层暴露接口、第 7 章在 optimizer 层消费。

16.3.5 源码核对:hot.setInvokeHandler 的双向通信接口

DevEnvironment 构造函数里(server/environment.ts:138-149)有这么一段:

this.hot.setInvokeHandler({
  fetchModule: (id, importer, options) => {
    return this.fetchModule(id, importer, options)
  },
  getBuiltins: async () => {
    return this.config.resolve.builtins.map((builtin) =>
      typeof builtin === 'string'
        ? { type: 'string', value: builtin }
        : { type: 'RegExp', source: builtin.source, flags: builtin.flags },
    )
  },
})

这段代码注册了两个”RPC-style handler”到 hot channel 上。hot channel(HMR 通道)不只能从 server 推送给 client——client 也能调回 server。Vite 通过 setInvokeHandler 注册一组 handler、client 通过 WebSocket 发消息触发 server 执行、server 响应结果。

这个机制在什么场景下用?Vite 的 Node.js runner——比如 Remote Runner 场景:

  • Vite 作为”模块服务器”暴露 fetchModule RPC
  • 用户的 Node.js 进程作为”runner”连接过来
  • Runner 需要某个模块时调 fetchModule(id)——server 返回 transform 后的 code
  • 这样可以把 ESM 模块执行分离到另一个进程——让 SSR、edge runtime 测试等场景变得简单

getBuiltins 返回当前环境配置的”内建模块”列表(比如 SSR 环境的 node:fsnode:path 等)——runner 知道这些不需要请求 server、直接用本地的 Node builtin 就行。

type: 'string' vs type: 'RegExp' 的序列化处理——RegExp 不能 JSON.stringify,得拆成 {source, flags} 形式传输、对端再 new RegExp(source, flags) 重建。RPC 边界上所有数据都要可 JSON 序列化——这是分布式系统的铁律。

这条机制让 Vite 从”dev server”演化成”模块即服务(Modules-as-a-Service)“——未来 edge runtime 部署、SSR 多进程、Cloudflare Worker 集成都以此为基础。Environment API + hot channel invoke handler 是 Vite 6 对”多运行时”的双头支撑。

16.10.4 本章与全书体系的呼应

Environment API 看似是 Vite 6 的新特性、实际上它是前几章所有机制的”重新组织”。梳理一下:

与第 4 章(插件系统)的升级:第 4 章讲的 plugin/transform/load 钩子在 Vite 5 是”单 pipeline”——所有 plugin 按序跑、ssr 参数区分环境。本章的 Environment API 把这条 pipeline 克隆成每环境一份——同样的 plugin 钩子、但按环境隔离的 pluginContainer。第 4 章不需要重学,只需要知道”同样的机制 × N 份”。

与第 5 章(模块图)的分治:第 5 章讲的 ModuleGraph 在 Vite 5 是全局一份。本章讲的 DevEnvironment 每个都有自己的 EnvironmentModuleGraph——一个文件在 client env 是一个 node、在 ssr env 是另一个 node、不冲突。这解决了 Vite 5 时代同文件 transformResult vs ssrTransformResult 混存的老 bug。

与第 7 章(依赖预构建)的环境化:第 7 章讲的 DepsOptimizer 在 Vite 5 是单实例。本章 DevEnvironment 的 depsOptimizer?: DepsOptimizer每环境独立 optimizer——client 预构建 browser 目标的 bundle、ssr 预构建 node 目标的 bundle、产物分开存在 .vite/deps_<env_name>/

与第 9/10 章(JS/CSS 转换)的配合:第 9 章讲的 §9.3-ter getTSConfigResolutionCache(config)、第 10 章讲的 cssPlugin 里 config.consumer === 'server' 判断——都是本章 PartialEnvironment.config 的消费者。每个 plugin 钩子的 this.environment.config 就是本章的 Proxy 对象、在不同环境下自动返回不同值。

与第 17 章(Worker 插件)的协作:第 17 章讲的 Worker 构建也是一个独立 “BuildEnvironment”——Worker 的编译是 main environment 之外的另一个 environment。Environment API 让 Worker 构建不再是 ad-hoc 的子流程、而是 first-class 的 parallel environment。

这种”一套抽象、N 种使用”的设计让 Vite 的心智模型在 6 版本里变简单了——之前有一堆 ssrisWorkerisBuild 布尔参数散落在各处;现在统一成 environment.config.*——所有环境相关逻辑走同一条查询路径。表面上看添加了复杂度(多了 environment 概念),实际上降低了总复杂度(消除了一堆特殊情况)——这是好抽象的标志。

16.10.4.5 Environment API 给其他生态的启示

Environment API 不只是 Vite 的内部机制——它的设计思想对其他构建/编排工具有普适价值:

1、对 Rolldown/Rollup 生态:Rollup 传统上是单一构建流程,没有 “environment” 概念。Vite 在 Rollup 之上套了一层 environment 抽象——用户写一份配置、产出多份不同目标的 bundle。如果你在写 Rollup 插件并希望支持 Vite 6+、理解 Environment API 让你的插件能正确使用 this.environment.config 做分支。

2、对 LangGraph/LangChain 生态:第 17 章的多 Agent 编排、第 15 章的 Store——如果未来想让一个 agent 配置同时服务多个 LLM provider 或多个部署环境(dev/prod),可以借鉴 Environment API 的模式:environment-scoped state + proxy config merge + perEnvironmentPlugin

3、对 Webpack 用户:Webpack 有 “multi-compiler” 概念(一个 webpack.config.js 数组产出多个 bundle)——但这是数组级别的多构建、每个 compiler 完全独立。Vite 的 Environment API 更精细——同一个 ViteDevServer 实例同时服务多个 environment、共享顶层 config 和 plugin 实例——开发体验优于 multi-compiler。

4、对普通 Node.js 工具作者:Environment API 背后的设计原则——“对象身份 × Proxy 配置 × WeakMap 状态”三件套——是一个值得复用的 pattern。任何”有多个受控实例、实例间需要部分共享部分隔离”的场景都适用。

5、对读者自己:如果你在设计一个需要支持多环境的工具(比如内部的 deploy 工具支持 dev/staging/prod),可以参考 Vite 的设计:environment 作为一等公民、每环境独立实例、config 通过 proxy 合并、per-env plugin 通过条件 factory 产出——这一套模板拿过来就能用。

16.10.5 源码定位索引

主题源文件关键行号
Environment 联合类型node/environment.ts7-11
perEnvironmentState同上20-33
PartialEnvironmentnode/baseEnvironment.ts13-102
BaseEnvironment同上104-121
UnknownEnvironment同上135-137
DevEnvironment 类node/server/environment.ts55+
pluginContainer getter同上65-71
_pendingRequests同上84-91
init() 幂等同上181-199
listen() 执行顺序同上207-211
warmupRequest 错误分级同上238-258
resolveEnvironmentPluginsnode/plugin.ts390-412
perEnvironmentPlugin同上417-427

16.10.6 读完本章能回答的具体问题清单

作为本章掌握度自测:

  1. Vite 5 的 ssr:boolean 为什么需要被 Environment API 替代?(§16.1.1——布尔不可扩展、共享模块图污染、优化冲突、插件歧义)
  2. 环境名为什么限制成 [\w$]+(§16.1.3——兼容目录名和 JS 标识符)
  3. Proxy 配置合并为什么比深拷贝好?(§16.1.4——内存从 O(N × config) 降到 O(N + config))
  4. prop in targettarget[prop] !== undefined 差在哪?(§16.1.4——前者正确处理显式设为 undefined 的字段)
  5. pluginContainer 用 getter 做保护想解决什么问题?(§16.3.1-bis——早失败、未 init 时访问立刻抛错)
  6. _pendingRequests 的 abort 函数有什么用?(§16.3.1-ter——可中断的进行中 transform、避免关闭时浪费 CPU)
  7. init() 为什么要幂等?(§16.3.2-bis——多路径可能触发、避免重复构造 plugin container)
  8. dev server restart 时方法调用顺序是什么?(§16.3.2-bis——new init → old close → new listen,零 downtime)
  9. warmupRequest 对哪些错误静默?(§16.3.2-ter——ERR_OUTDATED_OPTIMIZED_DEP 和 ERR_CLOSED_SERVER)
  10. applyToEnvironment 的 4 种返回值?(§16.7.3——true/false/单 plugin/plugin 数组)
  11. perEnvironmentPlugin 只有 10 行代码,价值在哪?(§16.7.4——让”per-env 插件产出”成为一等公民、被 5 个核心插件复用)

能答 8 条以上——你对 Vite 6 Environment API 的理解已经到了”能自己写跨环境插件”的水平。

16.10.7 本章的最后一条启示:从”参数化”到”对象化”的范式转变

Environment API 核心做的事用一句话概括:把散落各处的 {ssr: true} 参数聚合成 Environment 对象、让每个功能(模块图、插件容器、依赖优化)成为环境的成员

这种从”函数参数 → 对象成员”的转变在软件架构里有一个专有名词——“Parameter Object” 重构(参见《Refactoring》by Martin Fowler)。本章展示了这个 pattern 在大型框架里的完整兑现:

Before(参数化):每个 API 都要传 ssr 参数、所有实现都要 if(ssr) else 分支、新增环境类型要改一堆函数签名。

After(对象化):环境是对象、每个环境有独立数据、API 围绕环境展开(environment.transformRequest() 而非 server.transformRequest(url, {ssr}))——新增环境只需 new 一个 class 实例

这条范式转变的代价是表面 API 复杂度增加(多了一层 environment);收益是实现层大幅简化(if-else 消失、特殊情况归一到对象构造)。对大型框架来说这个权衡总是划算的——用户学一次 environment 概念、换来所有 API 的一致性。

读完本章你应该理解了 Environment API 的源码实现设计动机工程权衡——这三个层面合起来就是”真正懂”的标志。下次看到其他工具从”bool/enum 参数”重构到”对象化”(比如 Node.js 的 test runner 从 test.only = truetest.only() 方法),你能第一时间识别出这是同一个 pattern——模式识别能力是从”会用一个工具”跃迁到”懂得多个工具”的关键

16.11 小结

Environment API 是 Vite 6 最重要的架构变革,它将”环境”从一个布尔参数提升为一等公民:

  • 类型体系采用四层继承设计(PartialEnvironment -> BaseEnvironment -> Dev/Build/Scan/UnknownEnvironment),每一层增加一组能力。UnknownEnvironment 作为”类型守卫”确保正向模式检查的代码风格。

  • Proxy 配置合并通过 JavaScript Proxy 实现了零拷贝的配置覆盖。环境特定的选项覆盖顶层配置,未覆盖的属性自动回退,既保证了内存效率又维持了配置一致性。

  • DevEnvironment 拥有独立的模块图、插件容器、依赖优化器和热更新通道。模块图隔离确保了不同环境对同一文件的转换互不干扰。

  • BuildEnvironmentScanEnvironment 分别为构建和扫描阶段提供了精简的环境抽象,体现了”按需提供能力”的原则。

  • perEnvironmentPluginperEnvironmentState 为插件提供了环境感知的适配模式。WeakMap 状态管理确保了内存安全,惰性初始化避免了不必要的开销。

  • ViteBuilder 通过 sharedConfigBuildsharedPlugins 选项在隔离性和性能之间提供了灵活的权衡。

这套 API 使得 Vite 从”客户端 + SSR”的双模型,演进为可以支持任意数量自定义环境的通用构建编排器。框架开发者可以为 RSC、Service Worker、Edge Runtime 等场景定义独立的环境,每个环境拥有完全独立的处理管线,同时通过共享的顶层配置保持协调。

实践建议:clone vite 仓库、git checkout 到引入 Environment API 的那个 PR(6.0.0-beta.0 的某次提交附近),看 diff——可以看到 Vite 团队如何渐进式重构这套架构:保留旧 API 的同时引入新的、用 deprecation warning 引导迁移。第 17 章的 bundleWorkerEntry 内部用的就是 BuildEnvironment 的实例,回看会发现它不是 ad-hoc 设计、而是 Environment API 标准用法的延伸。