深入解析 Vite dev:从命令行到浏览器热更新的完整旅程

0 阅读9分钟

在前端工程化领域,Vite 凭借其极致的开发体验和强大的构建能力,已成为新一代开发工具链的事实标准。随着 Vite 8 的正式发布,这套工具在性能和架构上再次实现突破——底层打包器统一为 Rust 编写的 Rolldown,开发环境启动速度和热更新响应迈入毫秒级时代。而作为开发者每天最常接触的命令行入口,vite dev 和 vite serve 背后承载着怎样的设计理念?它们又有哪些鲜为人知的细节?本文将为你一一揭晓。

命令本质:开发服务器的统一入口

在 Vite 8 中,vite dev 与 vite serve 实际上是同一个命令的两种不同叫法,二者完全等价。

vite
vite dev
vite serve

之所以保留两个名称,主要是为了兼容过往的习惯(如 serve 源自早期版本,而 dev 更直观地表达开发用途)。

vite 启动通用的命令行参数?

// 定义 Vite 命令行工具
const cli = cac('vite')

cli
  .option('-c, --config <file>', `[string] use specified config file`)
  .option('--base <path>', `[string] public base path (default: /)`, {
    type: [convertBase],
  })
  .option('-l, --logLevel <level>', `[string] info | warn | error | silent`)
  .option('--clearScreen', `[boolean] allow/disable clear screen when logging`)
  .option(
    '--configLoader <loader>',
    `[string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle)`,
  )
  .option('-d, --debug [feat]', `[string | boolean] show debug logs`)
  .option('-f, --filter <filter>', `[string] filter debug logs`)
  .option('-m, --mode <mode>', `[string] set env mode`)
# 指定配置文件路径
vite dev --config my.config.js

# 设置公共路径,默认 /
vite dev --base /my-app/

# 设置日志级别
vite dev --logLevel error` # 只输出错误

vite dev --clearScreen # 启用清屏
vite dev --no-clearScreen # 禁用清屏

# `bundle`(默认):使用 Rolldown 将配置文件打包后执行。
# `runner`(实验性):使用动态 `import()` 即时处理配置文件。
# `native`(实验性):使用原生 Node.js 模块加载(需配置文件为 ESM)。
vite dev --configLoader runner # 使用 Rolldown 将配置文件打包后执行

vite dev --debug               # 开启全部调试日志
vite dev --debug vite:hmr      # 仅显示 HMR 相关调试信息

# 指定运行模式(如 `development`、`production`、`staging`)。
# Vite 会加载对应的环境变量文件(例如 `.env.[mode]`),并影响 `import.meta.env.MODE` 的值。
vite dev --mode staging

vite dev 启动接收的命令行参数

在当前目录下启动 Vite 开发服务器。vite dev 和 vite serve 是 vite 的别名。

cli
  .command('[root]', 'start dev server') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
  .option('--host [host]', `[string] specify hostname`, { type: [convertHost] })
  .option('--port <port>', `[number] specify port`)
  .option('--open [path]', `[boolean | string] open browser on startup`)
  .option('--cors', `[boolean] enable CORS`)
  .option('--strictPort', `[boolean] exit if specified port is already in use`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`,
  )
  .option(
    '--experimentalBundle',
    `[boolean] use experimental full bundle mode (this is highly experimental)`,
  )
# 指定项目的根目录。如果不提供,默认使用当前工作目录(`process.cwd()`)
vite dev ./my-project

vite dev --host               # 监听所有接口
vite dev --host localhost     # 仅监听本地

vite dev --port 3000

vite dev --open               # 打开 http://localhost:5173/
vite dev --open /admin        # 打开 http://localhost:5173/admin

# 强制依赖优化器忽略缓存,重新预构建所有依赖(`optimizeDeps`)
vite dev --force

# 启用实验性的“全量打包开发模式”(`bundledDev`)
vite dev --experimentalBundle

启用实验性的“全量打包开发模式”,文件会被打包。会减少大量请求。

image.png

命令行执行 vite 后做了什么?

  1. 创建 server 实例
  2. 启动监听端口
