深入 Vite 开发服务器:中间件栈、转换流水线与模块图
前置知识
- ›第 1-2 篇:架构、配置与插件系统
- ›Node.js HTTP 服务器与中间件模式(connect/express)
- ›Rollup 插件钩子概念(resolveId、load、transform)
深入 Vite 开发服务器:中间件栈、转换流水线与模块图
执行 vite dev 时,启动的开发服务器有一个与众不同之处:它不会打包源代码,而是将每个模块以原生 ESM 的形式单独提供,在浏览器请求时按需对文件进行转换。正是这种非打包的方式,造就了 Vite 开发服务器的高速响应——但与此同时,它也需要一套复杂的基础设施来处理 import 重写、缓存、HMR 失效以及依赖预构建。让我们从服务器创建开始,逐步拆解其中的每个环节。
服务器创建与环境初始化
createServer() 只是 _createServer() 的一层薄封装,后者从第 476 行开始执行。整个创建流程如下:
sequenceDiagram
participant CLI as CLI / User Code
participant CS as _createServer()
participant Config as resolveConfig()
participant HTTP as HTTP Server
participant WS as WebSocket Server
participant Env as DevEnvironment (per-env)
participant MW as Middleware Stack
CLI->>CS: createServer(inlineConfig)
CS->>Config: resolveConfig(inlineConfig, 'serve')
Config-->>CS: ResolvedConfig
CS->>HTTP: resolveHttpServer(middlewares, https)
CS->>WS: createWebSocketServer(httpServer, config)
loop For each environment in config.environments
CS->>Env: createEnvironment(name, config, { ws })
Env->>Env: init({ watcher })
Note over Env: Creates moduleGraph,<br/>pluginContainer,<br/>depsOptimizer
end
CS->>MW: Assemble middleware stack
CS-->>CLI: ViteDevServer
第 562–579 行的环境初始化循环,通过各环境解析配置中的工厂函数来创建对应的 DevEnvironment:
const environment = await environmentOptions.dev.createEnvironment(
name, config, { ws },
)
environments[name] = environment
await environment.init({ watcher, previousInstance })
每个 DevEnvironment 都拥有独立的 EnvironmentModuleGraph、EnvironmentPluginContainer,以及可选的 DepsOptimizer。ViteDevServer 接口通过 server.environments 统一对外暴露所有环境的访问入口。
中间件栈
Vite 使用 connect 作为中间件框架。中间件栈在 _createServer() 的第 920–1030 行完成组装,顺序遵循安全优先原则:
flowchart TD
A["timeMiddleware (DEBUG only)"] --> B
B["rejectInvalidRequestMiddleware"] --> C
C["rejectNoCorsRequestMiddleware"] --> D
D["corsMiddleware"] --> E
E["hostValidationMiddleware"] --> F
F["configureServer pre-hooks"] --> G
G["cachedTransformMiddleware"] --> H
H["proxyMiddleware"] --> I
I["baseMiddleware"] --> J
J["launchEditorMiddleware"] --> K
K["viteHMRPingMiddleware"] --> L
L["servePublicMiddleware"] --> M
M["transformMiddleware ⭐"] --> N
N["serveRawFsMiddleware"] --> O
O["serveStaticMiddleware"] --> P
P["htmlFallbackMiddleware"] --> Q
Q["configureServer post-hooks"] --> R
R["indexHtmlMiddleware"] --> S
S["notFoundMiddleware"] --> T
T["errorMiddleware"]
style M fill:#f96,stroke:#333,stroke-width:2px
安全层负责拒绝格式异常的请求、拦截缺少 CORS 头的跨域请求,并校验 Host 请求头以防范 DNS 重绑定攻击。cachedTransformMiddleware(第 957 行)在任何转换工作开始之前,就基于 ETag 返回 304 响应。
栈中的核心角色是 transformMiddleware,它拦截 JS、CSS 以及带 import 查询参数的请求 URL,经过 transformRequest() 处理后返回结果。它只负责处理 client 环境——SSR 的转换走另一条路径(module runner,将在第 5 篇介绍)。
提示: 插件作者可以通过
configureServer钩子注入中间件。直接在钩子中注册的中间件会在内部中间件之前执行。若要添加后置中间件——即在内部栈之后执行,适合处理兜底路由——可以从钩子中返回一个函数。
插件容器
开发模式下并不会真正执行 Rollup/Rolldown 的打包流程。Vite 通过插件容器模拟 Rollup 的插件执行模型——其实现参考自 WMR,这一点在 pluginContainer.ts 的头部注释中有所说明。
每个 DevEnvironment 在 environment.init() 阶段都会创建独立的 EnvironmentPluginContainer 实例。该容器实现了与 Rolldown 构建时相同的 resolveId、load 和 transform 钩子接口,并提供包含以下内容的 PluginContext:
this.environment— 当前DevEnvironmentthis.resolve()— 插件间的模块解析this.emitFile()— 资源产物输出this.parse()— 基于 Rolldown 解析器的 AST 解析
classDiagram
class EnvironmentPluginContainer {
+resolveId(id, importer, options)
+load(id, options)
+transform(code, id, options)
+buildStart()
+close()
-plugins: Plugin[]
-environment: DevEnvironment
}
class DevEnvironment {
+pluginContainer: EnvironmentPluginContainer
+moduleGraph: EnvironmentModuleGraph
+transformRequest(url): TransformResult
}
DevEnvironment --> EnvironmentPluginContainer
正是这层抽象,使得 Rollup/Rolldown 插件能够在开发模式下正常工作:插件容器以与真实打包流程完全相同的顺序调用相同的钩子,只不过每次只处理一个模块。
transformRequest():开发模式的核心
当转换中间件捕获到 /src/App.tsx 的请求时,会调用 environment.transformRequest(url),进而委托给 transformRequest() 函数:
sequenceDiagram
participant MW as transformMiddleware
participant TR as transformRequest()
participant PC as pluginContainer
participant MG as moduleGraph
participant FS as File System
MW->>TR: transformRequest(url)
TR->>TR: Check pending requests (dedup)
TR->>PC: resolveId(url)
PC-->>TR: resolved id + metadata
TR->>MG: ensureEntryFromUrl(url)
TR->>PC: load(id)
alt Plugin provides code
PC-->>TR: code
else No plugin handles
TR->>FS: fs.readFile(id)
FS-->>TR: code
end
TR->>PC: transform(code, id)
PC-->>TR: transformed code + sourcemap
TR->>TR: Generate ETag
TR->>MG: Update transformResult
TR-->>MW: TransformResult { code, map, etag }
函数首先在第 109–130 行处理请求去重。若同一 URL 的两个请求同时到达,第二个请求会等待第一个请求的结果,而不是重复执行转换。但这里有一个细节:如果模块在第一个请求开始之后、第二个请求到达之前被失效了,那么待完成的结果就已经过期,此时第二个请求会中止第一个,并重新开始转换。
TransformResult 类型包含了响应所需的全部信息:
export interface TransformResult {
code: string
map: SourceMap | { mappings: '' } | null
etag?: string
deps?: string[]
dynamicDeps?: string[]
}
Import 分析:连接原生 ESM 与 Vite
transform 钩子执行完毕后,importAnalysisPlugin(一个仅在开发模式下生效的插件,约 1100 行)会完成重写 import 语句这项关键工作,使其能够在浏览器中正确执行。以下面的源代码为例:
import React from 'react'
import './styles.css'
import { helper } from './utils'
浏览器无法解析 'react' 这样的裸模块标识符,也不理解 CSS import。import 分析插件会将其转换为:
import React from '/node_modules/.vite/deps/react.js?v=abc123'
import './styles.css?import'
import { helper } from '/src/utils.ts?t=1234567890'
这一过程包括:
- 使用
es-module-lexer解析所有 import 语句 - 通过插件容器的
resolveId解析每个模块标识符 - 将裸模块 import 重写为
.vite/deps/下预构建依赖的路径 - 附加时间戳查询参数,在文件变更时破坏缓存
- 为调用了
import.meta.hot.accept()的模块注入 HMR 边界标记 - 处理
import.meta.env和import.meta.glob的替换
flowchart LR
A["import React from 'react'"] -->|resolveId| B["/.vite/deps/react.js?v=abc"]
C["import './styles.css'"] -->|CSS detection| D["./styles.css?import&t=123"]
E["import { x } from './utils'"] -->|timestamp| F["./utils.ts?t=123"]
G["import.meta.hot.accept()"] -->|HMR injection| H["__vite__createHotContext(url)"]
提示: 如果你发现某个模块在热更新时触发了完整的页面刷新,而不是局部热替换,可以去查看 import 分析插件的 HMR 边界检测逻辑。一个没有调用
import.meta.hot.accept()的模块——无论是直接调用还是通过框架的 HMR 处理间接调用——都会导致更新向上传播,直至所有引用它的模块。
模块图数据结构
EnvironmentModuleGraph 用于追踪一个环境内所有模块之间的关系。它维护三个索引以实现快速查找:
urlToModuleMap: Map<string, EnvironmentModuleNode> // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode> // resolved ID → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>> // file → modules
同一个文件可以对应多个模块(例如携带不同查询参数时),这也是 fileToModulesMap 使用 Set 存储的原因。
每个 EnvironmentModuleNode 记录以下信息:
classDiagram
class EnvironmentModuleNode {
+url: string
+id: string | null
+file: string | null
+type: "js" | "css" | "asset"
+importers: Set~EnvironmentModuleNode~
+importedModules: Set~EnvironmentModuleNode~
+acceptedHmrDeps: Set~EnvironmentModuleNode~
+acceptedHmrExports: Set~string~ | null
+isSelfAccepting?: boolean
+transformResult: TransformResult | null
+lastHMRTimestamp: number
+lastInvalidationTimestamp: number
+invalidationState: TransformResult | "HARD_INVALIDATED" | undefined
}
class EnvironmentModuleGraph {
+urlToModuleMap: Map
+idToModuleMap: Map
+fileToModulesMap: Map
+etagToModuleMap: Map
+getModuleByUrl(url)
+getModulesByFile(file)
+invalidateModule(mod, ...)
+onFileChange(file)
}
EnvironmentModuleGraph --> EnvironmentModuleNode : manages
EnvironmentModuleNode --> EnvironmentModuleNode : importers / importedModules
importers 和 importedModules 这两个集合共同构成一张双向图。当文件发生变更时,Vite 通过 fileToModulesMap 找到对应的模块,再沿着 importers 向上遍历,寻找 HMR 边界。acceptedHmrDeps 集合记录了某个模块通过 import.meta.hot.accept(deps, callback) 声明接受更新的那些依赖模块。
invalidationState 字段区分了两种失效类型:软失效(只需更新时间戳,转换结果仍然有效)和硬失效(代码已变更,必须重新转换)。这一优化避免了 HMR 链路中不必要的重复转换。
为了向后兼容,Vite 还在 server.moduleGraph 上提供了一个 ModuleGraph(从 mixedModuleGraph.ts 导入),它将 client 和 SSR 环境的模块图合并展示。访问该属性会触发废弃警告——新代码应直接使用 server.environments.client.moduleGraph。
下一步
至此,我们已经完整追踪了一个请求从浏览器出发、经过中间件栈、进入转换流水线、最终填充模块图的全过程。那么,当你保存一个文件时会发生什么?下一篇文章将深入 HMR 的完整闭环——从 chokidar 检测到文件变更,到模块图遍历、WebSocket 派发,再到客户端重新拉取模块——并进一步探索依赖预构建是如何发现、打包并提供 npm 包的。