1. 背景
在 ESM 出现之前,由于浏览器缺少 JS 模块化的机制以及页面加载性能的问题,开发者都会打包来构建 Web App。期间 Webpack 等打包工具迅速流行在社区,被广泛使用在项目中。但是,随着项目的维护项目内部的 JS 模块越来越多,这些打包工具在开发时遇到了性能瓶颈。相信大家或多或少的都有体验,在 Webpack 的大型项目中需要等很长时间才能启动 Dev Server,更新文件之后也需要经过一些时间页面才能展示出最新的更改,这非常降低了开发者的开发效率以及体验。Vite 正是为了提高开发者的开发体验而开发的工具,拥有极速的服务启动和轻量快速的热更新,2.0 发布以来越来越多的用户开始使用 Vite[1]。本文会对 Vite 进行剖析,让大家对于 Vite 更加了解,在开发使用时更加得心应手。
2. 基于 ESM 的 Dev Server + HMR
首先,我们对比一下 vite 与 webpack 的启动一个 vue hello world 时间。
服务启动耗时 (ms) | 页面加载耗时 (ms) | |
---|---|---|
Webpack | 934 | 203 |
Vite | 368 | 231 |
从上面的例子中可以看到 Webpack 启动服务的时间较长一些,但是页面加载性能是好于 vite 的。
2.1 Webpack(Bundle-Based Dev Server) 如何工作的呢?
从图中可以看到 Webpack dev server 的启动方式:
-
从 entry 开始分析依赖,bundle 依赖 (性能瓶颈),同时将入口文件注入到 index.html 中
-
启动 Webpack-dev-server,等待浏览器访问 问题很明显:
-
Dev Server 必须等待所有模块构建完成,应用越大,启动时间越长
-
分片的模块也要构建
2.2 那 Vite dev server 是如何提高性能的呢?
ESM 是 ES6 引入的模块化能力,现已经被主流浏览器支持,当 import 模块时,浏览器就会下载被导入的模块。
Vite 的 Dev server 基于浏览器原生支持 ESM 的能力实现的,因此不需要通过 Bundler 即可加载 JS 模块,但是要求用户的代码必须是 ESM 模块,而且需要在 index.html 中使用 <script type="module" src="./main.js"/>
来引入模块
从图中可以看到 Vite 的启动方式:
-
不经过 Bundle,直接启动 Dev Server
-
等待浏览器访问文件,当请求文件时进行对文件那边进行转换返回给浏览器 (性能瓶颈)
2.3 Vite dev server 避免了 Bundle 的性能问题,但是也有一些新的问题:
-
文件 transform 性能
-
模块转换时尽可能使用性能高的工具
-
缓存 transform 结果
-
-
非 ESM 模块兼容 (TS/JSX ...)
-
将非 ESM 模块转换成 ESM,依靠文件类型来辨别模块类型
-
用 esbuild 转换 TS/JSX,代替 TSC/Babel
-
-
Broswer ESM 不能加载 Node 模块
-
使用 es-module-lexer 扫描 import 语法
-
magic-string 重写 Node 模块的引入路径,如下图
-
-
Node 模块其他问题
-
Node CJS 模块兼容
-
Node 模块一般文件数量较多,如果直接加载,一个文件会产生一个请求,导致页面加载性能降低
-
2.4 为了解决 Node 模块的问题,Vite 引入 Pre-Bundle Node 模块方案:
在项目启动前,扫描项目内的所使用的 node 模块,将 node 模块打包成单个文件,这个操作是耗时的,但是由于 Node 模块有自己的版本,可以将其写入硬盘,下次启动时如果版本匹配可以跳过 Pre-Bundle 使用硬盘缓存的结果。另外,Pre-Bunde 会生成模块的元信息,通过识别引入的模块并对其进行转换,支持了 CJS 模块的 Named Import。如下图
使用工具:
-
v1: Rollup + @rollup/plugin-commonjs @rollup/plugin-commonjs 的方案是将 cjs 代码直接转为 esm,cjs 模块的导入导出方式过于动态 而且 cjs 循环引用问题(19.0.0 版本修复)导致打包成 ESM 失败
-
v2: esbuild 其支持 cjs 的方案是生成 helper 函数,兼容性好
那应该怎么识别 Node 模块进行 Pre-Bundle 呢,vite 支持了用户自己配置和自动依赖扫描的功能。自动依赖扫描是扫描用户全部代码并识别其中引入的 node 模块,由于 esbuild 的打包性能是 rollup 的 10-100 倍,性能也没有造成下降。
2.5 ESM HMR
Vite ESM HMR API 借鉴于 Webpack HMR API,当某个模块发生变化时,不用刷新页面就可以更新对应的模块。
首先看个 Vite HMR 使用的例子
import foo from './foo.js'
foo()
if (import.meta.hot) {
import.meta.hot.accept('./foo.js' ,(newFoo) => {
newFoo.foo()
})
}
转换后的结果
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/hmrDep.js");
import foo form '/foo.js'
foo()
if (import.meta.hot) {
import.meta.hot.accept("/foo.js" ,(newFoo) => {
newFoo.foo()
})
}
首先介绍一个概念 boundary 代表接受更新的模块,如例子中引入 ./foo.js 的模块
import.meta[2] 是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象元信息的方式。
可以看到通过注入 helper 函数,给模块引入了 import.meta.hot API,这个 API 会在浏览器运行时记录 boundary 与模块直接的映射关系(包含更新执行的回调函数),当 ws 接收到某个模块更新信息(boundary 和 发生更新的模块)时,会发起对更新模块的加载,并且会根据模块的更新信息从映射关系中查找到更新需要执行的回调函数,执行并传入更新后的模块。这样就可以无需刷新页面就可以更新 JS 模块了。
那 vite 的 HMR 怎么工作的呢?
-
构建模块依赖图 当一个文件请求时,Vite 会扫描其中的 import 语法,记录模块之间的依赖关系
-
同时如果发现文件引用了 import.meta.hot 时会注入 helper 函数,并且模块中含有 import.meta.hot.accept 的调用则将模块标记成 boundary
-
当文件变更时,依据模块依赖图寻找 boundaries
-
发送 websocket 消息到浏览器端,浏览器会重新加载变更模块并执行更新
-
如果没有查找到 boundaries, 页面则会重新加载
支持了 ESM Dev Server,但是并不能直接用于生产环境,为了在生产环境获得更好的加载性能,还需要 生产构建,对代码进行体积优化(tree-shaking,minify)、chunk 合并分割等等。
3. 基于 Rollup 的 Bundle 和 Plugins
由于已有的 Bundler 很成熟而且有良好的生态, vite 选择在他们的基础上进行用户代码的 Bundle。那如何选择呢?Rollup 同样基于 ESM ,而且其灵活的 Plugin API 以及体积更小、运行速度更快的构建产物显然更为合适。但是由于其对于 Web App 的支持度较低,而且配置复杂,非常不利于用户的使用。为此,Vite 内置了开发 Web App 常用的 Plugins,尽可能让用户可以零配置的使用。
-
TS/JSX
-
PostCSS / CSS Modules / CSS Pre-processors
-
Assets
-
JSON
-
Web Worker
-
Module Resolver / Module Alias / Module Glob Import
-
...
对于 Framwork 的支持,Vite 官方集成了 Vue3(@vitejs/plugin-vue[3]) 、React (@vitejs/plugin-react-refresh[4]),而且提供了开箱即用的模版供用户选择。
另外,Vite Plugins 继承了 Rollup Plugins API,并进行了一些拓展(ssr、hmr 支持等),这样用户可以利用社区内已有的 Rollup Plugins,或者开发 Plugins 满足自己不同场景的需求。由于 Dev 是以文件为单位单独 transfrom,Bundle 是以项目为单位构建,造成了 dev 和 Bundle 有一定的差异。为了减少这种差异性,Vite 受 WMR 启发,通过 PluginContainer 模拟了 Bundle 时 Plugns 的行为,支持了在 Dev 环境中运行 Plugins。
对跑在浏览器内的 Web App 进行了体验优化,那能不能更进一步对跑在 Node 中的 Web App 进行优化呢?
4. SSR
SSR 是支持在 Node.js 中运行相同应用程序的前端框架(例如 React、Vue 等),将其预渲染成 HTML 返回客户端,用于解决 SPA 的 SEO 和 首屏性能问题。
和开发 Web App 一样,SSR 的开发环境也需要 Bundle,同样也有相同的性能问题。那如何解决呢?ESM Based Dev Server 依靠了浏览器原生支持了 ESM,那 Node.js 是否可以支持原生加载 ESM 呢?答案是肯定的,Node.js 在 12.22.0 版本支持了标准的 ESM 实现,提供了 Loaders API[5],但是仍是实验性质的而且需要 --experimental-loader 开启。为此,Vite 通过转换 ESM 模块导入导出相关的语法,并提供相应了 API,实现了自己的 Node ESM 模块加载器,而且复用了 Dev Server PluginContainer 支持了 Plugins + HMR。以下是一些实现/使用细节:
-
由于 Vite 实现 Node ESM 模块加载器 ,用户需要通过 ssrLoadModule API 来导入入口模块,此方法会递归加载子模块并执行。
-
使用 estree + magic-string 转换 ESM 模块,如下图 import 语法会转换成 vite_ssr_import API。
-
另外,由于运行在 Node.js 环境中,Node CJS 模块的加载可以不经过转换,直接 require 执行。
参考资料
[1]
Vite: https://www.npmtrends.com/vite
[2]
import.meta: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta
[3]
@vitejs/plugin-vue: https://github.com/vitejs/vite/tree/main/packages/plugin-vue
[4]
@vitejs/plugin-react-refresh: https://github.com/vitejs/vite/tree/main/packages/plugin-react-refresh
[5]
Loaders API: https://nodejs.org/api/esm.html#esm_loaders