Read OSS

深入 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 都拥有独立的 EnvironmentModuleGraphEnvironmentPluginContainer,以及可选的 DepsOptimizerViteDevServer 接口通过 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 的头部注释中有所说明。

每个 DevEnvironmentenvironment.init() 阶段都会创建独立的 EnvironmentPluginContainer 实例。该容器实现了与 Rolldown 构建时相同的 resolveIdloadtransform 钩子接口,并提供包含以下内容的 PluginContext

  • this.environment — 当前 DevEnvironment
  • this.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'

这一过程包括:

  1. 使用 es-module-lexer 解析所有 import 语句
  2. 通过插件容器的 resolveId 解析每个模块标识符
  3. 将裸模块 import 重写为 .vite/deps/ 下预构建依赖的路径
  4. 附加时间戳查询参数,在文件变更时破坏缓存
  5. 为调用了 import.meta.hot.accept() 的模块注入 HMR 边界标记
  6. 处理 import.meta.envimport.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

importersimportedModules 这两个集合共同构成一张双向图。当文件发生变更时,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 包的。