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 在开发服务器启动时,会构建一个 模块依赖图(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() 提供的回调,动态替换旧模块。

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 刷新特定组件。
  • 优势:
    • 保留组件的本地状态(例如 useStateuseRef 的值)。
    • 只重新渲染受影响的组件,而不是整个应用。

边界处理

  • HMR 边界:
    • 如果一个模块没有显式接受 HMR(即没有 import.meta.hot.accept()),Vite 会沿着依赖链向上找到最近的 HMR 边界。
    • 如果没有边界,Vite 会触发全页面刷新(Full Reload)。
  • 全页面刷新:
    • 当非模块文件(例如 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 开发趋势。
  • 简单性:配置简单,插件生态强大,适合中小型项目。