async (
  root: string,
  options: ServerOptions & ExperimentalDevOptions & GlobalCLIOptions,
) => {
  filterDuplicateOptions(options)
  // output structure is preserved even after bundling so require()
  // is ok here
  // 动态导入并创建开发服务器
  const { createServer } = await import('./server')
  try {
    const server = await createServer({
      root,
      base: options.base,
      mode: options.mode,
      configFile: options.config,
      configLoader: options.configLoader,
      logLevel: options.logLevel,
      clearScreen: options.clearScreen,
      server: cleanGlobalCLIOptions(options),
      forceOptimizeDeps: options.force,
      experimental: {
        bundledDev: options.experimentalBundle,
      },
    })

    // 校验服务器实例并启动
    if (!server.httpServer) {
      throw new Error('HTTP server not available')
    }

    // 启动 HTTP 服务器监听指定端口
    await server.listen()

    // 输出启动日志
    const info = server.config.logger.info

    const modeString =
    // 非 development 模式,输出环境模式
      options.mode && options.mode !== 'development'
        ? `  ${colors.bgGreen(` ${colors.bold(options.mode)} `)}`
        : ''

    // 启动耗时(计算从 Vite 启动到服务器就绪的时间)
    const viteStartTime = global.__vite_start_time ?? false
    const startupDurationString = viteStartTime
      ? colors.dim(
          `ready in ${colors.reset(
            colors.bold(Math.ceil(performance.now() - viteStartTime)),
          )} ms`,
        )
      : ''
    // 检查是否有已存在的日志输出(避免重复打印)
    const hasExistingLogs =
      process.stdout.bytesWritten > 0 || process.stderr.bytesWritten > 0

    // 输出核心启动日志(Vite 版本 + 模式 + 启动耗时)
    info(
      `\n  ${colors.green(
        `${colors.bold('VITE')} v${VERSION}`,
      )}${modeString}  ${startupDurationString}\n`,
      {
        clear: !hasExistingLogs,
      },
    )

    // 打印服务器访问地址(如 http://localhost:3000/)
    server.printUrls()
    const customShortcuts: CLIShortcut<typeof server>[] = []
    if (profileSession) {
      customShortcuts.push({
        key: 'p',
        description: 'start/stop the profiler',
        async action(server) {
          if (profileSession) {
            await stopProfiler(server.config.logger.info)
          } else {
            const inspector = await import('node:inspector').then(
              (r) => r.default,
            )
            await new Promise<void>((res) => {
              profileSession = new inspector.Session()
              profileSession.connect()
              profileSession.post('Profiler.enable', () => {
                profileSession!.post('Profiler.start', () => {
                  server.config.logger.info('Profiler started')
                  res()
                })
              })
            })
          }
        },
      })
    }
    // 绑定快捷键到服务器(print: true 表示打印快捷键说明)
    server.bindCLIShortcuts({ print: true, customShortcuts })
  } catch (e) {
    const logger = createLogger(options.logLevel)
    logger.error(
      colors.red(`error when starting dev server:\n${inspect(e)}`),
      {
        error: e,
      },
    )
    await stopProfiler(logger.info)
    process.exit(1)
  }
},

image.png

image.png

// 启动 HTTP 服务器监听指定端口
async listen(port?: number, isRestart?: boolean) {
  // 解析主机名
  const hostname = await resolveHostname(config.server.host)
  if (httpServer) {
    httpServer.prependListener('listening', () => {
      // 解析服务器监听的 URL 地址
      server.resolvedUrls = resolveServerUrls(
        httpServer,
        config.server,
        hostname,
        httpsOptions,
        config,
      )
    })
  }
  // 启动 HTTP 服务器
  await startServer(server, hostname, port)
  if (httpServer) {
    // 如果不是重启,配置了 open 选项打开浏览器
    if (!isRestart && config.server.open) server.openBrowser()
  }
  return server
},

createServer 函数做了什么工作?

  1. 参数解析与配置校验。
  2. 服务器基础设施创建(HTTP/WS/中间件/文件监听)。
  3. 多环境(environments)初始化。
  4. 服务器对象构建与向后兼容。
  5. 中间件栈构建。
  6. 文件变化监听与 HMR。
  7. 启动服务器逻辑。
  8. 返回 server 实例。

