Vite 设计与实现

第5章 开发服务器架构

作者 杨艺韬 · 7,824 字

第5章 开发服务器架构

开篇引言

Vite 的开发服务器是整个项目中最复杂的子系统。它不只是一个”启动一个 HTTP 服务器然后返回文件”的简单程序——它是一个精密的运行时环境,需要在接收到浏览器请求的瞬间完成模块解析、代码转换、依赖预构建、HMR 通知等一系列操作,且全部延迟控制在毫秒级。

src/node/server/index.ts 是整个服务器的入口和骨架,不到 1100 行代码编排了十几个中间件、多个环境实例、WebSocket 服务器、文件监听器的协同工作。这个文件的结构揭示了 Vite 开发体验的全部秘密:为什么首次启动那么快?为什么文件修改后浏览器几乎瞬间更新?为什么大型单仓项目也不会让开发服务器变慢?

答案在于三个关键设计:

  1. 基于 Connect 的中间件栈:不是路由匹配,而是管线式处理。每个请求从头到尾经过十几个中间件,每个中间件只关注自己能处理的请求类型,其余放行给下一个。这种架构天然支持横切关注点的分离。

  2. 按需编译:浏览器请求哪个模块,服务器才编译哪个模块。没有预先打包的步骤,也没有全量扫描。模块的 resolveId -> load -> transform 链条只在首次请求时执行,结果缓存在模块图中。

  3. 多环境架构:每个 DevEnvironment(client、ssr 等)拥有独立的模块图和插件容器,互不干扰。这使得同一个服务器实例可以同时为客户端渲染和服务端渲染提供服务。

本章将从 _createServer 函数出发,逐步拆解开发服务器的每一个构成要素。

本章要点

  1. _createServer 是开发服务器的核心工厂函数,负责组装所有子系统
  2. Connect 中间件栈包含 15+ 个中间件,按精确顺序排列,分为安全层、配置层、静态资源层、转换层、回退层
  3. WebSocket 服务器既可以共享 HTTP 端口,也可以独立监听,通过 token 机制防止跨站劫持
  4. Chokidar 文件监听器触发的变更事件驱动整个 HMR 管线
  5. DevEnvironment 封装了每个运行环境的模块图、插件容器、依赖优化器
  6. 服务器支持中间件模式(middlewareMode),可以嵌入到 Express/Koa 等框架中

5.1 服务器创建流程

5.1.1 入口函数

开发服务器的创建从 createServer 开始,它只是 _createServer 的薄包装:

// src/node/server/index.ts
export function createServer(
  inlineConfig: InlineConfig | ResolvedConfig = {},
): Promise<ViteDevServer> {
  return _createServer(inlineConfig, { listen: true })
}

_createServer 是真正的工厂函数,它接受一个 options 参数来区分首次创建和重启场景。重启时会传入 previousEnvironmentspreviousShortcutsState 等状态,避免重复初始化。

5.1.2 初始化序列

_createServer 的执行可以分为七个阶段:

graph TD
    A["1. 解析配置 resolveConfig"] --> B["2. 创建基础设施"]
    B --> C["3. 创建环境实例"]
    C --> D["4. 组装 ViteDevServer 对象"]
    D --> E["5. 注册文件监听器"]
    E --> F["6. 安装中间件栈"]
    F --> G["7. 等待服务器就绪"]

    B --> B1["HTTP Server"]
    B --> B2["Connect App"]
    B --> B3["WebSocket Server"]
    B --> B4["Chokidar Watcher"]
    B --> B5["Public Files"]

让我们逐一展开:

阶段 1:解析配置

const config = isResolvedConfig(inlineConfig)
  ? inlineConfig
  : await resolveConfig(inlineConfig, 'serve')

如果传入的是已解析的配置(重启场景),直接使用;否则调用 resolveConfig 进行完整的配置解析流程。

阶段 2:创建基础设施

const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
  ? null  // 中间件模式不创建 HTTP 服务器
  : await resolveHttpServer(middlewares, httpsOptions)
const ws = createWebSocketServer(httpServer, config, httpsOptions)

const watcher = watchEnabled
  ? chokidar.watch([root, ...configFileDependencies, ...envFiles], resolvedWatchOptions)
  : createNoopWatcher(resolvedWatchOptions)

这段代码创建了四个核心对象:Connect 应用、HTTP 服务器、WebSocket 服务器、文件监听器。注意 middlewareMode 下不创建 HTTP 服务器——Vite 将作为中间件嵌入到外部服务器中。

阶段 3:创建环境实例

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
      await environment.init({ watcher, previousInstance: options.previousEnvironments?.[name] })
    },
  ),
)

环境实例的创建是并行的。每个环境在 init 时创建自己的 EnvironmentPluginContainer(如第 4 章所述)和 EnvironmentModuleGraph。默认配置下会创建 clientssr 两个环境。

阶段 4-5 会在后续章节详述。

阶段 6:安装中间件栈(下一节详述)

阶段 7:等待服务器就绪

const initServer = async (onListen: boolean) => {
  if (serverInited) return
  // 启动 client 环境的 pluginContainer
  await environments.client.pluginContainer.buildStart()
  // 启动所有环境的 hot channel 和依赖优化器
  await Promise.all(Object.values(environments).map((e) => e.listen(server)))
}

