Vite 的 HMR
Vite 为什么快?
Vite 的速度优势主要来源于其基于原生 ESM(ES Modules)的开发模式和高效的构建过程。
原生 ESM 的开发服务器
传统打包工具(如 Webpack)的问题
- Webpack 在开发模式下会将所有模块打包成一个或多个 bundle 文件,即使只修改了一个文件,也需要重新打包整个 bundle。
- 这种打包过程涉及大量的文件读取、解析、转换和写入操作,随着项目规模增加,启动时间和热更新时间会显著变慢。
Vite 的解决方案
- Vite 利用现代浏览器对 原生 ES Modules(ESM) 的支持,在开发模式下不进行打包,而是直接通过 HTTP 请求按需加载模块。
- 浏览器支持
<script type="module">
,可以动态加载 ESM 模块,Vite 的开发服务器将源码直接以 ESM 格式提供给浏览器。 - 启动速度快:
- Vite 的开发服务器启动时,只需要初始化一个简单的 HTTP 服务器,无需预先打包整个项目。
- 文件按需加载,只有访问到的模块才会被处理(例如解析和转换),大大减少了初始启动时间。
- 按需编译:
- Vite 使用 Esbuild(一个极快的 JavaScript/TS 编译器)对 TypeScript、JSX 等非标准 ESM 文件进行预编译,生成浏览器可识别的 ESM 格式。
- Esbuild 的性能比传统工具(如 Babel)快 10-100 倍,因为它是基于 Go 语言实现的,充分利用了多核 CPU。
高效的依赖预构建
问题
- 浏览器原生 ESM 不支持 CommonJS 模块(例如 require()),而许多 npm 包是以 CommonJS 格式发布的。
- 如果直接在浏览器中加载大量依赖,会导致大量的 HTTP 请求(例如一个依赖树可能触发数百个请求),影响性能。
Vite 的解决方案
- Vite 在第一次启动时会对项目中的第三方依赖进行 预构建(Pre-bundling)。
使用 Esbuild 将依赖从 CommonJS 或 UMD 格式转换为 ESM 格式,并将多个小模块合并为一个或几个较大的 ESM 文件。 - 好处:
- 减少 HTTP 请求数量,提高加载性能。
- 缓存预构建结果,后续启动无需重复处理。
- 缓存机制:
- 预构建结果存储在
node_modules/.vite
目录中,只有当依赖发生变化(例如运行npm install
)时才会重新构建。
- 预构建结果存储在
按需加载和缓存
- Vite 的开发服务器会对每个模块的请求添加缓存头(如
Cache-Control: max-age=31536000, immutable
),浏览器会缓存已加载的模块。 - 当模块内容未发生变化时,浏览器直接使用缓存,无需重新请求服务器,减少了网络开销。
- 只有修改过的模块才会重新编译和加载,进一步提升热更新速度。
生产环境的 Rollup 打包
- 开发模式:Vite 不打包,依赖浏览器原生 ESM。
- 生产模式:Vite 使用 Rollup 进行打包。
- Rollup 是一个高效的打包工具,专注于 Tree Shaking(移除未使用的代码)和生成更小的 bundle。
- Vite 在生产模式下利用 Rollup 的成熟生态和优化能力,确保生成高效的生产代码。
- 为什么不用 Esbuild 打包?:
- Esbuild 的打包功能不如 Rollup 成熟,尤其是在代码分割(Code Splitting)和 Tree Shaking 方面。
- Vite 选择在开发模式中使用 Esbuild 追求速度,在生产模式中使用 Rollup 追求质量。
轻量级设计
- Vite 的核心功能高度聚焦,避免了不必要的复杂性。
- 插件系统简洁高效,支持与 Rollup 插件兼容,同时提供了 Vite 专用的插件 API。
- 相比 Webpack 的复杂配置和庞大的生态,Vite 的轻量级设计减少了性能开销。
热更新(HMR)是如何实现的?
Vite 的热更新(Hot Module Replacement, HMR)是其开发体验的核心优势之一。以下是 HMR 的实现原理
基于 WebSocket 的通信
- 初始化:
- Vite 开发服务器启动时,会创建一个 WebSocket 服务器(默认端口与 HTTP 服务器相同)。
- 客户端通过注入的脚本(
/@vite/client
)与服务器建立 WebSocket 连接。
- 事件通知:
- 当文件发生变化时,Vite 的文件监听系统(基于
chokidar
)检测到更改。 - 服务器通过 WebSocket 向客户端发送更新事件,包含变化的模块信息。
- 当文件发生变化时,Vite 的文件监听系统(基于
模块依赖图
- Vite 在开发服务器启动时,会构建一个 模块依赖图(Module Graph):
- 记录每个模块的导入关系(例如
import A from './A'
)。 - 跟踪模块之间的依赖链。
- 记录每个模块的导入关系(例如
- 当某个模块发生变化时,Vite 根据依赖图找到所有受影响的模块。
按需更新
- 模块更新:
- 当一个模块(例如
A.js
)发生变化时,Vite 只重新编译这个模块,并通过 WebSocket 通知客户端。 - 客户端接收到更新后,请求新的模块内容(例如
http://localhost:5173/A.js
)。
- 当一个模块(例如
- HMR 的实现:
- Vite 在客户端注入了一个 HMR 运行时(
/@vite/client
),负责处理模块更新。 - 当收到更新事件时,HMR 运行时会调用
import.meta.hot.accept()
提供的回调,动态替换旧模块。
- Vite 在客户端注入了一个 HMR 运行时(
React Fast Refresh
对于 React 项目,Vite 使用 React Fast Refresh(基于 react-refresh)实现更细粒度的更新:
- 原理:
- React Fast Refresh 在编译时为每个组件注入 HMR 逻辑。
- 当组件代码发生变化时,只更新变化的部分(例如函数组件的定义),而不会丢失状态。
- 实现:
- Vite 的插件
@vitejs/plugin-react
集成了 React Fast Refresh。 - 当
.jsx
或.tsx
文件变化时,Vite 编译新代码,并通过 HMR 运行时通知 React 刷新特定组件。
- Vite 的插件
- 优势:
- 保留组件的本地状态(例如
useState
和useRef
的值)。 - 只重新渲染受影响的组件,而不是整个应用。
- 保留组件的本地状态(例如
边界处理
- HMR 边界:
- 如果一个模块没有显式接受 HMR(即没有
import.meta.hot.accept()
),Vite 会沿着依赖链向上找到最近的 HMR 边界。 - 如果没有边界,Vite 会触发全页面刷新(Full Reload)。
- 如果一个模块没有显式接受 HMR(即没有
- 全页面刷新:
- 当非模块文件(例如 CSS 文件)或无法热更新的模块(例如 JSON 文件)发生变化时,Vite 会触发全页面刷新。
- 对于 CSS 文件,Vite 使用一种特殊的 HMR 方式,通过动态更新
<style>
标签实现快速刷新。
性能优化
- Esbuild 的作用:
- Vite 使用 Esbuild 快速编译变化的模块(例如 TypeScript 或 JSX),生成新的 ESM 代码。
- 编译速度极快,通常在毫秒级别完成。
- 增量更新:
- Vite 只处理变化的模块及其直接依赖,不会重新编译整个项目。
- 浏览器缓存:
- 未变化的模块直接使用浏览器缓存,减少网络请求。
总结
- Vite 为什么快?
- 原生 ESM:开发模式下不打包,直接利用浏览器支持的 ESM 按需加载模块,启动速度快。
- Esbuild 预编译:使用 Esbuild 快速编译 TypeScript/JSX 和依赖,性能远超传统工具。
- 依赖预构建:将第三方依赖转换为 ESM 并合并,减少 HTTP 请求。
- 按需加载和缓存:只编译和加载需要的模块,充分利用浏览器缓存。
- 生产环境 Rollup:在生产模式下使用 Rollup 优化打包结果。
- 热更新(HMR)如何实现?
- WebSocket 通信:服务器通过 WebSocket 通知客户端文件变化。
- 模块依赖图:跟踪模块依赖关系,确定受影响的模块。
- 按需更新:只编译和加载变化的模块,客户端动态替换。
- React Fast Refresh:集成 React 特定优化,保留状态并只刷新受影响的组件。
- 边界处理:处理无法热更新的情况,必要时触发全页面刷新。
- 性能优化:利用 Esbuild 和增量更新机制,确保 HMR 快速高效。
为什么选择 Vite?
- 开发体验:快速启动和热更新,显著提升开发效率。
- 现代性:基于 ESM,符合未来的 Web 开发趋势。
- 简单性:配置简单,插件生态强大,适合中小型项目。