一、config 解析

  1. 加载配置文件:读取 vite.config.js / vite.config.ts(可通过 --config 指定其他文件)。如果文件是 TypeScript,Vite 会使用 esbuild 或 rolldown 动态编译。
  2. 合并命令行参数:命令行选项优先级高于配置文件。
  3. 应用默认值:补充未提供的选项(如 root 默认为 process.cwd()base 默认为 /)。
  4. 加载环境变量:根据 mode(默认 development)读取 .env 和 .env.[mode] 文件,注入 process.env 和 import.meta.env
  5. 加载插件:收集用户配置中的 plugins 数组,调用每个插件的 config 钩子(允许插件修改配置),最后调用 configResolved 钩子通知插件配置已解析完成。
  6. 生成 ResolvedConfig:输出完整的、只读的配置对象,包含 serverbuildoptimizeDepsenvironments 等字段。

image.png

image.png

二、服务器基础设施创建(HTTP/WS/中间件/文件监听)

  // 3、网络服务构建
  const middlewares = connect() as Connect.Server

  // middlewareMode 为 true 时,不解析 HTTP 服务器,以中间件模式创建;否则解析 HTTP 服务器
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(middlewares, httpsOptions)

  // 创建 WebSocket 服务器
  const ws = createWebSocketServer(httpServer, config, httpsOptions)

新建 HTTP 服务

async function resolveHttpServer(
  app: Connect.Server,
  httpsOptions?: HttpsServerOptions,
): Promise<HttpServer> {
  // 如果没有 httpsOptions,创建 HTTP 服务器
  if (!httpsOptions) {
    // http 模块在 net 的基础上增加了 HTTP 协议解析和封装能力。
    // 当你创建一个 HTTP 服务器时,实际底层是一个 net.Server
    const { createServer } = await import('node:http')
    return createServer(app) // 创建 HTTP 服务器
  }

  // 如果有 httpsOptions,创建 HTTPS 服务器
  const { createSecureServer } = await import('node:http2')
  return createSecureServer(
    {
      // Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
      // errors on large numbers of requests
      maxSessionMemory: 1000, // 增加会话内存,防止 502 错误
      // Increase the stream reset rate limit to prevent net::ERR_HTTP2_PROTOCOL_ERROR
      // errors on large numbers of requests
      streamResetBurst: 100000, // 增加流重置突发量,防止 net::ERR_HTTP2_PROTOCOL_ERROR 错误
      streamResetRate: 33, // 增加流重置速率,防止 net::ERR_HTTP2_PROTOCOL_ERROR 错误
      ...httpsOptions, // 合并 httpsOptions 选项
      allowHTTP1: true, // 允许 HTTP/1 协议
    },
    // @ts-expect-error TODO: is this correct?
    app,
  )
}

三、 多环境(environments)初始化

Vite 8 引入了多环境(Environments)概念,每个环境(如 clientssr)拥有独立的模块图、插件容器和依赖优化器。

  const environments: Record<string, DevEnvironment> = {}

  // 多环境(Environments)初始化
  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 })
      },
    ),
  )

四、环境向后兼容

在 Vite 8 引入多环境(environments)之前,Vite 只有一个全局的模块图。升级到 Vite 8 后,每个环境(clientssr)有了自己独立的模块图,但为了不破坏现有的插件和 API,Vite 需要提供一个兼容层,使得老代码依然可以通过 server.moduleGraph 访问模块图。