buildStart 只对 client 环境调用一次——这是为了向后兼容。如果插件设置了 perEnvironmentStartEndDuringDev: true,则每个环境都会收到 buildStart 调用。

5.2 ViteDevServer 接口

ViteDevServer 接口定义了开发服务器对外暴露的所有能力:

export interface ViteDevServer {
  config: ResolvedConfig
  middlewares: Connect.Server
  httpServer: HttpServer | null
  watcher: FSWatcher
  ws: WebSocketServer
  hot: NormalizedHotChannel
  pluginContainer: PluginContainer
  environments: Record<'client' | 'ssr' | (string & {}), DevEnvironment>
  moduleGraph: ModuleGraph

  transformRequest(url: string, options?: TransformOptions): Promise<TransformResult | null>
  warmupRequest(url: string, options?: TransformOptions): Promise<void>
  transformIndexHtml(url: string, html: string, originalUrl?: string): Promise<string>

  listen(port?: number, isRestart?: boolean): Promise<ViteDevServer>
  close(): Promise<void>
  restart(forceOptimize?: boolean): Promise<void>
  // ...
}

几个值得注意的设计细节:

  • pluginContainermoduleGraph 使用 getter 并标记了弃用警告:这是因为它们实际上是 client 环境的代理,为了向后兼容而保留。新代码应使用 server.environments.client.pluginContainer
  • wshotws 是底层的 WebSocket 服务器,hot 是标准化的 HMR 通道。两者在当前版本中指向同一个对象
  • environments 的类型声明使用了 Record<'client' | 'ssr' | (string & {}), DevEnvironment>,这个巧妙的类型既提供了 clientssr 的自动补全,又允许自定义环境名

5.3 HTTP/HTTPS 服务器创建

src/node/http.ts 封装了 HTTP 和 HTTPS 服务器的创建逻辑:

// src/node/http.ts
export async function resolveHttpServer(
  app: Connect.Server,
  httpsOptions?: HttpsServerOptions,
): Promise<HttpServer> {
  if (!httpsOptions) {
    const { createServer } = await import('node:http')
    return createServer(app)
  }

  const { createSecureServer } = await import('node:http2')
  return createSecureServer(
    {
      maxSessionMemory: 1000,
      streamResetBurst: 100000,
      streamResetRate: 33,
      ...httpsOptions,
      allowHTTP1: true,
    },
    app,
  )
}

当启用 HTTPS 时,Vite 使用 HTTP/2 的 createSecureServer 而非 HTTPS 的 createServer。HTTP/2 的多路复用特性对开发服务器特别有利——浏览器可以同时请求数十个模块而不受连接数限制。

三个硬编码的参数值得说明:

  • maxSessionMemory: 1000(默认 10 MB):大幅提高到 1000 MB,防止大型项目中大量并发请求导致 502 ENHANCE_YOUR_CALM 错误
  • streamResetBurst: 100000streamResetRate: 33:放宽流重置速率限制,防止快速导航时浏览器取消大量请求触发 ERR_HTTP2_PROTOCOL_ERROR
  • allowHTTP1: true:保持对 HTTP/1.1 客户端的兼容

5.3.1 端口管理

httpServerStart 实现了智能端口选择:

export async function httpServerStart(httpServer, serverOptions): Promise<number> {
  const { port: startPort, strictPort, host, logger } = serverOptions
  for (let port = startPort; port <= MAX_PORT; port++) {
    if (await isPortAvailable(port)) {
      const result = await tryBindServer(httpServer, port, host)
      if (result.success) return port
      if (result.error.code !== 'EADDRINUSE') throw result.error
    }
    if (strictPort) throw new Error(`Port ${port} is already in use`)
    logger.info(`Port ${port} is in use, trying another one...`)
  }
  throw new Error(`No available ports found`)
}

注意 isPortAvailable 的实现——它检查通配符地址(0.0.0.0::)上的端口可用性,而不仅仅是目标地址。这避免了一个微妙的问题:如果另一个进程绑定了 0.0.0.0:3000,即使 tryBindServer 尝试绑定 localhost:3000 也可能成功(在某些操作系统上),但实际上该端口已被占用。

5.3.2 客户端错误处理

export function setClientErrorHandler(server: HttpServer, logger: Logger): void {
  server.on('clientError', (err, socket) => {
    let msg = '400 Bad Request'
    if ((err as any).code === 'HPE_HEADER_OVERFLOW') {
      msg = '431 Request Header Fields Too Large'
      logger.warn('Server responded with status code 431...')
    }
    if ((err as any).code === 'ECONNRESET' || !socket.writable) return
    socket.end(`HTTP/1.1 ${msg}\r\n\r\n`)
  })
}

HPE_HEADER_OVERFLOW 是开发中常见的问题——当 Cookie 或自定义 Header 过大时 Node.js 的 HTTP parser 会抛出此错误。Vite 给出了友好的错误提示和文档链接。

5.4 Connect 中间件栈

5.4.1 为什么选择 Connect

