前置知识: Vite

Vite 开发服务器与 HMR

10 min中级

Vite dev server:server 配置、host 端口、代理、HMR 原理(模块图/WebSocket/热替换边界)与 import.meta.hot API

0. 一个类比:餐厅后厨的”尝菜”

想象你开了一家餐厅。客人点了一桌菜,如果每次厨师调整一道菜的咸淡,都要把整桌菜重新端出去,客人的体验会非常糟糕。真正的大厨是在后厨先尝一口:哪道菜咸了,只回锅重做那一道,其他菜原封不动,客人正在进行的交谈也不被打断。

Vite 开发服务器里的 HMR(Hot Module Replacement,模块热替换)干的正是这件事:

  • 你写的代码 = 后厨的菜
  • 浏览器里的页面 = 客人的餐桌
  • HMR = 后厨尝菜:哪一行代码改了,只把”那一道菜”(那一个模块)端回后厨重做,再送回去
  • 整页刷新 = 把整桌菜撤掉重上:页面状态(输入框内容、滚动位置、弹窗)全部丢失

如果你给手机换过电池,对这个概念会更有体感:换电池是”模块级替换”,手机不需要重启;而”整页刷新”相当于关机再开机。Vite 的目标,就是让你在开发时永远只”换电池”,不”关机重启”。

1. 初体验:改一行代码,页面瞬间更新

先不聊原理,动手体验一次。用《Vite 快速上手与项目结构》的方式创建一个 Vite 项目并启动:

# 创建项目(以 vanilla-ts 模板为例)
pnpm create vite my-hmr-demo --template vanilla-ts
cd my-hmr-demo
pnpm install
pnpm dev

浏览器打开 http://localhost:5173,然后修改 src/main.ts 中的任意一行文本,保存。你会看到:

终端输出:
[vite] hmr update /src/main.ts
页面表现:内容立即变化,页面没有闪烁、没有重新加载

此时打开浏览器开发者工具的 Network 面板,切到 WS(WebSocket)标签,可以看到一条条类似下面的消息:

{ "type": "update", "updates": [{ "type": "js-update", "path": "/src/main.ts" }] }

这就是 HMR 的全部”魔法”入口:文件一保存,一条 WebSocket 消息就从服务器推到了浏览器。接下来我们一层层拆开,看看消息发出前后到底发生了什么。

2. 认识 dev server:不只是”起个本地服务”

2.1 传统静态服务器 vs Vite dev server

用 python -m http.server 或 http-server 也能打开一个网页,但那是”纯静态”服务:文件是什么样就发什么样,不经过任何加工。Vite 的 dev server 是”智能加工厂”:

浏览器请求 /src/main.ts
        ↓
Vite dev server 收到请求
        ↓
按需转换(TS -> JS、JSX -> JS、处理 import 路径)
        ↓
返回浏览器可直接执行的 ESM 代码

关键点:Vite 开发环境不打包,浏览器直接以原生 ES Module 的方式按需请求每个文件。这正是它冷启动快的原因——不需要像 Webpack 那样先把整个项目的依赖图构建一遍。

2.2 依赖预构建

dev server 启动时,Vite 会做一件重要的事:把 node_modules 里的依赖(如 React、Vue)预构建成 ESM,存放在 node_modules/.vite/deps。这一步在 Vite 8 中由 Rolldown 执行(Vite 7 及以前是 esbuild)。这样浏览器请求第三方库时,得到的是转换好、扁平化的 ESM,而不是层层嵌套的 CommonJS,加载速度大幅提升。

pnpm dev 启动时的输出:
  vite v8.x.x ready in 320 ms
  Local:   http://localhost:5173/
  Network: http://192.168.1.100:5173/

2.3 冷启动与按需加载

Vite 只转换”浏览器当前真正请求到的文件”。项目有 1000 个文件,但你只打开了首页,那就只转换首页涉及的几十个文件。这就是”按需加载”:加载多少,转换多少。

3. server 配置总览

