Skip to content
webpack 与 Vite 的开发服务器与 HMR 原理
概述
修改源码后,浏览器能立刻反映出变化,是开发服务器与模块热替换(HMR)在背后协作的结果。webpack 和 Vite 对这一过程的实现走了两条不同的路:一条是启动时完整构建,然后通过 JSONP 拉取补丁;另一条是启动时只起服务器,让浏览器用原生的 ESM 请求按需提供模块。理解这两种模型,就能判断冷启动耗时、热更新延迟这些日常体感的差异来自哪里。开发服务器的启动机制、静态资源服务方式、HMR 的底层通信与模块替换流程是其中的关键,同时 Vite 中的依赖预构建和缓存策略也直接影响开发体验。
开发服务器启动方式
webpack-dev-server:一次完整构建后启动
webpack-dev-server 启动时会调用 webpack 进行一次完整打包,把所有入口和依赖解析、loader 转换、代码合并与分块全部做完,并将结果保存在内存中(不落盘)。接着启动一个 Express 服务器,由 webpack-dev-middleware 接管请求,把内存中的 bundle 作为静态资源响应给浏览器。与此同时,该服务器还会和浏览器建立 WebSocket 连接,用于后续推送文件变化的通知。
常用的启动命令是 npx webpack serve,也可以在 package.json 的 scripts 中配置 "dev": "webpack serve" 后通过 npm run dev 执行。
这个过程意味着:在开发服务器可访问之前,整个应用已经被打包了一次。对于中小型项目,这个时间可以很短;当模块数量增长到几百个时,纯粹的依赖解析和代码生成就足以让启动时间超过 10 秒。
Vite:按需编译的原生 ESM 服务
Vite 的开发服务器在启动阶段不会打包应用源代码。它先启动一个基于 connect 的 HTTP 服务器,然后只做两件事:
- 使用 esbuild 对
node_modules中的依赖进行预构建(pre-bundle),将那些没有提供 ESM 格式或有大量内部模块的包,转换为单个 ESM 文件,并缓存到node_modules/.vite目录。 - 拦截浏览器发出的请求,对
.ts、.vue、.css等文件进行即时编译,返回浏览器可运行的 ESM 代码。
启动 Vite 开发服务器只需执行 npx vite,如果项目中没有配置文件,Vite 会自动应用默认设置。
浏览器的第一个请求是入口 HTML,接着它根据其中的 <script type="module" src="/src/main.ts"> 发起原生的 ESM 请求。Vite 在服务端对这些请求进行拦截、转译、重写裸模块导入(import vue from 'vue' → import vue from '/@modules/vue'),然后直接返回结果,不提前打包任何应用模块。
静态资源请求的处理
webpack 的 loader 转换与内存输出
当浏览器请求一个 JS 文件时,webpack-dev-server 并不是按原始文件目录结构提供资源,而是从内存中取出已被 webpack 打包处理过的 bundle。对于 CSS,如果配置了 css-loader 和 style-loader,webpack 会将 CSS 转换成一段 JS 模块,该模块在运行时动态创建 <style> 标签并把样式注入页面。图片等文件则会经由 file-loader 或 url-loader 处理,输出路径会被替换为数据 URI 或对应的静态资源请求路径。
浏览器因此只需加载少数几个 bundle 文件,但请求到的文件并非源码本身,而是经过 loader 处理甚至合并过的产物。开发时的调试依赖 source map 映射回原始文件。
Vite 的实时转化与预构建缓存
Vite 的服务器会根据请求路径的后缀决定如何处理文件。对于一个 .vue 文件的请求,Vite 会使用 @vitejs/plugin-vue 把它编译成包含 <template>、<script> 和 <style> 的 ESM 模块,再返回给浏览器。整个过程是实时的,并且利用了 esbuild 的高速转译能力。
对于裸模块,Vite 会用预构建好的缓存文件响应。例如项目中写了 import { ref } from 'vue',Vite 会把 vue 的导入重定向到 /@modules/vue,然后返回 node_modules/.vite/vue.js 这个已经转成 ESM 的文件。预构建的结果会被缓存,只有当依赖版本变化(依据 package-lock.json 等的哈希值)时才会重新执行。
Vite 在转化模块的同时维护一张模块图(importeeMap),记录“谁 import 了谁”。这个结构是后续 HMR 能够精准定位影响范围的基础。
模块热替换(HMR)工作流
WebSocket 通信与更新通知
两者都使用 WebSocket 实现服务端到客户端的推送,但通知的内容和时机不一样。
webpack-dev-server 在监听到文件变化后,触发一次增量构建(只重新编译变更模块)。构建结束后,服务器通过 WebSocket 向客户端推送一个 hash 事件,携带本次构建的哈希值。客户端根据这个哈希拼接出 [hash].hot-update.json 的 URL,准备拉取更新清单。
Vite 的 dev server 在文件变化时,只会重新编译该文件以及依赖它的模块链。编译完成后,服务器通过 WebSocket 发送 update 消息,内容直接包含了需要更新的模块路径(或一个边界信息)。客户端拿到这个消息后就知道究竟哪个模块需要被替换,无需再向服务器询问哈希。
模块替换的基本流程
更新数据拿到手之后,HMR runtime 开始尝试替换模块。替换的核心逻辑是:将新模块的代码注入执行环境,然后用它替代旧的导出,同时让所有依赖此模块的父模块获取到新的值。
在 webpack 中,模块是否愿意“被热替换”由 module.hot.accept 声明。如果某个模块没有声明接受自身或其依赖的更新,HMR runtime 会向上冒泡到父模块,直到找到某个声明了 accept 的模块,或者到达入口。如果没有模块接受更新,则会降级为完整的页面重载(live reload)。vue-loader、react-hot-loader 等已经内置了必要的 accept 声明,开发者多数情况下不需要手动写。
Vite 因为维护了精确的依赖图,它能立即知道哪些模块受到了文件变化的影响而不需要冒泡。同时 Vue 和 React 的官方插件已经封装好对应框架的 HMR 逻辑,组件状态(尤其是 Vue 的 SFC)能够保留。
webpack 的 bundle 级热更新
JSONP 拉取 hot-update 模块
webpack 的热更新产物是两个文件,通过 JSONP 方式拉取。收到 hash 事件后,HMR runtime 会发起两个 HTTP 请求:
hash.hot-update.json
chunkname.[hash].hot-update.jshot-update.json 的内容形如:
json
{"c":{"main":true},"h":"d69324ef62c3872485a2"}c 表示哪些 chunk 发生了变化,h 是新哈希。随后加载的 hot-update.js 文件内容是:
javascript
webpackHotUpdate("main", {
"./src/test.js": function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval(/* 更新后的模块代码 */);
}
})当这个脚本被浏览器执行时,webpackHotUpdate 函数会把新模块塞进 webpack 内部的模块系统,然后将旧模块失效。
虽然 webpack 的增量构建已经避免了全量打包,但更新单位仍是 chunk 级别。一个 chunk 内只要有一个模块变化,整个 chunk 的 hot-update.js 都会被拉取。对于按路由拆分 chunk 的大型项目,更新仍在可接受范围内;但对于单一庞大入口 chunk 的情形,即使只改了一行代码,客户端的更新包也可能很大。
以下是一个手动控制 HMR 行为的示例,通常出现在没有 loader 处理的纯 JS 模块中,或用于自定义副作用管理:
javascript
if (module.hot) {
module.hot.accept('./print.js', function() {
// 当 print.js 更新后执行的逻辑
document.body.innerHTML = '';
});
}该代码声明了当前模块接受 ./print.js 的更新,并提供了一个回调函数在更新完成后执行,这里是清空页面内容,实际场景中可能用于重新挂载组件或清理副作用。
Vite 的精确模块级热更新
ESM 动态导入与依赖失效链
Vite 的 HMR 不借助 JSONP,而是直接利用浏览器原生的动态 import()。当 WebSocket 发来 update 消息后,客户端 HMR runtime 会执行以下几步:
- 根据消息里的模块路径,附加时间戳参数构造 URL,如
/src/App.vue?t=1620000000。 - 用
import(newURL)动态加载新模块。因为 ESM 天然支持,无需包装函数。 - 让新模块的导出替换旧模块,并沿着依赖图(
importeeMap)找到所有“导入者”,将它们的导入引用更新到新模块上。这一步骤称为失效(invalidation),只影响直接或间接依赖变更文件的模块链,不会处理无关模块。
更新粒度精确到单个模块,浏览器只需发一个 import 请求。配合浏览器本身的 HTTP 缓存和模块缓存,更新速度稳定在毫秒级,与项目大小基本无关。
Vite 也暴露了 import.meta.hot.accept 供手动控制:
javascript
if (import.meta.hot) {
import.meta.hot.accept('./data.js', (newModule) => {
console.log('data updated', newModule);
});
}该回调在 ./data.js 更新后被触发,接收新模块的导出对象,可用于非框架模块的状态保持或自定义更新逻辑。在实际项目中,除了框架插件和特定状态的保持逻辑外,很少需要手动编写这类代码。
依赖预构建与缓存策略
预构建如何避免瀑布请求
当一个应用使用了数十个 npm 包,且在无预构建的情况下直接以原生 ESM 加载,浏览器会为了解析依赖树发出大量级联请求(例如某个包内部又 import 了十几个自己的子模块),这就是所谓的瀑布请求。网络延迟的叠加会让首次加载极慢。
Vite 在开发服务启动时,会将源代码中引用的裸模块用 esbuild 预打包成单个 ESM 文件。以 lodash-es 为例,原本几百个内部模块会被合并成一个文件;同时 CommonJS 或 UMD 格式的库也会被转换为 ESM。这样浏览器只需加载一个请求就能得到整个包。
文件系统缓存与模块图缓存
预构建的产物存放在 node_modules/.vite 下,文件名基于依赖项的版本和配置哈希生成。Vite 通过比对 package.json 及 lock 文件的哈希来判断是否需要重新执行预构建。如果依赖没有变化,第二次启动就可以跳过预构建,启动时间进一步缩短。
模块图(importeeMap 与 importMap)是内存中的数据结构,用来在模块转换过程中记录导入导出关系。当文件变化时,服务器会从图中快速定位受影响的模块范围,直接进入精确的重编译与更新推送,而不是重新解析整个应用。
webpack 的 watch 模式同样会缓存上次构建的模块信息来实现增量编译,但因为打包模型本身要求对整个依赖图做完整分析,所以缓存的粒度受限于 bundle 体系,与 Vite 的按需模型相比,减少的工作量级有本质差异。
开发体验差异的根源
冷启动时间
webpack 必须完整构建应用才能开始响应。模块量越大,启动越慢,且这个增长速度远超线性。
Vite 只需启动一个 connect 服务加一次快速的预构建(esbuild 是 Go 编写,速度极快),然后所有源码按需编译。冷启动通常在数百毫秒到几秒之间,受项目规模影响极小。
热更新速度
webpack 的增量构建加上 JSONP 更新流程,在小项目中感觉不到明显延迟,但当一个 chunk 包含数百个模块时,即使只改了一个文件,chunk 的 hot-update 生成和传输也会花掉几百毫秒。
Vite 的更新链路只是重新编译一个文件及它的依赖链,然后通过一个动态 import 完成替换,大多数场景下更新在几十毫秒内完成,并且延迟不会随项目膨胀而显著增加。
大型项目中的表现
当模块数量过千时,webpack 的冷启动时间可能超过一分钟,HMR 也可能有明显卡顿感。内存占用也会随着模块数量上升。
Vite 在这个规模下仍然可以迅速启动,因为源码仍然是按需编译的。但需留意:如果项目中存在大量未被预构建覆盖的动态导入(例如通过 import() 传入变量的情况),Vite 可能无法提前分析,需要在运行时处理额外的请求,这会在初次加载时引入额外延迟。这类情况可以手动配置 optimizeDeps.include 来提前构建。
注意点
- 浏览器兼容性:Vite 开发服务器要求浏览器原生支持 ESM,这意味着不能用于 IE 等旧浏览器的开发场景。生产构建会进行兼容性降级,所以不影响线上部署。
- 预构建边界:Vite 自动分析依赖的方式基于静态扫描
import声明,动态import(变量)可能被遗漏,需通过optimizeDeps.include手动指定。 - 开发与生产的不一致:Vite 开发使用 esbuild 编译,生产使用 Rollup 打包,两者在某些边界情况的处理上存在微小差异(如 tree shaking 行为或特定插件支持),不过通常情况下不影响开发体验。
- webpack 的 HMR 降级:如果模块树中没有任何
accept声明,修改一个深层模块可能导致整个页面刷新。这一点在引入未提供 HMR 支持的第三方库时偶尔会表现出来。 - webpack-dev-server 使用 Express,Vite 使用 connect,二者在内置功能和中间件机制上有所不同。在需要自定义中间件时需参照各自 API,不能跨框架复用。