Vite 没有使用 Express、Koa 或 Fastify,而是选择了 Connect——一个极其简单的中间件框架。Connect 的核心只有一个功能:将 HTTP 请求依次传递给一系列中间件函数。没有路由表、没有内置解析器、没有模板引擎。

这个选择背后的考量是:

  1. 最小依赖:Connect 本身几乎零依赖
  2. 完全控制:没有框架魔法,中间件的执行顺序完全由代码决定
  3. 嵌入友好:Connect 应用本身就是一个标准的 (req, res, next) 函数,可以直接嵌入到任何 Node.js HTTP 框架中

5.4.2 完整的中间件注册顺序

以下是 _createServer 中中间件的完整注册顺序。我从源码中逐行提取,每个中间件标注了其职责:

graph TD
    REQ["浏览器请求"] --> M1

    subgraph "安全层 Security Layer"
        M1["1. timeMiddleware<br/>(仅 DEBUG 模式)"]
        M2["2. rejectInvalidRequestMiddleware<br/>拒绝非法请求方法"]
        M3["3. rejectNoCorsRequestMiddleware<br/>拒绝缺少正确 Origin 的请求"]
        M4["4. corsMiddleware<br/>CORS 头部注入"]
        M5["5. hostValidationMiddleware<br/>DNS 重绑定攻击防护"]
    end

    subgraph "配置层 Configuration Layer"
        M6["6. configureServer pre hooks<br/>插件的前置服务器配置"]
    end

    subgraph "缓存层 Cache Layer"
        M7["7. cachedTransformMiddleware<br/>304 Not Modified 快速路径"]
    end

    subgraph "代理和基础路径 Proxy & Base"
        M8["8. proxyMiddleware<br/>API 代理"]
        M9["9. baseMiddleware<br/>base 路径重写"]
    end

    subgraph "工具层 Utility Layer"
        M10["10. launchEditorMiddleware<br/>点击错误打开编辑器"]
        M11["11. viteHMRPingMiddleware<br/>HMR 心跳检测"]
    end

    subgraph "静态资源层 Static Layer"
        M12["12. servePublicMiddleware<br/>public 目录文件"]
    end

    subgraph "转换层 Transform Layer"
        M13["13. transformMiddleware<br/>核心模块转换"]
        M14["14. serveRawFsMiddleware<br/>/@fs/ 路径文件"]
        M15["15. serveStaticMiddleware<br/>项目根目录静态文件"]
    end

    subgraph "回退层 Fallback Layer"
        M16["16. htmlFallbackMiddleware<br/>SPA 路由回退到 index.html"]
        M17["17. configureServer post hooks<br/>插件的后置服务器配置"]
        M18["18. indexHtmlMiddleware<br/>HTML 转换和注入"]
        M19["19. notFoundMiddleware<br/>404 响应"]
    end

    subgraph "错误层 Error Layer"
        M20["20. errorMiddleware<br/>错误捕获和展示"]
    end

    M1 --> M2 --> M3 --> M4 --> M5
    M5 --> M6 --> M7 --> M8 --> M9
    M9 --> M10 --> M11 --> M12
    M12 --> M13 --> M14 --> M15
    M15 --> M16 --> M17 --> M18 --> M19 --> M20

5.4.3 关键中间件详解

安全层(中间件 1-5)

安全中间件排在最前面,确保恶意请求在进入业务逻辑之前被拦截。rejectInvalidRequestMiddleware 拒绝非标准 HTTP 方法,hostValidationMiddleware 通过检查 Host 头防止 DNS 重绑定攻击(仅 HTTP 模式,HTTPS 不受此攻击影响)。

configureServer 钩子的双阶段执行(中间件 6 和 17)

const postHooks: ((() => void) | void)[] = []
for (const hook of config.getSortedPluginHooks('configureServer')) {
  postHooks.push(await hook.call(configureServerContext, reflexServer))
}
// ... 安装内部中间件 ...
postHooks.forEach((fn) => fn && fn())

这个双阶段设计意味着:

  • configureServer 中直接调用 server.middlewares.use() 注册的中间件排在内部中间件之前
  • 返回的后置函数注册的中间件排在 htmlFallbackMiddleware 之后、indexHtmlMiddleware 之前

这个位置非常巧妙——后置中间件可以拦截 SPA 路由回退后的请求,在 indexHtmlMiddleware 处理 HTML 之前提供自定义内容。

cachedTransformMiddleware(中间件 7)

这是性能的第一道防线。它检查请求的 If-None-Match 头是否匹配模块的 ETag:

// 如果 ETag 匹配,直接返回 304,无需任何文件 I/O 或代码转换
if (etag && req.headers['if-none-match'] === etag) {
  res.statusCode = 304
  return res.end()
}

transformMiddleware(中间件 13)

这是整个中间件栈中最核心的中间件。它拦截模块请求(.js.ts.vue.css 等),通过环境的插件容器进行完整的 resolveId -> load -> transform 管线处理,并将结果缓存到模块图中。第 6 章(模块图)和第 7 章(HMR)会深入分析它的实现。

htmlFallbackMiddleware(中间件 16)

对于 SPA 应用,所有非文件请求都需要回退到 index.html。这个中间件检查请求的 Accept 头是否包含 text/html,如果是且路径不匹配任何文件,就将请求重写为 index.html 的路径。

