Appearance
第2章 架构总览
"Architecture is the thoughtful making of space." -- Louis Kahn
本章要点
- 从一个 HTTP 请求的完整旅程理解 Vite 开发服务器的工作机制
- 掌握 Vite 四大核心子系统:开发服务器、插件系统、模块图、构建引擎
- 深入了解
src/目录的组织结构与各模块职责 - 理解开发模式与构建模式的架构差异及其设计动因
- 认识 Vite 8.0 中引入的 Environment API 对架构的深远影响
2.1 从一万英尺看 Vite
在深入源码细节之前,让我们先建立一个全局视角。Vite 的架构设计围绕一个核心理念展开:开发时利用浏览器原生 ESM 能力实现按需编译,构建时通过 Rolldown 进行全量打包。这一理念贯穿了整个代码库的组织方式。
传统的打包工具如 Webpack 在开发模式下需要先将整个应用打包成一个或多个 bundle,然后才能提供给浏览器。随着项目规模的增长,这个打包过程可能需要数十秒甚至数分钟。Vite 从根本上改变了这个范式——它利用现代浏览器对 ES Module 的原生支持,在开发时完全跳过打包步骤,直接将源文件以 ESM 格式提供给浏览器。当浏览器通过 <script type="module"> 加载入口文件时,它会根据 import 语句逐一请求依赖模块,而 Vite 在收到每个请求时才对对应的模块进行即时转换。这种按需编译的策略使得启动时间几乎与项目规模无关——无论项目有一百个还是一万个模块,启动时间都保持在秒级。
然而,按需编译策略在生产环境并不适用。原因有二:一是浏览器原生 ESM 加载在深层嵌套导入时会产生大量网络请求(即所谓的"瀑布流"问题),导致页面加载缓慢;二是生产环境需要代码压缩、tree-shaking、代码分割等优化手段,这些都需要全局视角的打包器。因此,Vite 在构建模式下使用 Rolldown 进行传统意义上的全量打包。Rolldown 是 Vite 团队开发的 Rust 原生打包器,它兼容 Rollup 的插件接口,同时提供了数倍于 Rollup 的性能。
这种"开发用 ESM、构建用打包器"的双模式架构是理解 Vite 所有设计决策的基础。它解释了为什么 Vite 需要一个插件容器来模拟 Rollup 的行为(使同一套插件在两种模式下都能工作),为什么需要依赖预构建(将 CommonJS 和多文件的 npm 包转换为浏览器可用的 ESM),以及为什么模块图需要精确追踪依赖关系(支持 HMR 的精准更新)。
Vite 8.0 的源码位于 packages/vite/src/ 目录下,由五个顶级模块构成:
packages/vite/src/
node/ -- 服务端核心(开发服务器、构建引擎、插件系统、配置解析)
client/ -- 浏览器端运行时(HMR 客户端、错误覆盖层)
shared/ -- 客户端与服务端共享的工具代码
module-runner/ -- 服务端模块执行器(SSR 模块运行环境)
types/ -- TypeScript 类型定义2.2 node/ 目录:服务端核心全景
node/ 目录是 Vite 最庞大的模块,承载了几乎所有服务端逻辑。理解这个目录的组织方式,对于在源码中快速定位问题和理解功能实现至关重要。Vite 团队选择了按职责划分的目录结构,每个文件或子目录承担一个明确的功能领域。让我们逐一审视其核心文件和子目录:
node/
config.ts -- 配置解析(2704行,最大的单文件)
build.ts -- 构建引擎入口
cli.ts -- 命令行接口
env.ts -- .env 文件加载
plugin.ts -- 插件类型定义与工具
environment.ts -- Environment API 核心
baseEnvironment.ts -- 环境基类
http.ts -- HTTP/HTTPS 服务器创建
logger.ts -- 日志系统
utils.ts -- 通用工具函数
constants.ts -- 常量定义
watch.ts -- 文件监听配置
publicDir.ts -- 静态资源目录处理
server/ -- 开发服务器子系统
plugins/ -- 内置插件集合
optimizer/ -- 依赖预构建优化器
ssr/ -- SSR 相关实现server/ 子目录是开发服务器的核心,其内部结构如下:
server/
index.ts -- 服务器创建与生命周期管理
environment.ts -- DevEnvironment 实现
environments/ -- 特定环境实现(runnableEnvironment, fullBundleEnvironment)
moduleGraph.ts -- 环境级模块图
mixedModuleGraph.ts-- 混合模块图(向后兼容)
pluginContainer.ts -- 插件容器(模拟 Rollup 插件机制)
transformRequest.ts-- 请求转换核心逻辑
hmr.ts -- HMR 热更新处理
ws.ts -- WebSocket 服务器
send.ts -- HTTP 响应发送
warmup.ts -- 模块预热
middlewares/ -- Connect 中间件集合plugins/ 子目录包含 Vite 全部内置插件:
| 文件 | 职责 |
|---|---|
resolve.ts | 模块路径解析(基于 oxc-resolver) |
css.ts | CSS 处理(PostCSS、预处理器、CSS Modules) |
html.ts | HTML 处理与注入 |
asset.ts | 静态资源处理 |
define.ts | 全局变量替换 |
oxc.ts | Oxc 转译(替代 esbuild) |
importAnalysis.ts | 开发时 import 分析与重写 |
importAnalysisBuild.ts | 构建时 import 分析 |
importMetaGlob.ts | import.meta.glob 支持 |
optimizedDeps.ts | 预构建依赖加载 |
worker.ts | Web Worker 支持 |
forwardConsole.ts | 控制台消息转发(AI Agent 环境) |
middlewares/ 子目录包含 Connect 中间件的完整集合:
middlewares/
base.ts -- base 路径处理
error.ts -- 错误处理
hostCheck.ts -- DNS 重绑定防护
htmlFallback.ts -- SPA 路由回退
indexHtml.ts -- index.html 转换
memoryFiles.ts -- 内存文件服务(bundledDev 模式)
notFound.ts -- 404 处理
proxy.ts -- 请求代理
rejectInvalidRequest.ts -- 无效请求拦截
rejectNoCorsRequest.ts -- 跨域请求拦截
static.ts -- 静态文件服务
time.ts -- 请求计时(调试用)
transform.ts -- 模块转换(核心中间件)2.3 一个 HTTP 请求的完整旅程
理解 Vite 架构最好的方式是跟踪一个请求从浏览器发出到响应返回的全过程。与其抽象地讨论模块之间的关系,不如选择一个具体的场景,步步追踪数据的流转。
假设我们有一个 React 项目,用户在浏览器中打开了 http://localhost:5173/。浏览器首先加载 index.html,其中包含 <script type="module" src="/src/main.tsx">。浏览器解析 main.tsx 后发现它导入了 ./App.tsx,于是发起 GET /src/App.tsx 请求。让我们完整追踪这个请求从离开浏览器到响应返回的全过程。
2.3.1 请求进入:中间件管道
当浏览器发起 GET /src/App.tsx 请求时,它首先抵达 Vite 的 HTTP 服务器。Vite 使用 Node.js 原生的 http.createServer 创建 HTTP 服务器,但请求处理逻辑由 Connect 框架的中间件管道负责。Connect 是一个极其轻量的 HTTP 中间件框架,它不带路由功能,只提供中间件的串联执行能力,这正是 Vite 所需要的——Vite 的路由逻辑分散在各个专用中间件中。
中间件管道在 _createServer 函数中装配,由多层中间件按精心设计的顺序组成:
上述中间件管道的装配代码位于 src/node/server/index.ts 的 _createServer 函数中(约第 920-1030 行):
typescript
// 文件: packages/vite/src/node/server/index.ts (简化示意)
// Pre applied internal middlewares
if (process.env.DEBUG) {
middlewares.use(timeMiddleware(root))
}
middlewares.use(rejectInvalidRequestMiddleware())
middlewares.use(rejectNoCorsRequestMiddleware())
const { cors } = serverConfig
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
const { allowedHosts } = serverConfig
if (allowedHosts !== true && !serverConfig.https) {
middlewares.use(hostValidationMiddleware(allowedHosts, false))
}
// configureServer hooks (pre)
const postHooks: ((() => void) | void)[] = []
for (const hook of config.getSortedPluginHooks('configureServer')) {
postHooks.push(await hook.call(configureServerContext, reflexServer))
}
// Internal middlewares
if (!config.experimental.bundledDev) {
middlewares.use(cachedTransformMiddleware(server))
}
if (proxy) {
middlewares.use(proxyMiddleware(middlewareServer, proxy, config))
}
if (config.base !== '/') {
middlewares.use(baseMiddleware(config.rawBase, !!middlewareMode))
}
// 静态文件与转换
if (publicDir) {
middlewares.use(servePublicMiddleware(server, publicFiles))
}
middlewares.use(transformMiddleware(server))
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(server))
// HTML 处理
if (config.appType === 'spa' || config.appType === 'mpa') {
middlewares.use(htmlFallbackMiddleware(root, config.appType === 'spa', ...))
}
// configureServer hooks (post)
postHooks.forEach((fn) => fn && fn())
middlewares.use(indexHtmlMiddleware(root, server))
middlewares.use(notFoundMiddleware())
middlewares.use(errorMiddleware(server, !!middlewareMode))这段代码揭示了几个重要的设计决策。首先,安全相关的中间件(拒绝无效请求、CORS、DNS 重绑定检查)始终位于管道最前端,构建了第一道防线。这是因为恶意请求可能尝试利用开发服务器的能力读取文件系统,因此必须在任何业务逻辑之前进行拦截。
其次,configureServer 钩子被分为前置和后置两部分。插件的 configureServer 钩子直接执行的代码注册前置中间件,而钩子返回的函数会被收集为后置中间件,在 HTML 处理之前执行。这种设计给了用户极大的灵活性:前置中间件可以拦截特定请求(如 API mock),后置中间件可以在所有内置处理之后提供兜底逻辑。
第三,cachedTransformMiddleware 位于 transformMiddleware 之前,这意味着重复请求可以在极早的阶段被 304 响应短路,完全跳过后续的代理、静态文件服务和模块转换逻辑。
第四,servePublicMiddleware 位于 transformMiddleware 之前,确保 public/ 目录中的文件不经过任何转换直接提供给浏览器。这不仅是性能优化,也是功能正确性的保证——public/ 中的文件应当原样提供。
第五,bundledDev 实验模式下的路径完全不同:cachedTransformMiddleware 和 transformMiddleware 被替换为 memoryFilesMiddleware,后者直接从 Rolldown 的内存打包产物中提供文件。这体现了 Vite 架构的灵活性——核心中间件可以根据运行模式整体替换。
2.3.2 缓存中间件:304 快速路径
在请求到达核心转换逻辑之前,cachedTransformMiddleware 会尝试进行 ETag 匹配。这段代码位于 src/node/server/middlewares/transform.ts:
typescript
// 文件: packages/vite/src/node/server/middlewares/transform.ts
export function cachedTransformMiddleware(
server: ViteDevServer,
): Connect.NextHandleFunction {
return function viteCachedTransformMiddleware(req, res, next) {
const environment = server.environments.client
// HTML 请求不走缓存
if (isDocumentFetchDest(req)) {
res.appendHeader('Vary', 'Sec-Fetch-Dest')
return next()
}
// 检查是否可以返回 304
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch) {
const moduleByEtag = environment.moduleGraph.getModuleByEtag(ifNoneMatch)
if (
moduleByEtag?.transformResult?.etag === ifNoneMatch &&
moduleByEtag.url === req.url
) {
const maybeMixedEtag = isCSSRequest(req.url!)
if (!maybeMixedEtag) {
debugCache?.(`[304] ${prettifyUrl(req.url!, server.config.root)}`)
res.statusCode = 304
return res.end()
}
}
}
next()
}
}这个中间件利用模块图的 etagToModuleMap 索引实现 O(1) 时间复杂度的 ETag 查找,使得重复请求几乎零成本地返回 304。值得注意的是,CSS 请求会跳过 ETag 快速路径,这是因为同一个 CSS 文件可能以不同方式被引用(直接请求 vs 模块导入),两种方式可能产生不同的 ETag。
2.3.3 transformMiddleware:请求路由的关键枢纽
当请求到达 transformMiddleware 时,它执行关键的路由判断逻辑:
typescript
// 文件: packages/vite/src/node/server/middlewares/transform.ts
export function transformMiddleware(
server: ViteDevServer,
): Connect.NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
const environment = server.environments.client
if (
(req.method !== 'GET' && req.method !== 'HEAD') ||
knownIgnoreList.has(req.url!) ||
isDocumentFetchDest(req) // 排除 HTML 文档请求
) {
return next()
}
let url: string
try {
url = decodeURI(removeTimestampQuery(req.url!)).replace(
NULL_BYTE_PLACEHOLDER, '\0',
)
} catch (e) {
if (e instanceof URIError) {
server.config.logger.warn(
`Malformed URI sequence in request URL: ${removeTimestampQuery(req.url!)}`,
)
return next()
}
return next(e)
}
// ... 后续处理 sourcemap、JS/CSS 请求
}
}该中间件会判断请求是否为 JS 请求(isJSRequest)、CSS 请求(isCSSRequest)或 import 请求(isImportRequest),只有满足条件的请求才会进入核心转换流程。对于我们的 /src/App.tsx 请求,.tsx 文件显然属于 JS 类型,因此会被路由到 environment.transformRequest(url) 方法。
2.3.4 transformRequest:请求转换核心
这是整个开发服务器最核心的逻辑,位于 src/node/server/transformRequest.ts。请求的处理遵循一个三阶段管道:resolve -> load -> transform。
让我们详细查看 transformRequest 函数的实现:
typescript
// 文件: packages/vite/src/node/server/transformRequest.ts
export function transformRequest(
environment: DevEnvironment,
url: string,
options: TransformOptionsInternal = {},
): Promise<TransformResult | null> {
if (environment._closing && environment.config.dev.recoverable)
throwClosedServerError()
// 保存当前时间戳,用于与 lastInvalidationTimestamp 比较
// 确保失效化后的模块不会被旧的转换结果覆盖
const timestamp = monotonicDateNow()
url = removeTimestampQuery(url)
// 请求去重:如果同一 URL 已有正在处理的请求
const pending = environment._pendingRequests.get(url)
if (pending) {
return environment.moduleGraph.getModuleByUrl(url).then((module) => {
if (!module || pending.timestamp > module.lastInvalidationTimestamp) {
return pending.request // 复用已有请求的结果
} else {
// 模块在请求处理过程中被失效化了
// 中止旧请求,发起新的转换
pending.abort()
return transformRequest(environment, url, options)
}
})
}
const request = doTransform(environment, url, options, timestamp)
// 缓存正在进行的请求,防止并发重复处理
let cleared = false
const clearCache = () => {
if (!cleared) {
environment._pendingRequests.delete(url)
cleared = true
}
}
environment._pendingRequests.set(url, {
request,
timestamp,
abort: clearCache,
})
return request.finally(clearCache)
}源码注释中清晰地列出了模块可能被失效化的四种场景:
- 预构建发现新依赖导致的全页重载
- 配置变更后的全页重载
- 模块对应的文件发生变化
- 虚拟模块的手动失效化
doTransform 函数执行实际的三阶段处理:
typescript
// 文件: packages/vite/src/node/server/transformRequest.ts
async function doTransform(
environment: DevEnvironment,
url: string,
options: TransformOptionsInternal,
timestamp: number,
) {
const { pluginContainer } = environment
// 尝试从模块图缓存中获取结果
let module = await environment.moduleGraph.getModuleByUrl(url)
if (module) {
const cached = await getCachedTransformResult(
environment, url, module, timestamp,
)
if (cached) return cached
}
// 阶段一:resolve -- 将 URL 解析为文件系统路径
const resolved = module
? undefined
: ((await pluginContainer.resolveId(url, undefined)) ?? undefined)
const id = module?.id ?? resolved?.id ?? url
// 尝试从 id 层面查找缓存
module ??= environment.moduleGraph.getModuleById(id)
if (module) {
await environment.moduleGraph._ensureEntryFromUrl(url, undefined, resolved)
const cached = await getCachedTransformResult(
environment, url, module, timestamp,
)
if (cached) return cached
}
// 阶段二和三在 loadAndTransform 中完成
const result = loadAndTransform(
environment, id, url, options, timestamp, module, resolved,
)
return result
}loadAndTransform 中完成 load 和 transform 两个阶段:
typescript
// 文件: packages/vite/src/node/server/transformRequest.ts (简化)
async function loadAndTransform(
environment: DevEnvironment, id: string, url: string,
options: TransformOptionsInternal, timestamp: number,
mod?: EnvironmentModuleNode, resolved?: PartialResolvedId,
) {
const { config, pluginContainer, logger } = environment
const moduleGraph = environment.moduleGraph
// 阶段二:load -- 加载模块内容
const loadResult = await pluginContainer.load(id)
if (loadResult == null) {
// 插件未处理时,回退到文件系统读取
const file = cleanUrl(id)
if (
environment.config.consumer === 'server' ||
isFileLoadingAllowed(environment.getTopLevelConfig(), slash(file))
) {
code = await fsp.readFile(file, 'utf-8')
}
} else {
if (isObject(loadResult)) {
code = loadResult.code
map = loadResult.map
moduleType = loadResult.moduleType
} else {
code = loadResult
}
}
// 确保模块在模块图中注册
mod = await moduleGraph._ensureEntryFromUrl(url, undefined, resolved)
// 阶段三:transform -- 代码转换
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
})
// 生成 ETag 用于缓存
const etag = getEtag(transformResult.code, { weak: true })
// 存储转换结果到模块节点
mod.transformResult = { code, map, etag, deps, dynamicDeps }
return mod.transformResult
}2.3.5 响应返回
转换完成后,transformMiddleware 通过 send 函数将结果返回给浏览器。响应头中设置 Cache-Control: no-cache,意味着浏览器每次都会发起条件请求(携带 If-None-Match 头),但如果 ETag 匹配,cachedTransformMiddleware 会返回 304 状态码,避免重复转换。
这里有一个值得深思的设计取舍:为什么 Vite 不使用强缓存(如 Cache-Control: max-age=31536000)?答案在于开发模式的核心需求是即时反馈。如果使用强缓存,浏览器在缓存有效期内不会发起任何请求,即便文件已经修改,用户也无法通过刷新页面看到最新代码。而 no-cache 配合 ETag 的协商缓存策略完美平衡了性能和正确性:未修改的模块只需要一个极轻量的 304 响应(无需传输响应体),而修改的模块则立即得到最新版本。
值得注意的是,预构建的依赖使用了完全不同的缓存策略。node_modules/.vite/deps/ 中的预构建产物通过带版本哈希的 URL(如 react.js?v=abc123)配合强缓存头提供,因为依赖版本变化时 URL 也会变化,不存在缓存失效问题。这种对不同类型资源采用不同缓存策略的设计,体现了 Vite 对 HTTP 缓存机制的深度理解和精细运用。
2.4 四大核心子系统
2.4.1 开发服务器
开发服务器是 Vite 在开发模式下的核心引擎。其设计哲学是:不预先打包,按需编译。当浏览器请求一个模块时,Vite 才即时对其进行转换。这意味着服务器启动时,除了配置解析和依赖预构建外,几乎不需要任何前置工作。真正的代码处理发生在浏览器发起请求的那一刻。
ViteDevServer 本质上是一个围绕 Node.js HTTP 服务器的增强层,它将 HTTP 服务、WebSocket 通信、文件监听、模块处理等能力统一协调。ViteDevServer 接口(定义在 src/node/server/index.ts 第 299 行)是对外暴露的核心 API:
typescript
// 文件: packages/vite/src/node/server/index.ts
export interface ViteDevServer {
config: ResolvedConfig // 解析后的配置
middlewares: Connect.Server // Connect 中间件管道
httpServer: HttpServer | null // HTTP 服务器实例
watcher: FSWatcher // Chokidar 文件监听器
ws: WebSocketServer // WebSocket 服务器(HMR 通信)
environments: Record<string, DevEnvironment> // 执行环境实例
moduleGraph: ModuleGraph // 模块图(向后兼容层)
transformRequest(url: string): Promise<TransformResult | null>
warmupRequest(url: string): Promise<void>
transformIndexHtml(url: string, html: string): Promise<string>
listen(port?: number): Promise<ViteDevServer>
close(): Promise<void>
restart(): Promise<void>
waitForRequestsIdle(ignoredId?: string): Promise<void>
}Vite 8.0 引入的 Environment API 是一个重要的架构变革。在此之前,开发服务器只有一个全局的模块图和插件容器。如今,每个环境(client、ssr、或用户自定义环境)都拥有独立的 DevEnvironment 实例,包含独立的插件容器和模块图。这一变化的实现可以在服务器创建代码中看到:
typescript
// 文件: packages/vite/src/node/server/index.ts
const environments: Record<string, DevEnvironment> = {}
await Promise.all(
Object.entries(config.environments).map(
async ([name, environmentOptions]) => {
const environment = await environmentOptions.dev.createEnvironment(
name, config, { ws },
)
environments[name] = environment
const previousInstance = options.previousEnvironments?.[environment.name]
await environment.init({ watcher, previousInstance })
},
),
)默认的客户端环境创建函数会根据是否启用了 bundledDev 实验性特性,选择不同的 DevEnvironment 实现:
typescript
// 文件: packages/vite/src/node/config.ts
function defaultCreateClientDevEnvironment(
name: string, config: ResolvedConfig,
context: CreateDevEnvironmentContext,
) {
if (config.experimental.bundledDev) {
return new FullBundleDevEnvironment(name, config, {
hot: true, transport: context.ws,
})
}
return new DevEnvironment(name, config, {
hot: true, transport: context.ws,
})
}文件监听也体现了环境隔离的设计——当文件变化时,事件会广播到所有环境:
typescript
// 文件: packages/vite/src/node/server/index.ts
watcher.on('change', async (file) => {
file = normalizePath(file)
reloadOnTsconfigChange(server, file)
// 通知所有环境的插件容器
await Promise.all(
Object.values(server.environments).map((environment) =>
environment.pluginContainer.watchChange(file, { event: 'update' }),
),
)
// 使各环境的模块图缓存失效
for (const environment of Object.values(server.environments)) {
environment.moduleGraph.onFileChange(file)
}
await onHMRUpdate('update', file)
})2.4.2 插件系统
Vite 的插件系统建立在 Rollup 插件接口之上,但进行了重要扩展。选择兼容 Rollup 插件接口是一个深思熟虑的决策——Rollup 已经拥有庞大的插件生态,兼容意味着用户可以直接复用这些插件(如 @rollup/plugin-alias、@rollup/plugin-commonjs 等),无需等待 Vite 专用版本。同时,Vite 扩展了插件接口,添加了 configureServer、transformIndexHtml、handleHotUpdate 等 Vite 特有的钩子。
插件的组织和排序由 resolvePlugins 函数完成(src/node/plugins/index.ts)。在开发模式下,插件容器(PluginContainer,定义在 src/node/server/pluginContainer.ts)模拟了 Rollup 的运行时环境,使插件的 resolveId、load、transform 等钩子能够在没有真正 Rollup 实例的情况下正常工作。这个实现源自 Preact 团队的 WMR 项目,其文件头部的 MIT 许可证声明记录了这一历史渊源。
插件被组织成一个精心排序的管道,排序的目的是确保每个处理阶段在正确的时机执行:
该排序逻辑的核心源码:
typescript
// 文件: packages/vite/src/node/plugins/index.ts
export async function resolvePlugins(
config: ResolvedConfig,
prePlugins: Plugin[],
normalPlugins: Plugin[],
postPlugins: Plugin[],
): Promise<Plugin[]> {
const isBuild = config.command === 'build'
const isBundled = config.isBundled
const isWorker = config.isWorker
const buildPlugins = isBundled
? await (await import('../build')).resolveBuildPlugins(config)
: { pre: [], post: [] }
return [
!isBundled ? optimizedDepsPlugin() : null,
!isWorker ? watchPackageDataPlugin(config.packageCache) : null,
!isBundled ? preAliasPlugin(config) : null,
// 选择原生或 JS 实现的 alias 插件
isBundled && !config.resolve.alias.some((v) => v.customResolver)
? nativeAliasPlugin({ entries: config.resolve.alias.map(/*...*/) })
: aliasPlugin({ entries: config.resolve.alias }),
...prePlugins,
...oxcResolvePlugin({ root: config.root, /* ... */ }),
htmlInlineProxyPlugin(config),
cssPlugin(config),
config.oxc !== false ? oxcPlugin(config) : null,
nativeJsonPlugin({ ...config.json, minify: isBuild }),
wasmHelperPlugin(),
webWorkerPlugin(config),
assetPlugin(config),
...normalPlugins,
nativeWasmFallbackPlugin(),
definePlugin(config),
cssPostPlugin(config),
isBundled && buildHtmlPlugin(config),
dynamicImportVarsPlugin(config),
importGlobPlugin(config),
...postPlugins,
...buildPlugins.post,
// 仅开发模式的插件放在最后
...(isBundled
? []
: [
clientInjectionsPlugin(config),
cssAnalysisPlugin(config),
importAnalysisPlugin(config),
]),
].filter(Boolean) as Plugin[]
}需要特别注意 importAnalysisPlugin 插件——它是开发模式独有的,位于管道末尾,负责将源码中的 bare import(如 import React from 'react')重写为浏览器可识别的路径(如 /node_modules/.vite/deps/react.js?v=abc123),同时注入 HMR 客户端代码和 import.meta.hot API。它之所以位于管道末尾,是因为它需要在所有其他转换完成后才能正确分析 import 语句——如果在 JSX 转译之前运行,它可能会错过嵌套在 JSX 表达式中的动态导入。
另一个值得注意的设计决策是 nativeAliasPlugin 的条件使用:当构建模式下所有 alias 都没有自定义解析器时,Vite 会使用 Rolldown 提供的原生 alias 插件,这比 JavaScript 实现的 @rollup/plugin-alias 性能更好,因为路径替换操作可以在 Rust 层完成而无需跨越 JavaScript 边界。
还需要关注条件编译的设计:许多插件通过检查 config.isBundled 标志来决定是否激活。例如 optimizedDepsPlugin 仅在非打包模式(即标准开发模式)下加载,因为依赖预构建是开发模式特有的优化。同样,preAliasPlugin 在打包模式下不需要,因为 Rolldown 本身就能处理路径别名。而在管道末尾,clientInjectionsPlugin、cssAnalysisPlugin 和 importAnalysisPlugin 三个仅在开发模式下存在的插件,它们只有在 isBundled 为 false 时才会被包含。这种条件加载机制使得同一个 resolvePlugins 函数能够为开发和构建两种截然不同的模式生成适当的插件管道。
2.4.3 模块图
模块图是 Vite 维护模块依赖关系的核心数据结构,也是 HMR 得以高效工作的基础。在传统打包工具中,模块依赖关系在打包过程中被计算并嵌入到 bundle 中。但在 Vite 的按需编译模式下,没有前置的打包步骤,依赖关系是随着请求的到来逐步发现和记录的。模块图就是存储这些动态发现的依赖关系的数据结构。
当一个模块首次被请求并经过 importAnalysisPlugin 转换后,该插件会解析出模块中的所有 import 语句,并将这些导入关系记录到模块图中。这样,当某个文件发生变化时,Vite 可以通过模块图快速找到所有直接和间接依赖该文件的模块,从而确定 HMR 更新的边界。
每个 DevEnvironment 拥有一个独立的 EnvironmentModuleGraph(定义在 src/node/server/moduleGraph.ts)。这种每环境独立模块图的设计确保了客户端和服务端的模块依赖关系不会互相干扰——同一个文件在客户端可能被当作 React 组件处理,在服务端可能被当作 Node.js 模块处理,两者的依赖链完全不同。
EnvironmentModuleNode 维护了丰富的元数据,其构造函数展示了核心字段:
typescript
// 文件: packages/vite/src/node/server/moduleGraph.ts
export class EnvironmentModuleNode {
environment: string
url: string // 公开 URL 路径,以 / 开头
id: string | null = null // 解析后的文件系统路径 + 查询参数
file: string | null = null // 纯文件系统路径
type: 'js' | 'css' | 'asset' // 模块类型
importers: Set<EnvironmentModuleNode> = new Set() // 引用此模块的模块
importedModules: Set<EnvironmentModuleNode> = new Set() // 此模块引用的模块
acceptedHmrDeps: Set<EnvironmentModuleNode> = new Set() // HMR 接受的依赖
transformResult: TransformResult | null = null // 缓存的转换结果
lastInvalidationTimestamp = 0 // 最后失效化的时间戳
invalidationState: TransformResult | 'HARD_INVALIDATED' | undefined
constructor(url: string, environment: string, setIsSelfAccepting = true) {
this.environment = environment
this.url = url
this.type = isDirectCSSRequest(url) ? 'css' : 'js'
if (setIsSelfAccepting) {
this.isSelfAccepting = false
}
}
}模块图维护了四个核心索引:
- urlToModuleMap:从请求 URL 到模块节点的映射
- idToModuleMap:从解析后的文件系统 ID 到模块节点的映射
- fileToModulesMap:从文件路径到模块节点集合的映射(一个文件可能产生多个模块,例如 CSS Modules 同时产生 CSS 和 JS 模块)
- etagToModuleMap:从 ETag 到模块节点的映射,用于 304 缓存的快速查找
当文件发生变化时,watcher 触发 onFileChange,模块图通过 invalidateModule 使对应模块的缓存失效。随后 HMR 系统沿着 importers 链向上传播,确定需要更新的边界模块。
invalidationState 字段区分了"软失效"和"硬失效"两种状态。软失效仅更新导入时间戳(例如 HMR 链中的中间模块),此时保留旧的 transformResult,下次请求时只替换时间戳而无需重新转换。硬失效则完全清除缓存,要求完整的重新转换。
2.4.4 构建引擎
构建引擎是 Vite 在生产模式下的核心。与开发模式的按需编译不同,构建模式需要对整个应用进行全量分析和打包。这包括入口发现、依赖解析、代码转换、tree-shaking(死代码消除)、代码分割、资源处理、代码压缩等一系列优化步骤。
构建模式的入口在 src/node/build.ts。Vite 8.0 使用 Rolldown 作为默认打包工具,取代了之前的 Rollup。Rolldown 是用 Rust 编写的打包器,在保持 Rollup 插件兼容性的同时,提供了显著更快的打包速度。这一迁移对于大型项目的构建时间有着立竿见影的改善。需要强调的是,Rolldown 不仅用于生产构建,还用于依赖预构建和配置文件加载——Vite 正在逐步统一其底层打包基础设施。
typescript
// 文件: packages/vite/src/node/build.ts
import {
type RolldownBuild,
type RolldownOptions,
type RolldownOutput,
type RolldownWatcher,
rolldown,
} from 'rolldown'
import { viteLoadFallbackPlugin as nativeLoadFallbackPlugin } from 'rolldown/experimental'
import { esmExternalRequirePlugin } from 'rolldown/plugins'构建引擎的核心也利用了同一套插件系统——resolvePlugins 返回的插件数组同时服务于开发和构建两种模式,只是部分插件通过 config.command 或 config.isBundled 标志进行条件加载。构建模式独有的插件通过 resolveBuildPlugins 函数提供,包括 buildReporterPlugin(构建进度报告)、buildEsbuildPlugin(兼容性转译)、terserPlugin(代码压缩)等。
2.5 开发模式 vs 构建模式:架构差异
开发模式和构建模式在架构上存在根本性差异,理解这些差异对于深入掌握 Vite 至关重要。许多困扰 Vite 用户的问题(如"开发环境正常但构建后出错")都源于对这两种模式差异的认识不足。
从底层机制看,开发模式是一个请求驱动的即时编译系统:浏览器发出请求,Vite 收到请求后对单个文件进行转换并返回。整个过程没有全局的依赖分析,每个模块独立处理。这意味着开发模式下不存在 tree-shaking、代码分割或跨模块优化——这些都是全局分析的产物。
构建模式则是一个经典的打包流程:Rolldown 从入口文件开始,递归解析所有依赖,构建完整的模块依赖图,然后执行全局优化(tree-shaking、代码分割),最终输出优化后的静态文件。这个过程需要一次性处理所有模块,因此在大型项目上耗时更长,但产出的文件经过了全面优化。
核心差异总结如下:
| 维度 | 开发模式 | 构建模式 |
|---|---|---|
| 模块处理 | 按需转换单个模块 | Rolldown 全量打包 |
| 代码分割 | 不分割,依赖浏览器原生 ESM | 自动代码分割 |
| 依赖处理 | 预构建 + 强缓存 | 直接打包 |
| CSS 处理 | 注入 <style> 标签 | 提取为独立文件 |
| 资源处理 | 保持原始路径 | 加 hash 指纹 |
| HMR | 支持(WebSocket 通信) | 不支持(完整重建) |
| 插件差异 | importAnalysis + cssAnalysis | importAnalysisBuild + manifest |
| 底层引擎 | PluginContainer(模拟 Rollup) | Rolldown(原生打包) |
| isBundled | false(默认)/ true(bundledDev) | true |
| target | 不转译(面向现代浏览器) | 转译到 baseline-widely-available |
注意 config.isBundled 这个标志的双重含义。它在构建模式下始终为 true,但在开发模式下,如果启用了实验性的 experimental.bundledDev,它也会变为 true。在 bundledDev 模式下,开发服务器使用 FullBundleDevEnvironment 替代普通的 DevEnvironment,通过 Rolldown 进行全量打包而非按需转换。这时,开发和构建的架构差异大幅缩小——中间件管道中 transformMiddleware 被 memoryFilesMiddleware 替代,直接从内存中的打包产物提供服务。
2.6 核心设计模式概述
2.6.1 中间件管道模式
Vite 采用 Connect 框架的中间件管道模式处理 HTTP 请求。每个中间件是一个 (req, res, next) => void 函数,通过调用 next() 将请求传递给下一个中间件。这种模式的优势在于:
- 关注点分离:每个中间件只负责一个职责(安全校验、缓存检查、路径处理、模块转换、静态文件服务等)
- 可组合性:用户可以通过
configureServer钩子在管道中插入自定义中间件 - 短路优化:缓存中间件可以提前返回 304,避免不必要的转换;静态文件中间件也可以直接返回,不经过转换
2.6.2 插件容器模式
PluginContainer(src/node/server/pluginContainer.ts)模拟了 Rollup 的插件执行环境,使同一套插件接口在开发和构建模式下都能工作。这是一个经典的适配器模式应用——在开发模式下,没有真正的 Rollup 实例,但插件容器提供了等价的 resolveId、load、transform 等钩子调用能力。其设计思想源自 Preact 团队的 WMR 项目。
2.6.3 延迟初始化模式
Vite 大量使用延迟初始化来优化启动性能。例如:
- SSR 环境的优化器在首次调用
ssrLoadModule时才初始化 - 依赖预构建采用"先启动后发现"的策略,不阻塞服务器启动
- 模块转换在实际请求到来时才执行
- buildStart 钩子仅在服务器初始化时调用一次(客户端环境),其他环境在首次请求时延迟调用
2.6.4 请求去重模式
transformRequest 中实现了精巧的请求去重机制。当多个浏览器标签页或并发请求同一模块时,Vite 通过 _pendingRequests Map 确保同一模块同时只有一个转换操作在进行。这不仅避免了重复计算,还通过时间戳比较确保了失效化的正确性——如果一个模块在处理过程中被失效化了,旧的请求会被中止,新的请求会触发全新的转换。
2.6.5 代理与向后兼容模式
Vite 8.0 大量使用 JavaScript Proxy 来实现向后兼容。最典型的例子是 ViteDevServer 实例的代理包装:
typescript
// 文件: packages/vite/src/node/server/index.ts
const reflexServer = new Proxy(server, {
get: (_, property: keyof ViteDevServer) => {
return server[property]
},
set: (_, property: keyof ViteDevServer, value: never) => {
server[property] = value
return true
},
})这个代理确保了在服务器重启后(server 变量被重新赋值),用户持有的引用仍然指向新的实例。同样,server.moduleGraph、server.pluginContainer、server.hot 等属性通过 getter 包装了弃用警告(warnFutureDeprecation),引导用户迁移到新的 Environment API。
2.6.6 环境隔离模式
Vite 8.0 的 Environment API 实现了真正的环境隔离。每个 DevEnvironment 是一个自包含的处理单元:
这种隔离确保了:
- 客户端和服务端的模块解析策略可以不同(如
conditions、mainFields) - 各环境的模块图互不干扰
- 插件可以针对不同环境有不同的行为(通过
environment.name判断) - 每个环境可以有独立的依赖优化配置
2.7 client/ 目录:浏览器端运行时
client/ 目录虽然只有三个文件,但它们构成了 Vite 开发体验中浏览器侧的关键基础设施。这些代码运行在浏览器中,与服务端形成了一个完整的双向通信系统,使得代码变更能够在毫秒级别反映到用户界面上。让我们详细了解每个文件的职责:
client.ts:HMR 客户端,建立 WebSocket 连接,处理模块热更新。它监听服务端通过 WebSocket 发送的更新消息,执行模块的动态import()来加载新版本代码,并调用模块注册的import.meta.hot.accept回调overlay.ts:错误覆盖层组件,使用 Shadow DOM 实现,在编译错误或运行时错误时显示全屏错误信息,包含文件名、行号、错误堆栈等详细信息env.ts:在浏览器端提供import.meta.env对象的运行时值
HMR 客户端的代码会被 clientInjectionsPlugin(位于插件管道末尾的开发专属插件)注入到每个开发模式的 HTML 页面中,路径为 /@vite/client。当浏览器加载这个脚本时,它会建立一个到 Vite 开发服务器的 WebSocket 长连接。此后,每当服务端检测到文件变更并计算出 HMR 更新方案,它就会通过这个连接推送更新指令(如"模块 X 已更新,请重新加载"),客户端收到指令后执行对应的热更新操作。这种双向实时通信机制是 Vite 实现亚秒级热更新体验的关键基础设施。
2.8 shared/ 目录:共享基础设施
shared/ 目录包含客户端和服务端共享的代码:
utils.ts:路径处理(cleanUrl、slash、unwrapId)、URL 查询参数处理(withTrailingSlash)等基础工具constants.ts:如NULL_BYTE_PLACEHOLDER(用于编码空字节)、ERR_OUTDATED_OPTIMIZED_DEP(依赖版本过期错误码)等共享常量hmr.ts:HMR 消息协议定义,确保服务端发送和客户端接收使用一致的消息格式forwardConsole.ts:控制台转发相关的共享类型,支持在 AI Agent 等无浏览器环境中将客户端console输出转发到服务端
2.9 module-runner/ 目录:服务端模块执行器
module-runner/ 是 Vite 8.0 引入的新模块,代表了 Vite 在服务端渲染(SSR)架构上的重要演进。在传统的 SSR 方案中,服务端需要执行前端代码来生成 HTML。这带来了一系列挑战:浏览器 API(如 window、document)不存在于 Node.js 中,模块的加载方式(ESM vs CommonJS)需要特殊处理,热更新需要一套独立的机制。module-runner/ 正是为了系统性地解决这些问题而设计的。
Module Runner 为 SSR 和非浏览器环境提供了一个沙盒化的模块执行环境,具体包含:
runner.ts:ModuleRunner类,管理模块的加载和执行,维护模块实例缓存esmEvaluator.ts:ESM 模块的运行时评估器,使用new AsyncFunction()在隔离的模块作用域中执行代码hmrHandler.ts:服务端 HMR 处理器,使模块运行器能够响应热更新importMetaResolver.ts:import.meta.resolve的自定义实现evaluatedModules.ts:已执行模块的缓存管理,支持模块的失效化和重新加载
Module Runner 的引入替代了之前全局的 ssrLoadModule 方法。与旧方案相比,Module Runner 是环境感知的——每个 RunnableDevEnvironment 拥有独立的 Module Runner 实例,避免了不同环境间模块状态的污染。这种隔离性对于支持多环境 SSR(例如同时为浏览器和边缘计算环境渲染页面)至关重要。旧的 ssrLoadModule 方法虽然仍然可用,但已被标记为弃用,未来将被移除。
2.10 请求转换过程中的插件协作
为了更深入地理解架构中各子系统的协作方式,让我们详细分析 /src/App.tsx 在转换过程中经历的插件处理链。
当 pluginContainer.transform(code, id) 被调用时,代码会依次经过多个插件的 transform 钩子。对于一个典型的 .tsx 文件,处理链大致如下:
oxcPlugin:使用 Oxc 转译器处理 JSX 语法和 TypeScript 类型标注。JSX 表达式(如
<App />) 被转换为React.createElement或jsx()函数调用,TypeScript 类型被完全剥离。这是一个纯语法转换,不涉及模块解析。definePlugin:扫描代码中的全局变量引用(如
process.env.NODE_ENV、import.meta.env.VITE_API_URL),将其替换为配置中定义的常量值。这使得构建时的 tree-shaking 能够消除if (process.env.NODE_ENV === 'production')分支中的开发专用代码。importAnalysisPlugin(仅开发模式):这是开发模式下最关键的转换步骤。它使用
es-module-lexer快速解析模块中的所有import和export语句,然后执行以下操作:- 将 bare import(如
import React from 'react')重写为预构建依赖的路径(如import React from '/node_modules/.vite/deps/react.js?v=abc123') - 为每个相对导入添加时间戳查询参数(如
import './style.css'变为import './style.css?t=1234567890'),确保 HMR 更新后浏览器不使用缓存 - 注入
import.meta.hotAPI 的实现代码,使模块能够接收热更新 - 将导入关系注册到模块图中,建立
importedModules和importers双向链接
- 将 bare import(如
经过这条处理链后,原始的 TypeScript + JSX 代码已经变成了浏览器可以直接执行的标准 JavaScript,其中的模块引用也全部指向了开发服务器能够处理的 URL。
2.11 数据流总览
让我们用一张图总结 Vite 开发模式下的完整数据流,从请求发起到 HMR 更新的全生命周期:
这张图展示了两条主要数据流:
- 请求处理流(编号 1-3):浏览器请求 -> 中间件管道 -> 插件容器(resolve/load/transform)-> 模块图缓存 -> 响应返回
- HMR 更新流(编号 4-6):文件变更 -> 模块图失效 -> WebSocket 推送 -> 浏览器动态加载
2.12 本章小结
本章从全局视角审视了 Vite 8.0 的架构设计。通过跟踪一个 HTTP 请求的完整旅程,我们理解了开发服务器的工作机制——从 Connect 中间件管道接收请求,到 transformRequest 的三阶段处理(resolve -> load -> transform),再到响应返回和缓存策略。
我们详细探索了 Vite 的四大核心子系统:
- 开发服务器:基于 Connect 的中间件架构,结合 Environment API 实现多环境隔离。每个
DevEnvironment拥有独立的插件容器、模块图和依赖优化器。 - 插件系统:兼容 Rollup 的插件接口,通过精心排序的插件管道处理各类资源。插件按 pre -> 用户 pre -> normal -> 用户 normal -> post -> 用户 post -> 服务端专属的顺序执行。
- 模块图:维护模块依赖关系的核心数据结构,通过四个索引(URL、ID、文件、ETag)提供高效查找,支撑 HMR 传播和缓存失效机制。
- 构建引擎:基于 Rolldown 的全量打包器,复用同一套插件系统,但通过条件标志加载构建专属插件。
我们还总结了六种核心设计模式:中间件管道、插件容器、延迟初始化、请求去重、代理兼容和环境隔离。这些模式贯穿 Vite 的整个代码库,是理解后续章节的基础。
在下一章中,我们将深入配置系统——Vite 架构中最庞大的单文件(config.ts,2704 行),看看用户的配置对象是如何经过层层处理,最终成为驱动整个系统运转的 ResolvedConfig 的。配置系统是连接用户意图与内部实现的桥梁,理解它的工作原理将帮助我们更好地使用和调试 Vite。