dev server 的行为全部通过 vite.config.ts 的 server 块配置:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    port: 5173,           // 指定端口,被占用时自动加 1
    strictPort: false,    // true 时端口被占用直接报错,不再自动换端口
    host: 'localhost',    // 监听地址,见第 4 节
    open: true,           // 启动后自动打开浏览器
    cors: true,           // 允许跨域访问开发资源
    https: false,         // 需要 https 时配置证书对象
    proxy: {},            // 开发期请求代理,见第 5 节
  },
})
选项默认值说明
port5173开发服务器端口,被占用时自动 +1
strictPortfalse端口被占用时是否直接报错退出
hostlocalhost监听的主机名或 IP,见第 4 节
openfalse启动后自动用默认浏览器打开页面
corstrue允许跨域请求开发资源
proxy无请求代理配置,见第 5 节

注意:vite preview(预览构建产物)使用独立的 preview 配置块,语法与 server 相同但互不影响,例如 preview.port 默认 4173。

4. host 与端口:让局域网也能访问

host 决定 dev server 监听在哪张网卡上,直接影响”别人能不能访问到你的开发页面”:

# 仅本机可访问(默认)
pnpm dev --host localhost

# 暴露到局域网,手机/同事可访问
pnpm dev --host 0.0.0.0

# 监听全部网卡,并自动打开浏览器
pnpm dev --host 0.0.0.0 --open

讲解:默认 localhost 下,同一局域网的手机访问 http://你的IP:5173 会失败。改成 0.0.0.0 后,Vite 终端会输出 Network: http://192.168.x.x:5173/,其他设备即可访问。

两个常见的附加问题:

  • HTTPS:浏览器对局域网 HTTP 环境下的敏感 API(摄像头、麦克风、蓝牙)有限制。可用 server.https 配置自签证书,或使用 @vitejs/plugin-basic-ssl 插件一键开启。
  • Node 代理:公司网络环境下 HMR 的 WebSocket 连接可能被拦截,可配置 server.hmr 的相关选项解决(见第 9 节错误表)。

5. 代理 proxy:解决开发跨域

前后端分离开发时,前端在 5173 端口,后端接口在 8080 端口,浏览器直接请求必然遇到跨域。推荐方案是开发代理而不是去改后端的 CORS:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      // 前端请求 /api/xxx -> 转发到 http://localhost:8080/xxx
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
      // 简写形式:无 rewrite 需求时直接写目标地址
      '/socket': 'ws://localhost:3000',
    },
  },
})