五、中间件栈构建

  1. 请求计时器(仅 DEBUG 模式)
  2. 拒绝无效请求(过滤包含空格等非法字符的请求)
  3. CORS 中间件(默认启用)
  4. 主机验证(防止 DNS 重绑定攻击)
  5. 用户插件 configureServer 钩子(允许插件注入自定义中间件)
  6. 缓存转换中间件(若未启用 bundledDev
  7. 代理中间件(将 /api 等请求转发到后端服务器)
  8. Base 路径中间件(处理 base 配置)
  9. 编辑器打开支持/__open-in-editor
  10. HMR Ping 处理(响应客户端心跳)
  11. public 目录静态服务(直接返回 public 下的文件)
  12. 转换中间件(核心) :拦截对 .js.vue.ts 等文件的请求,调用插件链进行转换,返回最终代码。
  13. 静态文件服务:返回项目根目录下未被转换的静态资源。
  14. HTML fallback(SPA 模式下,未匹配路径返回 index.html
  15. index.html 转换中间件:注入客户端脚本(/@vite/client)和环境变量。
  16. 404 处理
  17. 错误处理中间件

六、利用 chokidar,文件变化监听

  // 9、文件变更事件处理
  // 监听文件变化事件
  watcher.on('change', async (file) => {
    file = normalizePath(file)
    // 检查是否是 TypeScript 配置文件变化,如果是则重启服务器
    reloadOnTsconfigChange(server, file)

    await Promise.all(
      Object.values(server.environments).map((environment) =>
        // 通知所有环境的插件容器文件已更新
        environment.pluginContainer.watchChange(file, { event: 'update' }),
      ),
    )
    // invalidate module graph cache on file change
    for (const environment of Object.values(server.environments)) {
      environment.moduleGraph.onFileChange(file)
    }
    // 触发热模块替换更新,将变更同步到客户端
    await onHMRUpdate('update', file)
  })

  // 监听文件添加事件
  watcher.on('add', (file) => {
    onFileAddUnlink(file, false)
  })
  // 监听文件删除事件
  watcher.on('unlink', (file) => {
    onFileAddUnlink(file, true)
  })
修改 tsconfig.app.json

image.png

修改 tsconfig.json文件

会全量刷新,执行 location.reload()

4:13:20 PM [vite] changed tsconfig file detected: /Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. (x2)

image.png

{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxx/Documents/code/cloudcode/vue3-vite-cube/tsconfig.json"
    }
}
修改 vue 页面的 script setup 块template 块
{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/home/index.vue"
    }
}
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "timestamp": 1775036864943,
            "path": "/src/pages/home/index.vue",
            "acceptedPath": "/src/pages/home/index.vue",
            "explicitImportRequired": false,
            "isWithinCircularImport": false
        }
    ]
}

image.png

修改 vue 页面的 style 块

image.png

{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxxxxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/home/index.vue"
    }
}
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "timestamp": 1775037234261,
            "path": "/src/pages/home/index.vue",
            "acceptedPath": "/src/pages/home/index.vue",
            "explicitImportRequired": false,
            "isWithinCircularImport": false
        },
        {
            "type": "js-update",
            "timestamp": 1775037234261,
            "path": "/src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.css",
            "acceptedPath": "/src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.css",
            "explicitImportRequired": false, // 示是否需要显式动态导入新模块
            "isWithinCircularImport": false // 表示是否处于循环依赖中
        }
    ]
}

image.png

七、启动服务器逻辑

真正启动服务器在 cli 中 server.listen 执行。

这里只是重写 listen方法 ,待启动服务器时执行。

  • 调用 server.listen()
  • 监听配置的端口(默认 5173)
  • 启动完成后执行回调
  • 自动打开浏览器(如果配置 server.open
  • 终端打印
  let initingServer: Promise<void> | undefined
  let serverInited = false // 标记服务器是否已初始化

  if (!middlewareMode && httpServer) {
    // overwrite listen to init optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    // 重写 listen 方法,确保在服务器启动前初始化优化器
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        await initServer(true)
      } catch (e) {
        httpServer.emit('error', e)
        return
      }
      // 调用原始 listen 方法启动服务器
      return listen(port, ...args)
    }) as any
  } else {
    await initServer(false)
  }
  const initServer = async (onListen: boolean) => {
    if (serverInited) return // 如果服务器已初始化,直接返回
    if (initingServer) return initingServer // 如果服务器正在初始化,直接返回

    initingServer = (async function () {
      // 如果没有配置 bundledDev,则在初始化服务器时调用 buildStart 方法
      if (!config.experimental.bundledDev) {
        // For backward compatibility, we call buildStart for the client
        // environment when initing the server. For other environments
        // buildStart will be called when the first request is transformed
        await environments.client.pluginContainer.buildStart()
      }

      // ensure ws server started
      // 确保 WebSocket 服务器已启动
      if (onListen || options.listen) {
        await Promise.all(
          // 确保所有环境的服务器都启动
          Object.values(environments).map((e) => e.listen(server)),
        )
      }

      initingServer = undefined // 清空初始化 Promise
      serverInited = true // 标记服务器已初始化
    })()
    return initingServer
  }

热更新

Vite 的热更新(HMR)基于原生 ES 模块和 WebSocket 实现,能在文件修改后仅更新受影响的模块,无需刷新页面,从而保留应用状态。其原理可分为服务端和客户端两个阶段。

服务端:变化检测与消息推送

一、文件检测