errorMiddleware(中间件 20)

错误中间件在栈的最末端,捕获前面所有中间件抛出的异常。它将错误格式化为浏览器可以渲染的 HTML 页面(开发模式下),或者在中间件模式下将错误传递给外部框架的错误处理器。

5.4.4 请求处理时序

一个典型的模块请求的完整处理流程:

sequenceDiagram
    participant Browser
    participant Connect as Connect Stack
    participant Cache as cachedTransformMiddleware
    participant Transform as transformMiddleware
    participant PC as PluginContainer
    participant MG as ModuleGraph

    Browser->>Connect: GET /src/App.tsx
    Connect->>Cache: 检查 If-None-Match
    alt ETag 匹配
        Cache-->>Browser: 304 Not Modified
    else ETag 不匹配或无缓存
        Cache->>Transform: next()
        Transform->>MG: 查找已缓存的 TransformResult
        alt 缓存命中
            MG-->>Transform: 返回缓存结果
        else 缓存未命中
            Transform->>PC: resolveId('/src/App.tsx')
            PC-->>Transform: '/absolute/path/src/App.tsx'
            Transform->>PC: load('/absolute/path/src/App.tsx')
            PC-->>Transform: 原始文件内容
            Transform->>PC: transform(code, id)
            PC-->>Transform: 转换后的代码 + sourcemap
            Transform->>MG: 缓存 TransformResult
        end
        Transform-->>Browser: 200 OK + 转换后的 JS
    end

5.5 WebSocket 服务器

5.5.1 创建策略

src/node/server/ws.ts 中的 createWebSocketServer 支持三种创建模式:

export function createWebSocketServer(
  server: HttpServer | null,
  config: ResolvedConfig,
  httpsOptions?: HttpsServerOptions,
): WebSocketServer {
  // 模式 1:禁用 WebSocket
  if (config.server.ws === false) {
    return noopWebSocketServer
  }

  const hmr = isObject(config.server.hmr) && config.server.hmr
  const wsServer = hmr?.server || (portsAreCompatible && server)

  if (wsServer) {
    // 模式 2:共享 HTTP 服务器(默认)
    wsServer.on('upgrade', hmrServerWsListener)
  } else {
    // 模式 3:独立 WebSocket 服务器
    wsHttpServer = httpsOptions
      ? createHttpsServer(httpsOptions, route)
      : createHttpServer(route)
    wsHttpServer.on('upgrade', handleUpgrade)
  }
}
graph TD
    A{"config.server.ws === false?"} -->|"是"| B["返回 noop 实现"]
    A -->|"否"| C{"HMR 端口与服务器端口兼容?"}
    C -->|"是"| D["共享 HTTP 服务器<br/>监听 upgrade 事件"]
    C -->|"否"| E["创建独立 HTTP 服务器<br/>监听独立端口"]
    D --> F["WebSocket Server Ready"]
    E --> F

默认情况下(模式 2),WebSocket 服务器复用 HTTP 服务器的端口。当浏览器发起 WebSocket 升级请求时,Connect 的 HTTP 处理管线会忽略它(因为它不是标准 HTTP 请求),由 upgrade 事件监听器接管。

模式 3 在以下场景激活:用户通过 hmr.port 指定了不同于 HTTP 服务器的端口。这在某些代理环境中是必要的。

5.5.2 安全机制:Token 验证

WebSocket 连接面临跨站 WebSocket 劫持(Cross-site WebSocket Hijacking)的风险。恶意网页可以向 ws://localhost:5173 发起 WebSocket 连接,如果没有验证机制,它就能接收到 HMR 消息,甚至可能利用自定义事件执行恶意操作。

Vite 的防护方案是 Token 验证:

function hasValidToken(config: ResolvedConfig, url: URL) {
  const token = url.searchParams.get('token')
  if (!token) return false
  try {
    return crypto.timingSafeEqual(
      Buffer.from(token),
      Buffer.from(config.webSocketToken),
    )
  } catch {}
  return false
}

const shouldHandle = (req: IncomingMessage) => {
  const protocol = req.headers['sec-websocket-protocol']!
  // vite-ping 允许任何来源连接
  if (protocol === 'vite-ping') return true

  // 检查 Host 头
  if (allowedHosts !== true && !isHostAllowed(req.headers.host, allowedHosts)) {
    return false
  }

  // 如果有 Origin 头(浏览器请求),必须携带有效 token
  if (req.headers.origin) {
    const parsedUrl = new URL(`http://example.com${req.url!}`)
    return hasValidToken(config, parsedUrl)
  }

  // 非浏览器请求(如 CLI 工具)可以无 token 连接
  return true
}

关键设计点:

  1. timingSafeEqual:使用恒定时间比较防止时序攻击
  2. vite-ping 豁免:心跳探测协议允许无 token 连接,因为它只用于检测服务器是否在线,不传输敏感数据,且连接会立即关闭
  3. 非浏览器请求豁免:没有 Origin 头的请求(如 Node.js 客户端)被认为是可信的——因为如果攻击者能直接发起无 SOP 限制的请求,他也可以发送普通 HTTP 请求,WebSocket 验证无法提供额外保护
  4. Token 通过 URL 查询参数传递:虽然 token 可能出现在服务器日志中,但这被认为是可接受的风险,因为 token 每次进程启动都会重新生成