讲解:

  • /api 开头的请求由 dev server 转发到目标地址,浏览器看到的仍是同源请求(页面和接口都来自 5173),从而绕过跨域。
  • changeOrigin: true 会把请求头中的 Host 改为目标地址——后端按 Host 做鉴权或路由时需要开启。
  • rewrite 用于路径改写:去掉 /api 前缀、添加前缀、替换路径片段都行。
  • 代理基于 http-proxy 实现,天然支持 WebSocket(ws:// 协议)。
场景配置要点
转发 REST APItarget + changeOrigin + rewrite
转发 WebSocket(如 HMR、聊天)target 用 ws:// 协议
转发到 HTTPS 后端target 填 https 地址 + secure: false(自签证书时)
仅开发环境生效放在 server.proxy 中,构建产物不受影响

6. HMR 原理:从”尝菜”到”换菜”

6.1 三个关键角色

HMR 能成立,靠的是三个角色各司其职:

1. 文件监听器(chokidar)
   监听磁盘上的文件变化,一保存就触发

2. 模块图(ModuleGraph)
   记录"谁 import 了谁"的依赖关系,决定影响范围

3. WebSocket 通道
   服务器与浏览器之间的"对讲机",负责推送更新消息

6.2 模块图:谁依赖谁

服务器内部维护着一张”模块关系网”。Vite 用 ModuleGraph 数据结构保存四类映射:

urlToModuleMap      按请求 URL 找模块(如 "/src/main.ts?v=123")
idToModuleMap       按解析后的模块 ID 找模块(绝对路径)
fileToModulesMap    按文件路径找模块(一个文件可能产生多个模块,如 .module.css)
etagToModuleMap     按 ETag 找模块(用于协商缓存,避免重复转换)

每个模块节点(ModuleNode)记录两条方向的边:

importedModules  指向"这个模块 import 了谁"(向下依赖)
importers        指向"谁 import 了这个模块"(向上引用)

这两条边是 HMR 的核心。文件变化时,Vite 沿着 importers 向上走,寻找”愿意接受热更新”的边界;找到就只更新边界之下的模块,找不到就整页刷新。由于只向上走有限的层数,HMR 的耗时取决于模块深度(O(深度))而不是项目总模块数(O(总数)),所以项目再大也能保持即时。

6.3 热替换 vs 整页刷新:accept 边界

“能不能热替换”取决于模块是否声明了”我接受热更新”。Vite 内部用 isSelfAccepting(模块自己调用了 import.meta.hot.accept())和 acceptedHmrDeps(声明接受了哪些依赖的更新)两个标记来判断:

flowchart TD
  A["修改 counter.ts(普通模块,无 accept)"] -->|"沿 importers 向上冒泡"| B{"main.ts 调用了 import.meta.hot.accept()?"}
  B -->|"是"| C["只重新执行 main.ts 边界内的更新(热替换)"]
  B -->|"否"| D["继续向上冒泡,直到遇到边界或顶层"]
  D -->|"没有边界"| E["整页刷新(reload)"]

各类型模块的默认更新方式:

模块类型更新方式原因
CSS / SCSS样式热替换,不刷新浏览器直接替换 <link> 标签
React / Vue 组件组件级热更新,状态保留框架插件提供 Fast Refresh
普通 JS 模块(无 accept)递归更新依赖它的模块,必要时整页刷新没有声明更新边界

6.4 完整更新流程

把以上串起来,一次保存动作的完整链路是:

  1. 你保存文件
  2. chokidar 监听到文件变化
  3. 服务器在 ModuleGraph 中定位受影响的模块并使其失效
  4. 服务器沿 importers 向上寻找 accept 边界,计算出”更新范围”
  5. 服务器通过 WebSocket 推送 { type: ‘update’, updates: […] } 消息
  6. 浏览器端 @vite/client 收到消息
  7. 浏览器用 import() 以 “原路径?t=时间戳” 重新拉取模块(时间戳用于绕过浏览器缓存)
  8. 执行对应模块的更新逻辑(React Fast Refresh / Vue 重渲染 / 你的 accept 回调)
  9. 页面其余部分原封不动

注意第 7 步:浏览器重新加载模块时在 URL 后面加了时间戳参数(如 main.ts?t=1785700000000),这是为了防止浏览器缓存机制拦截到旧版本代码。

7. HMR API:import.meta.hot

框架项目里,React/Vue 插件的 HMR 是开箱即用的。但如果你在写工具函数、状态库、原生 JS 模块,想让它们也”热起来”,就需要手动接入 HMR API。

7.1 核心 API 一览

API作用
import.meta.hot.accept(deps?, cb)接受自身或指定依赖的热更新,声明”热更新边界”
import.meta.hot.dispose(cb)模块被替换前清理副作用(定时器、事件监听、全局变量)
import.meta.hot.prune(cb)模块从页面中消失(不再被任何模块引用)时清理副作用
import.meta.hot.invalidate(msg?)使当前模块失效,强制走整页刷新
import.meta.hot.data跨热更新保存数据的容器,状态在替换前后共享

7.2 完整示例一:可热更新的计数器

// counter.ts
// 需求:页面上的计数器在热更新后继续累加,而不是从 0 开始
let count = 0

// 从上一次热更新的 data 中恢复状态(首次加载时没有)
if (import.meta.hot && import.meta.hot.data.count !== undefined) {
  count = import.meta.hot.data.count
}

export function inc() {
  return ++count
}
export function getCount() {
  return count
}

// 声明:本模块接受热更新
if (import.meta.hot) {
  // 模块被替换前执行:把当前状态存进 data,留给新模块
  import.meta.hot.dispose(() => {
    import.meta.hot.data.count = count
  })

  // 新模块加载完成后的回调(可选,用于触发页面重新渲染)
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      console.log('counter.ts 已热更新,当前计数:', newModule.getCount())
    }
  })
}