Vite 使用 chokidar 库来监听文件系统的变化。在 _createServer 函数中,会创建一个文件监听器(watcher),监听范围包括:

  • 项目根目录(root
  • 配置文件依赖(config.configFileDependencies
  • 环境变量文件(.env 等)
  • public 目录
(chokidar.watch(
    // config file dependencies and env file might be outside of root
    [
      ...(config.experimental.bundledDev ? [] : [root]),
      ...config.configFileDependencies,
      ...getEnvFilesForMode(config.mode, config.envDir),
      // Watch the public directory explicitly because it might be outside
      // of the root directory.
      ...(publicDir && publicFiles ? [publicDir] : []),
    ],

    resolvedWatchOptions,
  ) as FSWatcher)

二、模块图与依赖分析

1、 模块图的数据结构

  • urlToModuleMap:根据 URL 查找模块节点。
  • fileToModulesMap:根据文件路径查找对应的模块节点(一个文件可能对应多个模块,如 ?import 和 ?url 查询)。
  • 每个模块节点(ModuleNode)记录了:
    • importers:依赖该模块的模块(即父模块)。
    • importedModules:该模块导入的子模块。

2、依赖分析

当文件发生变化时,handleHMRUpdate 会执行以下步骤:

  1. 根据文件路径找到对应的模块节点(moduleGraph.getModulesByFile(file))。
  2. 遍历这些模块节点,收集所有受影响的模块(包括自己以及所有 importers 链上的模块)。
  3. 通过模块图向上追溯,找到所有依赖该模块的模块,直到没有更多依赖者为止

三、重新编译与生成更新消息

对于每个受影响的模块,Vite 调用 environment.transformRequest(url) 重新进行转换。该函数会经过完整的插件链(resolveId → load → transform),生成新的模块代码和 source map,并更新模块图中的 transformResult 缓存。

编译过程中,Vite 会记录一个时间戳(timestamp),用于客户端绕过浏览器缓存。

四、Websocket 推送消息

Vite 开发服务器内置了一个 WebSocket 服务器,用于与客户端通信。当 update 消息生成后,Vite 会通过 WebSocket 将其推送给所有已连接的客户端。

客户端:接收消息并执行更新

一、客户端初始化与 Websocket 连接

1、注入客户端脚本

当浏览器请求 index.html 时,Vite 的 indexHtmlMiddleware 会调用 clientPlugin 的 transformIndexHtml 钩子,在 HTML 中自动注入 <script type="module" src="/@vite/client">,该脚本负责建立 WebSocket 连接,暴露 HMR API。

image.png

2、建立 WebSocket 连接

客户端脚本首先会创建一个 WebSocket 连接指向开发服务器(默认地址 ws://localhost:5173)。同时,它会监听 openmessagecloseerror 等事件。

连接成功后,服务端会发送 { type: 'connected' } 消息,客户端收到后标记为就绪状态。

3、暴露 import.meta.hot API

客户端在全局维护了几个 Map 结构,用于存储每个模块注册的 HMR 回调(acceptdispose 等)。同时,它定义了一个 createHotContext 函数,该函数返回一个包含 acceptdisposeinvalidate 等方法的对象。

二、接收消息与类型分发

客户端 WebSocket 的 message 事件处理函数负责解析 JSON 消息,并根据 type 字段分发到不同的处理逻辑。

客户端 WebSocket 收到消息后,根据 type 进行处理:

  • connected, 标记就绪,可发送预热请求。
  • update:遍历 updates 数组,对每个更新执行热替换。
  • full-reload:调用 location.reload() 刷新页面。
  • prune,
  • custom,自定义事件。
  • error:在页面上显示错误覆盖层。
  • ping,不做处理。
async function handleMessage(payload: HotPayload) {
  switch (payload.type) {
    // WebSocket 和服务器握手成功,打印日志。
    case 'connected':
      console.debug(`[vite] connected.`)
      break
    // JS/CSS 热更新
    case 'update':
      // 通知所有插件 / 监听:马上要热更新了
      // 用于在热更新前执行自定义逻辑,例如刷新页面
      await hmrClient.notifyListeners('vite:beforeUpdate', payload)
      if (hasDocument) {
        // if this is the first update and there's already an error overlay, it
        // means the page opened with existing server compile error and the whole
        // module script failed to load (since one of the nested imports is 500).
        // in this case a normal update won't work and a full reload is needed.
        // 首次更新容错 + 清理错误
        if (isFirstUpdate && hasErrorOverlay()) {
          // 如果页面一打开就报错(编译失败),第一次热更新直接全页刷新,确保能正常运行
          location.reload() // 刚打开页面就报错,直接刷新修复
          return
        } else {
          if (enableOverlay) {
            clearErrorOverlay() // 清空之前的报错
          }
          isFirstUpdate = false
        }
      }
      // 所有文件更新并行处理,速度极快
      await Promise.all(
        payload.updates.map(async (update): Promise<void> => {
          if (update.type === 'js-update') {
            return hmrClient.queueUpdate(update) // 交给核心引擎更新JS
          }

          // css-update
          // this is only sent when a css file referenced with <link> is updated
          const { path, timestamp } = update
          const searchUrl = cleanUrl(path)
          // can't use querySelector with `[href*=]` here since the link may be
          // using relative paths so we need to use link.href to grab the full
          // URL for the include check.
          // 找到页面对应的旧 <link> 标签
          // 页面 <link href="style.css"> 是相对路径
          // e.href 会返回 http://localhost:5173/src/style.css 完整 URL
          const el = Array.from(
            document.querySelectorAll<HTMLLinkElement>('link'),
          ).find(
            (e) =>
              !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl),
          )

          if (!el) {
            return
          }

          // 拼接带时间戳的新 CSS 路径
          const newPath = `${base}${searchUrl.slice(1)}${
            searchUrl.includes('?') ? '&' : '?'
          }t=${timestamp}`

          // rather than swapping the href on the existing tag, we will
          // create a new link tag. Once the new stylesheet has loaded we
          // will remove the existing link tag. This removes a Flash Of
          // Unstyled Content that can occur when swapping out the tag href
          // directly, as the new stylesheet has not yet been loaded.
          return new Promise((resolve) => {
            // 克隆新 link 标签,不直接改旧 href
            const newLinkTag = el.cloneNode() as HTMLLinkElement
            newLinkTag.href = new URL(newPath, el.href).href
            const removeOldEl = () => {
              el.remove()
              console.debug(`[vite] css hot updated: ${searchUrl}`)
              resolve()
            }
            // 等新 CSS 加载完成后,再删除旧标签
            newLinkTag.addEventListener('load', removeOldEl)
            newLinkTag.addEventListener('error', removeOldEl)
            // 缓存新标签,避免重复删除
            outdatedLinkTags.add(el)
            // 插入新标签到旧标签后面
            el.after(newLinkTag)
          })
        }),
      )
      // 触发更新完成事件
      // 通知插件 / 框架:热更新完成
      await hmrClient.notifyListeners('vite:afterUpdate', payload)
      break
    //  处理 custom 自定义消息
    case 'custom': {
      await hmrClient.notifyListeners(payload.event, payload.data)

      if (payload.event === 'vite:ws:disconnect') {
        // dom环境,且页面未卸载
        if (hasDocument && !willUnload) {
          console.log(`[vite] server connection lost. Polling for restart...`)
          const socket = payload.data.webSocket as WebSocket
          const url = new URL(socket.url)
          url.search = '' // remove query string including `token`
          await waitForSuccessfulPing(url.href) // 轮询等待服务器重启
          location.reload() // 服务器回来后,自动刷新页面
        }
      }
      break
    }
    // 处理 full-reload 全页刷新
    case 'full-reload':
      await hmrClient.notifyListeners('vite:beforeFullReload', payload)
      if (hasDocument) {
        if (payload.path && payload.path.endsWith('.html')) {
          // if html file is edited, only reload the page if the browser is
          // currently on that page.
          const pagePath = decodeURI(location.pathname)
          const payloadPath = base + payload.path.slice(1)
          if (
            pagePath === payloadPath ||
            payload.path === '/index.html' ||
            (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
          ) {
            pageReload()
          }
          return
        } else {
          pageReload()
        }
      }
      break
    //  处理 prune 清理模块
    case 'prune':
      await hmrClient.notifyListeners('vite:beforePrune', payload)
      await hmrClient.prunePaths(payload.paths)
      break
    // 显示红色错误遮罩
    case 'error': {
      await hmrClient.notifyListeners('vite:error', payload)
      if (hasDocument) {
        const err = payload.err
        if (enableOverlay) {
          createErrorOverlay(err)
        } else {
          console.error(
            `[vite] Internal Server Error\n${err.message}\n${err.stack}`,
          )
        }
      }
      break
    }
    // 处理 ping 消息,心跳检测,不处理任何逻辑
    case 'ping': // noop
      break
    // 处理默认情况
    default: {
      const check: never = payload
      return check
    }
  }
}

三、处理 update 消息(热更新)

  1. 请求新模块(带时间戳),每个 update 对象包含 pathacceptedPathtimestamp 等字段。客户端构造新的 UR,利用 ?t=timestamp 强制绕过浏览器缓存。使用动态 import() 获取模块的导出对象。
  2. 执行 dispose 回调(清理旧资源),在替换模块之前,需要先执行旧模块注册的 dispose 回调(如果有),以便清理定时器、事件监听等。
  3. 找到接受更新的模块,
  4. 针对css处理。如果 update.type === 'css-update',客户端不会通过 import() 请求,而是直接替换页面中的 <link> 或 <style> 标签。
  5. 失败回退(full-reload),如果更新过程中发生错误(例如网络请求失败、回调抛出异常),或者找不到任何接受回调,客户端会发送 full-reload 指令,刷新整个页面以确保应用状态正确。

image.png

image.png

客户端执行 js-update

importUpdatedModule 是 Vite HMR 的模块更新加载器:拼接带时间戳的最新 URL,动态加载新代码 ,循环依赖异常时自动刷新。

  // 普通 ESM 模式
  // 动态加载最新的模块代码 → 解决浏览器缓存 → 处理循环依赖错误
  async function importUpdatedModule({
    acceptedPath, // 要更新的模块路径
    timestamp, // 模块更新时间戳
    explicitImportRequired, // 是否显式导入
    isWithinCircularImport, // 是否在循环依赖里
  }) {
    // 拆分路径
    const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
    const importPromise = import(
      /* @vite-ignore */ // 告诉 vite 不解析这个动态导入,由浏览器负责加载
      base +
      // 移除前导斜杠,确保路径正确
        acceptedPathWithoutQuery.slice(1) +
        // timestamp 用于刷新浏览器缓存,确保加载最新代码
        `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
          query ? `&${query}` : ''
        }`
    )
    if (isWithinCircularImport) {
      // 循环依赖, 热更失败 → 自动刷新页面
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    // 返回模块
    return await importPromise
  }

importUpdatedModule 负责用原生 ESM 加载最新代码,通知 Rolldown 运行时更新模块导出,循环依赖异常自动刷新页面。

  // 打包开发模式(bundledDev)
  async function importUpdatedModule({
    url,
    acceptedPath,
    isWithinCircularImport, // 是否在循环依赖里
  }) {
    // 加载新代码,并通知 Rolldown 运行时更新模块
    // import(base + url!) 浏览器原生 ESM 动态导入
    // 浏览器发起网络请求 → 访问 Vite 开发服务器
    // url 已带时间戳 → 强制不缓存,加载最新版
    const importPromise = import(base + url!).then(() =>
      // @ts-expect-error globalThis.__rolldown_runtime__
      // 全局运行时.loadExports
      // __rolldown_runtime__:Rolldown 运行时(Vite 新一代底层打包 / 运行核心)
      // loadExports(acceptedPath)
        // → 告诉运行时:重新收集这个模块的最新导出
        // → 运行时会自动更新所有引用该模块的地方
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    // 循环依赖容错
    if (isWithinCircularImport) {
      // 热更失败 → 自动刷新页面
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
  }

更新文件

  /**
   * 处理 HMR 更新
   * @param type 文件操作类型
   * @param file 文件路径
   */
  const onHMRUpdate = async (
    type: 'create' | 'delete' | 'update',
    file: string,
  ) => {
    // 如果 HMR 已启用,则处理 HMR 更新
    if (serverConfig.hmr !== false) {
      await handleHMRUpdate(type, file, server)
    }
  }

新增文件/删除文件


  /**
   * 处理文件添加或删除
   * @param file 文件路径
   * @param isUnlink 是否删除文件
   */
  const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
    file = normalizePath(file)
    // 「检测文件是否为 tsconfig.json/jsconfig.json,若是则触发服务器重启」
    // 因为这类配置文件变更会影响模块解析规则,必须重启才能生效。
    reloadOnTsconfigChange(server, file)

    await Promise.all(
      // 通知所有环境的插件容器,同步文件变更事件
      Object.values(server.environments).map((environment) =>
        // 对每个环境,调用其插件容器的 watchChange 方法
        // 传递文件路径和事件类型('delete' 或 'create')
        environment.pluginContainer.watchChange(file, {
          event: isUnlink ? 'delete' : 'create',
        }),
      ),
    )

    if (publicDir && publicFiles) {
      if (file.startsWith(publicDir)) {
        const path = file.slice(publicDir.length)
        publicFiles[isUnlink ? 'delete' : 'add'](path)

        // 新增文件时:清理同名模块的 ETag 缓存,保证公共文件优先响应
        // Vite 会为模块生成 ETag(实体标签),用于「ETag 快速路径」—— 客户端请求时,若 ETag 未变,直接返回缓存的模块内容
        if (!isUnlink) {
          // 获取客户端环境的模块图实例
          const clientModuleGraph = server.environments.client.moduleGraph
          // 根据路径 path(如 /image.png)查找模块图中是否存在同名模块
          const moduleWithSamePath =
            await clientModuleGraph.getModuleByUrl(path)

          const etag = moduleWithSamePath?.transformResult?.etag

          // 如果有etag ,则删除。
          // 保证 public 下文件等优先级
          if (etag) {
            // The public file should win on the next request over a module with the
            // same path. Prevent the transform etag fast path from serving the module
            clientModuleGraph.etagToModuleMap.delete(etag)
          }
        }
      }
    }
    // 文件删除时,清理模块依赖图缓存
    if (isUnlink) {
      // invalidate module graph cache on file change
      for (const environment of Object.values(server.environments)) {
        environment.moduleGraph.onFileDelete(file)
      }
    }
    // 触发 HMR 更新,同步变更到客户端
    await onHMRUpdate(isUnlink ? 'delete' : 'create', file)
  }

禁止热更新

  server: {
    ws: false,
  }

修改文件浏览器内容不会自动更新。

image.png

image.png

image.png

重启服务器

什么场景会触发开发服务器重启?

  1. 修改 vite.config.js 配置文件。
  2. 依赖文件修改,如 package.json
  3. 创建/修改 .env 环境文件。
  4. 插件中调用 server.restart

image.png

  // 配置文件、配置文件依赖、环境文件变化时,自动重启服务器
  if (isConfig || isConfigDependency || isEnv) {
    // auto restart server
    debugHmr?.(`[config change] ${colors.dim(shortFile)}`)

    // 打印日志
    config.logger.info(
      colors.green(
        `${normalizePath(
          path.relative(process.cwd(), file),
        )} changed, restarting server...`,
      ),
      { clear: true, timestamp: true },
    )
    try {
      // 重启服务器
      await restartServerWithUrls(server)
    } catch (e) {
      config.logger.error(colors.red(e))
    }
    return
  }

server.restart

重启服务器前,会先关闭服务器(包含 停止HTTP服务,停止Websocket 服务,关闭文件监听,关闭所有环境的 DevEnvironment 实例,释放模块图、插件容器、依赖优化器等资源)。

// 重启 Vite 开发服务器,同时处理并发重启请求,确保同一时间只有一个重启操作在执行。
async restart(forceOptimize?: boolean) {
  // 如果没有重启 Promise,创建一个
  if (!server._restartPromise) {
    // 设置是否强制优化依赖
    server._forceOptimizeOnRestart = !!forceOptimize
    // 重启服务器
    server._restartPromise = restartServer(server).finally(() => {
      // 重启完成后,重置重启 Promise 和强制优化依赖
      server._restartPromise = null
      server._forceOptimizeOnRestart = false
    })
  }
  // 如果存在,说明已经有一个重启操作在进行中,直接返回该 Promise
  return server._restartPromise
},

全量更新

什么场景会触发全量刷新?

  1. 修改index.html文件。
  2. 修改main.ts文件。
  3. 修改路由配置 router/index.ts 文件。

image.png

image.png

{
    "type": "full-reload",
    "triggeredBy": "/Users/xxxxxx/Documents/code/cloudcode/vue3-vite-cube/src/common/utils.ts",
    "path": "*"
}
  // (dev only) the client itself cannot be hot updated.
  // Vite 客户端自身文件变更 → 不能热更 → 必须整页刷新
  if (file.startsWith(withTrailingSlash(normalizedClientDir))) {
    environments.forEach(({ hot }) =>
      hot.send({
        type: 'full-reload',
        path: '*',
        triggeredBy: path.resolve(config.root, file),
      }),
    )
    return
  }

最后

  1. Websoket
  2. vite server 配置项