5.5.3 连接处理

当验证通过后,handleUpgrade 将连接升级为 WebSocket:

const handleUpgrade = (req, socket, head, isPing) => {
  wss.handleUpgrade(req, socket, head, (ws) => {
    if (isPing) {
      ws.close(1000)  // Normal Closure
      return
    }
    wss.emit('connection', ws, req)
  })
}

vite-ping 连接在升级后立即关闭——它的唯一目的是探测服务器是否可达,不需要保持连接。

5.6 文件监听系统

5.6.1 Chokidar 配置

Vite 使用 Chokidar 监听文件系统变更。监听的路径包括:

const watcher = chokidar.watch(
  [
    ...(config.experimental.bundledDev ? [] : [root]),  // 项目根目录
    ...config.configFileDependencies,                    // 配置文件依赖
    ...getEnvFilesForMode(config.mode, config.envDir),  // .env 文件
    ...(publicDir && publicFiles ? [publicDir] : []),   // public 目录
  ],
  resolvedWatchOptions,
)

server.watch 设置为 null 时,Vite 使用 createNoopWatcher 创建一个什么都不做的虚拟监听器,这在 CI 环境或只读文件系统中很有用。

5.6.2 变更事件处理

文件变更触发三种事件,每种有不同的处理逻辑:

// 文件修改
watcher.on('change', async (file) => {
  file = normalizePath(file)
  reloadOnTsconfigChange(server, file)  // tsconfig 变更时重载

  // 通知所有环境的插件容器
  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)
  }

  // 触发 HMR
  await onHMRUpdate('update', file)
})

// 文件创建
watcher.on('add', (file) => onFileAddUnlink(file, false))

// 文件删除
watcher.on('unlink', (file) => onFileAddUnlink(file, true))

onFileAddUnlink 有一个巧妙的逻辑——当 public 目录中新增了一个文件,如果模块图中存在同路径的模块(例如 /logo.svg),它会清除该模块的 ETag 缓存。这确保下次请求该路径时,public 目录中的文件优先于模块图中的结果:

if (publicDir && publicFiles) {
  if (file.startsWith(publicDir)) {
    const path = file.slice(publicDir.length)
    publicFiles[isUnlink ? 'delete' : 'add'](path)
    if (!isUnlink) {
      const moduleWithSamePath = await clientModuleGraph.getModuleByUrl(path)
      const etag = moduleWithSamePath?.transformResult?.etag
      if (etag) {
        clientModuleGraph.etagToModuleMap.delete(etag)
      }
    }
  }
}

5.6.3 变更事件的传播顺序

sequenceDiagram
    participant FS as File System
    participant Chokidar
    participant PC as PluginContainer
    participant MG as ModuleGraph
    participant HMR as HMR Engine
    participant WS as WebSocket
    participant Browser

    FS->>Chokidar: 文件变更
    Chokidar->>PC: watchChange(file, { event })
    Note over PC: 通知所有环境的插件容器
    Chokidar->>MG: onFileChange(file)
    Note over MG: 使受影响的模块失效
    Chokidar->>HMR: handleHMRUpdate(type, file, server)
    HMR->>HMR: 确定更新边界
    HMR->>WS: 发送 update/full-reload 消息
    WS->>Browser: HMR Payload
    Browser->>Browser: 应用更新或刷新页面

5.7 DevEnvironment 类

5.7.1 结构概览

DevEnvironment(定义在 src/node/server/environment.ts)是每个运行环境在开发模式下的完整封装:

export class DevEnvironment extends BaseEnvironment {
  mode = 'dev' as const
  moduleGraph: EnvironmentModuleGraph
  depsOptimizer?: DepsOptimizer
  hot: NormalizedHotChannel
  _pluginContainer: EnvironmentPluginContainer<DevEnvironment> | undefined
  _pendingRequests: Map<string, { request: Promise<TransformResult | null>; timestamp: number; abort: () => void }>
  _crawlEndFinder: CrawlEndFinder

  constructor(name: string, config: ResolvedConfig, context: DevEnvironmentContext) {
    // 创建模块图
    this.moduleGraph = new EnvironmentModuleGraph(name, (url) =>
      this.pluginContainer.resolveId(url, undefined),
    )
    // 配置 HMR 通道
    this.hot = context.transport ? normalizeHotChannel(context.transport, context.hot) : normalizeHotChannel({}, context.hot)
    // 创建依赖优化器
    if (!isDepOptimizationDisabled(optimizeDeps)) {
      this.depsOptimizer = (optimizeDeps.noDiscovery ? createExplicitDepsOptimizer : createDepsOptimizer)(this)
    }
  }
}

5.7.2 初始化和生命周期

DevEnvironment 有三个生命周期阶段:

stateDiagram-v2
    [*] --> Created : new DevEnvironment()
    Created --> Initiated : init()
    Initiated --> Listening : listen()
    Listening --> Closed : close()
    Closed --> [*]

    state Created {
        [*] --> moduleGraph_created
        moduleGraph_created --> hot_configured
        hot_configured --> depsOptimizer_created
    }

    state Initiated {
        [*] --> pluginContainer_created
        pluginContainer_created --> ready
    }

    state Listening {
        [*] --> hot_listening
        hot_listening --> depsOptimizer_inited
        depsOptimizer_inited --> warmup_started
    }

init() 阶段创建插件容器:

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

listen() 阶段启动 HMR 通道、依赖优化器和预热:

async listen(server: ViteDevServer): Promise<void> {
  this.hot.listen()
  await this.depsOptimizer?.init()
  warmupFiles(server, this)
}

5.7.3 模块转换

transformRequestDevEnvironment 最核心的方法。它接受一个 URL,返回转换后的代码:

transformRequest(url: string, options?: TransformOptionsInternal): Promise<TransformResult | null> {
  return transformRequest(this, url, options)
}

实际的 transformRequest 函数(定义在 transformRequest.ts 中)协调了完整的模块处理管线:URL 解析 -> 缓存检查 -> resolveId -> load -> transform -> 结果缓存。这个过程的详细机制将在第 6 章展开。

5.7.4 请求去重

_pendingRequests Map 实现了请求去重——如果同一个模块的转换请求正在处理中,后续请求会等待已有的 Promise 而不是发起新的转换:

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

这在页面首次加载时尤为重要:浏览器可能同时请求同一个模块的多个版本(例如通过不同的 import 路径),去重机制确保每个模块只被转换一次。

5.8 中间件模式

5.8.1 嵌入到外部框架

middlewareModetrue 或一个对象时,Vite 不创建自己的 HTTP 服务器:

const httpServer = middlewareMode
  ? null
  : await resolveHttpServer(middlewares, httpsOptions)

此时,server.middlewares(Connect 应用)可以作为中间件嵌入到 Express、Koa 或其他框架中:

// 在 Express 中使用 Vite
const app = express()
const vite = await createServer({
  server: { middlewareMode: true },
})
app.use(vite.middlewares)
app.listen(3000)

5.8.2 WebSocket 端口共享

middlewareMode 支持传入父服务器实例以实现 WebSocket 端口共享:

middlewareMode?: boolean | {
  server: HttpServer  // 父服务器实例
}

当传入 server 时,WebSocket 的 upgrade 事件会注册在父服务器上,而不是创建独立的 WebSocket 服务器。这避免了端口冲突,同时保持 HMR 功能正常。

5.9 服务器重启机制

restart 方法的实现揭示了一个优雅的状态传递机制:

async restart(forceOptimize?: boolean) {
  if (!server._restartPromise) {
    server._forceOptimizeOnRestart = !!forceOptimize
    server._restartPromise = restartServer(server).finally(() => {
      server._restartPromise = null
      server._forceOptimizeOnRestart = false
    })
  }
  return server._restartPromise
}

restartServer 函数关闭旧服务器,创建新服务器,并将旧实例的状态传递给新实例:

// 重启时传递的状态
_createServer(inlineConfig, {
  listen: true,
  previousEnvironments: server.environments,     // 环境实例
  previousShortcutsState: server._shortcutsState, // CLI 快捷键状态
  previousRestartPromise: server._restartPromise,  // 重启 Promise
})

为了保持外部引用的有效性,Vite 使用了一个 Proxy 技巧:

const reflexServer = new Proxy(server, {
  get: (_, property) => server[property],
  set: (_, property, value) => { server[property] = value; return true },
})

所有对外暴露的 server 引用实际上是这个 Proxy。当 server 变量在重启过程中被替换为新实例时,Proxy 自动指向新实例,外部持有的引用无需更新。这个模式也用在 configureServer 钩子中——传给插件的是 reflexServer,确保插件在服务器重启后仍然能访问到正确的实例。

5.10 设计决策

5.10.1 为什么不用路由表而用中间件栈?

路由表(如 Express 的 app.get('/path', handler))适合 API 服务器,因为每个端点有明确的路径模式。但开发服务器的请求模式是动态的:

  • /src/App.tsx -> 模块转换
  • /public/logo.svg -> 静态文件
  • /@fs/node_modules/react/index.js -> 文件系统访问
  • / -> HTML 回退
  • 任意路径 -> 可能匹配 public 文件、可能需要转换、可能需要代理

中间件栈让每个关注点独立判断”这个请求是否属于我”,不需要预先枚举所有可能的路径模式。这也使得插件可以在任意位置注入自定义处理逻辑。

5.10.2 为什么 public 文件中间件排在 transform 之前?

servePublicMiddleware 排在 transformMiddleware 之前,这意味着 public 目录中的文件优先于需要转换的源码文件。如果 public/app.jssrc/app.js 同时存在,对 /app.js 的请求会直接返回 public 目录中的文件,不会触发模块转换。

这个设计反映了 public 目录的语义:它包含的是不需要任何处理的静态资源,应该以最高优先级、最低延迟返回。

5.10.3 为什么 buildStart 只对 client 环境调用?

// 只对 client 环境调用 buildStart
await environments.client.pluginContainer.buildStart()

