Skip to content
Vite 工作原理:ES modules、依赖预构建与 HMR
概述
Vite 的开发服务器直接利用浏览器原生支持的 ES modules,不再在启动前对源代码做全量打包。依赖预构建与模块热替换(HMR)则分别解决了网络请求瀑布和快速反馈窗口的问题,三者共同构成了 Vite 开发体验的基础。本篇解读这三个机制的工作方式,以及它们在请求链路中的协同过程。
基本概念
原生 ES modules
浏览器通过 <script type="module"> 加载 JavaScript 模块时,会依据 import 语句继续请求其他模块——只有用到的代码才会被加载。这为开发服务器提供了一种“按需编译”的可能:服务器不需要提前把整个应用合并成一个 bundle,只需在浏览器请求某个文件时对其做即时转换并返回标准 ESM 即可。
Vite 的职责分离
Vite 将应用代码分为依赖和源码两类。依赖(例如 vue、lodash-es)变动频率低,在开发阶段被提前整合成少量 ESM 文件并强缓存;源码(项目中的 .vue、.ts、.jsx 等)则保持原样,每次请求时才进行转换。这种分离使得项目越大,冷启动的优势越明显。
工作原理
模块请求与按需编译
浏览器首先请求入口模块(如 /src/main.js)。Vite 启动一个基于 connect 的 HTTP 服务器,该服务器拦截所有请求,对文件进行转换后返回。
http
浏览器请求 /src/main.js
→ Vite 读取文件、重写裸模块导入、转换 TypeScript/JSX
→ 返回 ESM 格式响应
→ 浏览器解析到新 import,继续请求下一批模块以下面的入口文件为例:
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')当请求 /main.js 时,Vite 会将 ./App.vue 重写为类似 /src/App.vue?t=xxxxxxxx 的路径,浏览器会顺藤摸瓜继续发起请求。整个过程中,只有被 import 的文件才会进入编译管道。
裸模块导入重写与依赖缓存
浏览器无法识别 import { ref } from 'vue' 这种裸模块说明符。Vite 在扫描源文件时,会检测所有裸模块,并将其替换为指向预构建缓存目录的完整 URL,同时添加版本查询参数。
js
// 转换前
import { ref } from 'vue'
import debounce from 'lodash/debounce'
// 转换后
import { ref } from '/node_modules/.vite/deps/vue.js?v=1a2b3c'
import debounce from '/node_modules/.vite/deps/lodash_debounce.js?v=1a2b3c'v 参数的值取自预构建的哈希元数据。这些请求的响应头包含强缓存指令:
Cache-Control: max-age=31536000,immutable只要依赖版本不变,浏览器就使用本地缓存,不再发起网络请求。缓存有效性通过 node_modules/.vite/deps/_metadata.json 记录的哈希进行判断。当 package-lock.json 或某个依赖的版本发生变化导致哈希值改变时,下一次启动 dev server 会自动重新执行预构建。
依赖预构建
如果直接将许多依赖包中的数百个模块以 ESM 形式暴露给浏览器,就会引发“请求瀑布”:浏览器需要下载并解析当前模块后才知道下一个依赖是什么,这在网络延迟高时尤其明显。
Vite 在启动 dev server 时,会先用 esbuild 将被依赖的包内部的小模块合并成单个或少数几个 ESM 文件,输出到 node_modules/.vite/deps/。以 lodash-es 为例,预构建后浏览器只需要发起一次请求即可获得全部功能。
预构建过程简述:
- 扫描入口文件,递归收集
node_modules中的依赖名称。 - 对每个依赖,将它的多个内部模块打包为一个 ESM 文件,同时完成 CommonJS/UMD → ESM 的格式转换。
- 写入
_metadata.json,记录文件内容哈希。
预构建在首次启动 vite 时自动执行。若依赖或配置发生变更后缓存未自动刷新,可以手动强制执行:
bash
npx vite --forceesbuild 的定位与工具差异
esbuild 用 Go 编写,打包和转译速度远快于传统 JavaScript 打包器。Vite 只在开发阶段的依赖预构建和部分单文件转译(如剥离 TS 类型、JSX 转换)中使用 esbuild。生产构建则由 Rollup 接管,通过 vite build 完成模块合并、tree-shaking 和 chunk 拆分。两者的插件接口并不完全等价,因此开发阶段可用的插件在生产构建中可能失效。
此外需要注意,Vite 在不同版本中依赖预构建的实现有所变化:早期版本完全依赖 esbuild,而较新的版本已引入或正在迁移到 Rust 编写的 Rolldown 作为预构建工具。本节描述的原理(按需合并、生成缓存、格式转换等)是共通的,但具体执行器请参照你所使用的 Vite 版本及其文档。
资源转换中间件
Vite 将常见的非 JS 资源转换为浏览器可以直接执行的 ESM 模块。
CSS
导入 .css 文件时,Vite 将其包装成一个带有副作用的模块——动态创建 <style> 标签并注入样式内容:
js
// 请求 style.css,内容为:h1 { color: red }
// Vite 返回:
const css = "h1 { color: red }\n"
const style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.textContent = css
document.head.appendChild(style)
export default css若文件名使用 .module.css,则导出的会是 CSS Modules 类名映射对象。
JSON
JSON 文件直接作为默认导出对象:
js
// data.json 内容:{ "version": "1.0.0" }
// Vite 返回:
export default { "version": "1.0.0" }如此一来 import data from './data.json' 就能直接拿到解析好的对象。
TypeScript.ts 文件由 esbuild 剥离类型标注后转换为 JavaScript。这里不进行类型检查——类型检查需要完整的模块图信息,会破坏按需编译的速度优势。因此 Vite 建议在 IDE 中依靠语言服务获取错误提示,或者另起 tsc --noEmit --watch 单独做检查。
JSX / TSX
对于 React 或 Vue JSX,Vite 通过对应插件(如 @vitejs/plugin-react)利用 esbuild 将 JSX 转换为 React.createElement 或 h() 调用。Preact 的项目则通过 @prefresh/vite 获取内置的 HMR 集成和 JSX 转换。
以上转换在服务器内部体现为各自独立的中间件函数,互不阻塞,覆盖了绝大多数常见资源类型。
HMR 原理
HMR(Hot Module Replacement,模块热替换)让文件改动直接反映到浏览器中,不需要刷新页面。
服务端:文件监听、编译与 WebSocket 推送
Vite 使用 chokidar 监视项目文件。当文件变更发生时:
- 定位受影响的模块,并检查模块链中是否注册了 HMR 接收器(accept)。
- 如果存在接收器,重新编译该文件及其可能受影响的父模块。
- 通过内置 WebSocket 向浏览器推送更新消息,消息结构类似于:
{ type: 'update', updates: [{ type: 'js-update', path: '/src/components/Counter.vue', acceptedPath: '/src/App.vue', timestamp: 1692000000000 }] } - 浏览器客户端根据消息执行模块替换。
如果变更的模块链上没有找到任何 accept,更新请求会沿着依赖图向上传播直至入口模块,最终触发 full reload(即浏览器直接 location.reload())。Vue 单文件组件等框架已内建了完备的 HMR 接收器,开发者通常感知不到这一决策过程。
客户端:依赖图与更新边界
浏览器侧运行着一份小型 WebSocket 客户端,与 Vite 服务端维持长连接。模块加载后,客户端维护一个有向依赖图(module graph),记录每个模块被哪些模块所引用。
收到更新列表后,客户端的处理逻辑为:
- 标记失效模块,沿着依赖图向上查找最近的
accept边界。 - 重新执行边界模块,并用新模块值替换旧值。如果边界模块注册了
import.meta.hot.accept回调,新模块会作为参数传入,便于自定义更新逻辑。
整个过程中,运行中的模块树并未完全卸载,仅将需要更新的部分“热插拔”了一下,其他模块仍持有最新的引用。
import.meta.hot 接口
Vite 向每个模块注入 import.meta.hot 对象,提供编程级别的 HMR 控制。
自我接受(self-accept)
模块声明自己可以处理自身的热更新:
js
// counter.js
let count = 0
export function increment() { count++ }
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// newModule 提供新的导出,此处可按需替换
})
}当 self-accept 模块被修改后,只有该模块重新执行,不会影响依赖它的父模块。
接受依赖更新
也可以在一个模块中监听其他依赖的变化,并在回调中做出反应:
js
import { store } from './store'
import mutations from './mutations'
import modules from './modules'
if (import.meta.hot) {
import.meta.hot.accept(
['./mutations', './modules'],
([newMutations, newModules]) => {
store.hotUpdate({
mutations: newMutations.default,
modules: { a: newModules.default }
})
}
)
}这种模式正是 Vuex 热重载的实现方式:mutations 或 modules 文件变动时,重新注册最新定义,而 state 得以保留,页面不会刷新。如果模块明确不支持热更新,可以使用 import.meta.hot.decline() 告知 HMR 运行时,变化时转为 full reload。
示例:通过 Network 面板观察模块加载与热更新
启动一个 Vite + Vue 项目,打开 Chrome DevTools 的 Network 面板并勾选 “Preserve log”。
- 初次加载时,会看到一连串模块请求:
/src/main.js、/src/App.vue、/node_modules/.vite/deps/vue.js?v=xxx,以及若干带?vue&type=style的 CSS 请求。这些请求几乎同时发出,且单个响应体积都很小——通常只有几 KB。 - 保持页面打开,修改
App.vue中的 template 文本并保存。 - 观察 Network 面板:
- 出现一个 WebSocket 帧(类型 WS),展开后可见服务端推送的
{"type":"update","updates":[...]}数据。 - 如果更新走的是 self-accept 路径,可能不会向服务器发起新的模块请求,客户端直接执行新代码。
- 如果模块路径或依赖关系发生变化,可能会看到对
/src/App.vue?t=xxxx或某个 CSS 文件的重新请求。
- 出现一个 WebSocket 帧(类型 WS),展开后可见服务端推送的
- 页面内容已更新,但浏览器标签页没有出现刷新图标,输入框内的文字也得以保留。若是 full reload,则会重新请求
main.js等入口文件,页面闪动后状态重置。
这个例子直接体现了按需编译的特征:冷启动时只加载当前页面必需的模块,而运行时变更只产生极少量通信和模块替换。
注意点
模块请求数量与部署
原生 ESM 开发模式会造成大量小模块请求,在本地开发环境延迟极低,无实际影响。但该模式不宜直接用于线上——嵌套导入带来的网络往返会明显拖慢首屏加载,因此仍然需要通过 vite build 进行打包优化。
预构建缓存感知边界
Vite 根据 package-lock.json 和部分配置计算哈希来决定是否重新预构建。如果你使用 npm link 链接了一个本地开发的依赖,且该依赖内容频繁变动,Vite 可能察觉不到变化,此时需手动执行 --force 强制刷缓存。
非 ESM 依赖的风险
多数包可通过预构建顺利转成 ESM,但那些内部使用了动态 require、__dirname、多层条件加载等 Node 专用特性的包,转换后的行为可能不符合预期。优先考虑包的 ESM 分发版本,或通过插件显式处理。
TypeScript 类型检查缺失
Vite 在开发阶段只做转译,不做类型检查。如果完全依赖 Vite 而不运行 tsc --noEmit --watch,类型错误可能直到 vite build 时才暴露。
HMR 边界需自行维护
Vue 和 React 的 HMR 已被框架插件封装,但纯 JavaScript 模块或自定义 store 需要显式编写 import.meta.hot.accept,否则每次改动都会触发 full reload,丢失运行时状态。对于很少修改的模块,这是可以接受的,但频繁调整的模块最好添加接受回调。
开发与构建使用不同打包器
开发期依赖预构建和部分转译由 esbuild(或新版中的 Rolldown)完成,正式构建则依赖 Rollup。两者的插件 API 相似但不完全相同,部分开发期生效的插件在构建时可能失效,配置时需要分开验证。