讲解:dispose 里保存状态,accept 回调里重新渲染——这是手写 HMR 的标准套路。import.meta.hot.data 在旧模块与新模块之间共享同一个对象,所以状态能”接力”。

7.3 完整示例二:清理定时器防泄漏

// timer.ts
// 需求:热更新时旧的定时器必须清掉,否则会出现多个定时器叠加
let seconds = 0

const timer = setInterval(() => {
  seconds++
  console.log(`已运行 ${seconds} 秒`)
}, 1000)

if (import.meta.hot) {
  // 每次热更新前清理旧定时器,防止内存泄漏和重复输出
  import.meta.hot.dispose(() => {
    clearInterval(timer)
    console.log('旧定时器已清理')
  })
}

7.4 三个容易踩的规则

  1. accept() 必须是字面量调用。Vite 通过静态分析源码判断模块是否可热更新,import.meta.hot.accept ((带空格)或把调用包进函数再导出,都可能不被识别。
  2. hot.data 不能被重新赋值。import.meta.hot.data = {} 是无效的,应修改其属性:import.meta.hot.data.count = 1。
  3. 生产环境没有 import.meta.hot。所有 HMR 代码必须包在 if (import.meta.hot) 里,这样生产构建时能被 tree-shaking 整段删掉。

8. forwardConsole:日志转发

Vite 8 新增的 server.forwardConsole 会把浏览器控制台输出与未捕获错误转发到终端,开发调试时不用在浏览器和终端之间来回切换。它的取值是布尔值或对象,默认行为是”auto”:检测到 AI 编码代理接入时自动开启,否则关闭:

// vite.config.ts
export default defineConfig({
  server: {
    // boolean | { unhandledErrors?: boolean, logLevels?: ('error'|'warn'|'info'|'log'|'debug')[] }
    // true:转发未捕获错误 + console.error / console.warn
    // 对象:按需选择转发哪些错误与日志级别
    // false / 省略:默认关闭(检测到编码代理时自动开启)
    forwardConsole: true,
  },
})

典型场景:移动端真机调试、iframe 内日志、SSR 场景——这些情况下 DevTools 不方便打开,日志直接看终端最省事;编码代理(AI 结对)场景下 Vite 会自动开启,让代理直接”看到”浏览器报错。觉得刷屏就显式设为 false。

9. 常见错误与对策表

现象 / 报错信息常见原因解决办法
修改 vite.config.ts 或新增插件后 HMR 失灵配置文件与插件列表变更不会触发 HMR,需重启手动重启 dev server:pnpm dev
热更新变成了整页刷新(页面闪一下)修改的模块没有 accept 边界,冒泡到了顶层给模块加 import.meta.hot.accept(),或用框架插件(React/Vue)
React 组件热更新后 state 丢失缺少 @vitejs/plugin-react,无法获得 Fast Refresh安装并注册 @vitejs/plugin-react
网络面板 WS 一直报错、页面不更新代理配置把 HMR 的 WebSocket 请求拦走了代理中为 HMR 路径放行,或配置 server.hmr 的端口/协议
Failed to connect websocket 或公司网络下 HMR 失效内网拦截了 WebSocket 长连接配置 server.hmr: { protocol: 'wss' } 等,或改用 --host 直连
修改普通 .ts 工具模块后状态初始化了模块自身的顶层副作用在热更新时重新执行用 hot.data 保存状态、hot.dispose 清理旧副作用
端口被占用且 strictPort: true端口冲突换端口,或 lsof -i:5173 查占用进程后处理
代理不生效、接口 404请求没走代理前缀,或 rewrite 误删了路径确认请求路径以 /api 开头,检查 rewrite 正则

10. 一句话记忆

HMR 就是”后厨尝菜”:保存文件后,Vite 沿着模块图向上找到 accept 边界,只把改动的模块通过 WebSocket 换掉,页面状态原封不动——把整页刷新留给实在热不起来的模块。