Vite 设计与实现
第16章 Environment API
第16章 Environment API
开篇引言
在 Vite 6 之前,一个 Vite 服务器实例只有一个统一的模块图、插件管线和依赖优化器。当项目需要同时处理客户端代码和 SSR 代码时,这些共享的基础设施不得不通过参数(如 ssr: boolean)来区分行为。这种方式简单但脆弱 — 当需要支持更多的运行环境(如 RSC、Service Worker、Edge Runtime)时,布尔参数无法扩展。
Vite 6 引入的 Environment API 从根本上改变了这一架构。每个”环境”拥有独立的模块图、插件容器和依赖优化器,它们通过共享的顶层配置保持协调。这不是一次简单的重构,而是 Vite 向”通用构建编排器”角色演进的关键一步。
本章将从 environment.ts、baseEnvironment.ts、server/environment.ts、build.ts、optimizer/scan.ts 等源码文件出发,深入分析 Environment API 的类型体系、生命周期管理和多环境协作机制。
本章要点
- 理解 Environment API 的设计动机与架构目标
- 掌握
PartialEnvironment -> BaseEnvironment -> DevEnvironment/BuildEnvironment/ScanEnvironment的类型层次 - 分析
perEnvironmentPlugin和perEnvironmentState的多环境插件适配模式 - 理解每环境独立的模块图、插件容器和依赖优化器
- 掌握环境配置的 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)
这种方式存在几个问题:
- 不可扩展:当需要支持第三种环境(如 React Server Components 的 RSC 环境)时,布尔值无法表达
- 共享污染:客户端和 SSR 共享同一个模块图,模块的
transformResult和ssrTransformResult混存在同一个节点上 - 优化冲突:客户端和 SSR 可能需要不同的依赖优化策略,共享的优化器无法同时满足
- 插件歧义:插件需要在运行时检查
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.client、environments.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:插件层
BaseEnvironment 在 PartialEnvironment 基础上增加了插件访问能力和初始化状态追踪:
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
modeto 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
_pendingRequests 是 URL → 进行中的 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 --force 或 Ctrl+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 instanceclose- new instancelisten
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、不需要依赖优化器。它的主要职责是:
- 持有环境特定的配置
- 提供环境特定的插件列表
- 作为构建函数的上下文对象
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 用。
这是两个环境的本质区别:
| 维度 | DevEnvironment | BuildEnvironment |
|---|---|---|
| 模块图管理 | 自己的 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 另一个看不到。
sharedConfigBuild 和 sharedPlugins 让这俩行为和 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>
}
sharedConfigBuild 和 sharedPlugins 选项允许控制环境间的共享程度,在构建性能和隔离性之间做出权衡。
16.5 ScanEnvironment
16.5.1 依赖扫描的专用环境
ScanEnvironment(optimizer/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 函数——每次被调用时:
- 从 context 拿到当前 environment
- 查 WeakMap 找这个 environment 对应的 state
- 没有就 lazy init、存进 WeakMap
- 返回 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<Environment, State>"]
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
关键设计点:
WeakMap:当环境实例被垃圾回收时,关联的状态也会被自动清理- 惰性初始化:状态在第一次访问时创建,避免了不必要的初始化开销
- 类型安全:通过泛型
<State>确保类型正确性 - 上下文绑定:状态通过
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 种可能语义:
true——使用原 plugin。这是 “confirm use” 的信号。false/ 假值——跳过这个 plugin。这个 environment 不用它。- 单个 plugin——替换原 plugin。用一个新 plugin 替代。用于”client/ssr 用不同实现但同名插件”。
- 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.ts、oxc.ts、importAnalysisBuild.ts、wasm.ts、modulePreloadPolyfill.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<Environment, State>"]
B --> C["优势 1: 自动垃圾回收"]
B --> D["优势 2: 避免内存泄漏"]
B --> E["优势 3: 无需手动清理"]
F["对比: Map<string, State>"] --> 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:fs、node: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 版本里变简单了——之前有一堆 ssr、isWorker、isBuild 布尔参数散落在各处;现在统一成 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.ts | 7-11 |
| perEnvironmentState | 同上 | 20-33 |
| PartialEnvironment | node/baseEnvironment.ts | 13-102 |
| BaseEnvironment | 同上 | 104-121 |
| UnknownEnvironment | 同上 | 135-137 |
| DevEnvironment 类 | node/server/environment.ts | 55+ |
| pluginContainer getter | 同上 | 65-71 |
| _pendingRequests | 同上 | 84-91 |
| init() 幂等 | 同上 | 181-199 |
| listen() 执行顺序 | 同上 | 207-211 |
| warmupRequest 错误分级 | 同上 | 238-258 |
| resolveEnvironmentPlugins | node/plugin.ts | 390-412 |
| perEnvironmentPlugin | 同上 | 417-427 |
16.10.6 读完本章能回答的具体问题清单
作为本章掌握度自测:
- Vite 5 的 ssr:boolean 为什么需要被 Environment API 替代?(§16.1.1——布尔不可扩展、共享模块图污染、优化冲突、插件歧义)
- 环境名为什么限制成
[\w$]+?(§16.1.3——兼容目录名和 JS 标识符) - Proxy 配置合并为什么比深拷贝好?(§16.1.4——内存从 O(N × config) 降到 O(N + config))
prop in target和target[prop] !== undefined差在哪?(§16.1.4——前者正确处理显式设为 undefined 的字段)- pluginContainer 用 getter 做保护想解决什么问题?(§16.3.1-bis——早失败、未 init 时访问立刻抛错)
- _pendingRequests 的 abort 函数有什么用?(§16.3.1-ter——可中断的进行中 transform、避免关闭时浪费 CPU)
- init() 为什么要幂等?(§16.3.2-bis——多路径可能触发、避免重复构造 plugin container)
- dev server restart 时方法调用顺序是什么?(§16.3.2-bis——new init → old close → new listen,零 downtime)
- warmupRequest 对哪些错误静默?(§16.3.2-ter——ERR_OUTDATED_OPTIMIZED_DEP 和 ERR_CLOSED_SERVER)
- applyToEnvironment 的 4 种返回值?(§16.7.3——true/false/单 plugin/plugin 数组)
- 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 = true 到 test.only() 方法),你能第一时间识别出这是同一个 pattern——模式识别能力是从”会用一个工具”跃迁到”懂得多个工具”的关键。
16.11 小结
Environment API 是 Vite 6 最重要的架构变革,它将”环境”从一个布尔参数提升为一等公民:
-
类型体系采用四层继承设计(
PartialEnvironment -> BaseEnvironment -> Dev/Build/Scan/UnknownEnvironment),每一层增加一组能力。UnknownEnvironment作为”类型守卫”确保正向模式检查的代码风格。 -
Proxy 配置合并通过 JavaScript Proxy 实现了零拷贝的配置覆盖。环境特定的选项覆盖顶层配置,未覆盖的属性自动回退,既保证了内存效率又维持了配置一致性。
-
DevEnvironment 拥有独立的模块图、插件容器、依赖优化器和热更新通道。模块图隔离确保了不同环境对同一文件的转换互不干扰。
-
BuildEnvironment 和 ScanEnvironment 分别为构建和扫描阶段提供了精简的环境抽象,体现了”按需提供能力”的原则。
-
perEnvironmentPlugin 和 perEnvironmentState 为插件提供了环境感知的适配模式。
WeakMap状态管理确保了内存安全,惰性初始化避免了不必要的开销。 -
ViteBuilder 通过
sharedConfigBuild和sharedPlugins选项在隔离性和性能之间提供了灵活的权衡。
这套 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 标准用法的延伸。