这是一个向后兼容的决策。在 Environment API 引入之前,buildStart 在服务器启动时只调用一次。为了不破坏现有插件的行为,默认仍然只对 client 环境调用。插件可以通过设置 perEnvironmentStartEndDuringDev: true 来启用按环境调用。

这体现了 Vite 团队的渐进式迁移策略:新功能通过 opt-in 的方式引入,现有行为保持不变,给生态留出迁移时间。

5.10.4 src/node/server/ 真实尺寸:18 文件 9184 行的核心地图

把整个 packages/vite/src/node/server/ 子树按行数实测——

主目录 9 文件——

文件角色
index.ts1380_createServer + ViteDevServer 接口实现(§5.1)——本目录最大
pluginContainer.ts1326第二大——§4 PluginContainer 的服务端运行时(resolveId / load / transform 链式调用)
hmr.ts1154HMR 模块图遍历 + Boundary 计算 + WebSocket 推送编排——本章 §5.6.3 触及但未深入
mixedModuleGraph.ts741本章未提——Vite 7 引入的 backwards-compat shim:让插件用旧的 ModuleGraph API 时透明转发到新的 EnvironmentModuleGraph——是迁移期的关键
transformRequest.ts560单个请求的转换主流程 + 缓存 + 同请求去重(§5.7.4)
moduleGraph.ts489新版 EnvironmentModuleGraph——按 environment 隔离的模块图
ws.ts467WebSocket 服务器(§5.5)
environment.ts401DevEnvironment 类(§5.7)
sourcemap.ts / send.ts / warmup.ts / openBrowser.ts / searchRoot.ts78~180工具

middlewares/ 13 文件(本章 §5.4.2 说”15+ 个中间件”——实测 13 个文件)——

文件角色
indexHtml.ts619HTML 入口转换——本目录最大、不只是路由还要预处理 <script type="module">
transform.ts375/src/... 的模块转换路由
static.ts361静态资源 + public/ 服务
proxy.ts236server.proxy 配置代理
error.ts108全局错误捕获
htmlFallback.ts91SPA fallback to index.html
hostCheck.ts62DNS rebinding 防护
base.ts62base path 重写
memoryFiles.ts / rejectNoCorsRequest.ts / rejectInvalidRequest.ts / time.ts / notFound.ts23~52杂项小中间件

environments/ 3 文件 557 行——

文件角色
fullBundleEnvironment.ts413整批打包的非 SSR 环境(开发期编辑保存后整 bundle 重建)
runnableEnvironment.ts80SSR 模块运行时
fetchableEnvironment.ts64Edge Worker 风格的 fetch() 环境

三条值得记住的物理事实——

  1. hmr.ts 1154 行 + pluginContainer.ts 1326 行 = HMR/插件容器合计 2480 行 = server/ 27%——dev server 的”两大智力中枢”——比 index.ts 服务器创建编排(1380)还重——印证 dev server 价值密度集中在”模块图变化的反应”和”插件钩子链调度”两块、不在 HTTP 套接字层
  2. mixedModuleGraph.ts 741 行是本章完全没提的板块——Vite 7 引入新的多 environment 模块图后、为了让旧插件(Vue plugin / React plugin / 各种社区插件)继续 work、做了一个透明转发 shim——是 Vite 演进期”新旧 API 共存”工程债的体现;下一版章节应补一节
  3. indexHtml.ts 619 行是 middlewares/ 最大——但它不只是路由——HTML 入口要做 <script src="/src/main.ts"> 改写、vite/client 注入、CSP nonce、@vite/plugin 钩子链等数十件事——是为什么 Vite 的”index.html 当 entry”看似自然其实工程量大的根因

5.11 小结

Vite 的开发服务器是一个精心编排的协作系统。_createServer 工厂函数将 HTTP 服务器、Connect 中间件栈、WebSocket 服务器、Chokidar 文件监听器、多个 DevEnvironment 实例组装为一个统一的 ViteDevServer

中间件栈的设计是理解开发服务器的关键。15+ 个中间件按照”安全 -> 配置 -> 缓存 -> 代理 -> 静态资源 -> 模块转换 -> HTML 回退 -> 错误处理”的逻辑分层排列,每一层都有明确的职责边界。插件通过 configureServer 钩子可以在内部中间件之前或之后注入自定义处理。

WebSocket 服务器的 Token 验证机制和 Chokidar 的变更事件传播链共同构成了 HMR 的基础设施。文件变更从文件系统出发,经过插件通知、模块图失效、HMR 边界计算,最终通过 WebSocket 推送到浏览器。

DevEnvironment 作为环境的运行时封装,将模块图、插件容器、依赖优化器、HMR 通道聚合在一起,为多环境并行开发提供了清晰的隔离边界。

物理事实:server/ 子树 18 文件 9184 行——hmr.ts 1154 + pluginContainer.ts 1326 合计 27% 是 dev server 的”两大智力中枢”;middlewares/ 实测 13 个文件(不是 15+);mixedModuleGraph.ts 741 行是 Vite 7 引入的新旧 API 共存 shim、本章未覆盖。

5.12 中间件顺序是一条安全边界,不只是实现细节

前文把中间件栈解释为”请求处理流水线”,但源码里更重要的是顺序本身就是安全模型../vite-latest/packages/vite/src/node/server/index.ts:927-940 先挂 rejectInvalidRequestMiddlewarerejectNoCorsRequestMiddleware、CORS 和 host validation;尤其 host validation 的注释明确说它用于防 DNS rebinding。也就是说,Vite 先确认”这个请求能不能进门”,再讨论它是不是源码模块、静态资源或 HTML。

之后才进入插件扩展点:configureServer hooks 在 ../vite-latest/packages/vite/src/node/server/index.ts:943-952 执行,返回的 post hooks 会被暂存。这个设计给插件两个插入点:hook 函数里直接 middlewares.use() 的逻辑会排在内部中间件之前;返回的 post hook 会在 HTML 中间件之前执行(../vite-latest/packages/vite/src/node/server/index.ts:1015-1019)。插件因此可以选择”拦截早一点”还是”等 Vite 内部处理完再接手”。

flowchart TB
    A["安全前置<br/>invalid / CORS / host"] --> B["configureServer 前置 hook"]
    B --> C["cachedTransform / proxy / base"]
    C --> D["public 静态文件"]
    D --> E["transformMiddleware"]
    E --> F["raw fs / static"]
    F --> G["htmlFallback"]
    G --> H["configureServer post hook"]
    H --> I["indexHtml / notFound / error"]

最容易写错的是 public 与 transform 的顺序。源码注释写得很直白:servePublicMiddleware 应该在 transform middleware 之前,因为 public 文件要原样返回,不参与转换(../vite-latest/packages/vite/src/node/server/index.ts:986-997)。如果顺序反过来,public/foo.js 会被当成源码模块分析,既浪费转换成本,也破坏 public 目录”不处理、直接服务”的语义。

这个顺序还解释了为什么 dev server 不能简单类比成 Express 路由表。API 服务器通常先按路径匹配处理器;Vite 必须先处理安全和插件,再按资源语义分流:public、源码模块、raw fs、SPA fallback、HTML transform。路径只是输入,最终走哪条路径取决于文件系统、插件、appType、base、proxy 和安全配置共同判断。

5.13 restart 的真正约束:外部引用不能失效

第 5.9 节讲过 restart,但还可以再往下追一层:为什么 Vite 要用 reflexServer 这个 Proxy,而不是简单返回一个新 server?

../vite-latest/packages/vite/src/node/server/index.ts:797-803 把并发 restart 合并到同一个 _restartPromise,避免用户连续保存配置文件时启动多个重启流程。真正重启时,restartServer 会重新调用 _createServer,并把旧 server 的 environments、快捷键状态、restart promise、forceOptimize 标志传给新实例(../vite-latest/packages/vite/src/node/server/index.ts:1272-1297)。这说明重启不是”关掉再开”这么粗糙,而是一次带状态迁移的重建。

Proxy 的价值在插件生态。插件的 configureServer hook 拿到的是 server 对象;如果重启后这个引用失效,插件内部保存的闭包、WebSocket 推送逻辑、调试工具都会指向旧实例。reflexServerget/set 都转发到当前 server 变量(../vite-latest/packages/vite/src/node/server/index.ts:820-826),当内部 server 被替换时,外部引用仍然可用。

普通重建Vite restart
旧引用失效,插件要重新拿 server旧引用通过 Proxy 自动指向新实例
并发保存可能触发多次重启_restartPromise 合并并发重启
状态容易丢失显式传递 environments、shortcuts、forceOptimize
插件需要处理生命周期细节Vite 把迁移封装在 server 内部

这就是 Vite dev server 的工程目标:让配置变更、依赖优化重跑、插件重新初始化都能发生,但用户和插件作者感知到的 server 引用尽量稳定。开发服务器的难点不只是”快”,还包括”重启时不让生态掉下去”。

5.14 插件作者使用 configureServer 的边界

configureServer 很强,但它不是让插件随意接管 dev server 的后门。结合 ../vite-latest/packages/vite/src/node/server/index.ts:943-9521015-1019,插件作者应该明确自己要插入哪一段:

需求应该做法不该做法
提供自定义 API在 hook 中直接 middlewares.use(),排在 Vite 内部转换前覆盖 Vite 的 transform 中间件
修改 HTML fallback 后的内容返回 post hook,排在 indexHtmlMiddleware在最前面吞掉所有 GET 请求
监听 HMR 状态使用传入的 server / ws API自己创建另一套 WebSocket 端口
处理静态文件明确限定 path 前缀/ 全部接管

原因很简单:Vite dev server 同时服务源码模块、HTML、public 文件、raw fs、HMR ping、proxy 和错误页。插件如果用一个过宽的中间件把请求提前 end(),后面的模块图、缓存、HTML transform 都没有机会执行。好的插件应该像外科手术一样只切自己的路径,剩余请求交回 next()

这也是为什么 reflexServer 很重要。插件保存 server 引用是常见行为;Vite 通过 Proxy 保证重启后这个引用继续指向新实例。插件作者要利用这个稳定性,而不是把内部的 httpServermiddlewares.stack 或私有字段当作长期 API。稳定扩展点只有公开 hook 和公开 server 方法,绕过它们会在重启、middlewareMode、多环境开发时暴露问题。