⤴Top⤴

Vite

博客分类: 前端
修改内容:add Dependency pre-bundling

Vite

Vite

什么是 Vite

时过境迁,我们见证了诸如 webpack、Rollup 和 Parcel 等工具的变迁,它们极大地改善了前端开发者的开发体验。然而,当我们开始构建越来越大型的应用时,需要处理的 JavaScript 代码量也呈指数级增长。包含数千个模块的大型项目相当普遍。我们开始遇到性能瓶颈 —— 使用 JavaScript 开发的工具通常需要很长时间(甚至是几分钟!)才能启动开发服务器,即使使用 HMR,文件修改后的效果也需要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。

Vite 是尤大在更新 vue3 之后,再次开发的一个新的构建工具。你可以把它理解为一个开箱即用的开发服务器 + 打包工具的组合,但是更轻更快。Vite 利用浏览器原生的 ES Modules支持和用编译到原生的语言开发的工具(如 esbuild)来提供一个快速且现代的开发体验。

浏览器对于 ES Modules 的原生支持使得对于第三方的模块,可以不用类似 webpack 一样打包合并,而是通过 import 这种方式去发起 http 请求来获取代码。这也是 Vite 的核心思路。ES Modules 下面简称 ESM

esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

Vite 的三大特点:

  1. 快速的冷启动在开发预览中,它是不进行打包的
  2. 即时热模块更新(HMR,Hot Module Replacement)
  3. 真正按需进行加载

为什么说 Vite 是真正的按需加载?,虽然 webpack 可以生成 chunk,并在合适的时候去加载 import 中的内容,但不管我们这段 import 的代码何时执行,我们都需要对它进行一定的打包。而 Vite 只有在真正加载的时候,浏览器才会发送请求文件内容

当然目前 Vite 也有一定的问题:

  1. 生态还不够完善
  2. 在生产模式下仍然需要打包。尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)。

webpack 工作流

webpack 一直是近几年主流的构建工具,当 webpack 处理应用程序时,它会在内部构建一个依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle:

bundle-based dev server

从图中我们大致可以看出 webpack 打包分以下几个步骤:

  1. 查找入口文件 - 从 webpack 的配置文件中查找 entry 的配置,从而找到入口文件
  2. 分析依赖关系 - 从入口文件出发,分析入口文件中依赖了哪些文件,并且这些依赖的文件中还可能依赖别的文件,就这么递归的找下去
  3. 模块函数 - 找到依赖中的所有文件,把这些文件转化成模块的函数,为了方便后面 webpack 进行调用
  4. 打包 - 打包完毕的文件可以产出到配置文件的 output 指定路径里,生成一个 bundle
  5. 启动服务 - node 创建本地服务器并启动静态页面

Vite 工作流

Native ESM-based dev server

  1. 启动一个静态资源服务器
  2. 找到项目的入口,开始加载入口文件
  3. 当声明一个 script 标签类型为 module 时,浏览器就会像服务器发起一个 GET 请求
  4. Vite 通过劫持浏览器的这些请求,并在后端进行相应的处理,将项目中使用的文件通过简单的分解与整合,然后再返回给浏览器。

我们可以看到,Vite 在启动了一个静态资源服务器后,只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。Vite 整个过程中没有对文件进行打包编译,至于其他加载的工作就交给了浏览器,所以其运行速度比原始的 webpack 开发编译速度快出许多。

esbuild

esbuild 为啥构建速度这么快,来自官网的解释:即使用 Go 编写,并且编译成了机器码。

现在的构建工具一般都是用 JavaScript 进行编写的,对于这种解释型语言(动态语言)来说,在命令行下的性能非常糟糕。因为每次运行编译的时候 V8 引擎都是第一次遇见代码,无法进行任何优化措施。而 esbuild 使用 Go 这种编译型语言(静态语言)编写而成,已经编译成了机器可以直接执行的机器码。当 esbuild 在编译你的 javaScript 代码的时候,可能 Node 还在忙着解析你的构建工具的代码。

当然还有其他方面的处理,如更有效利用内存、大量使用并行算法、从一开始就考虑到性能的从零开始编写等。This benchmark approximates a large JavaScript codebase by duplicating the three.js library 10 times and building a single bundle from scratch, without any caches. The benchmark can be run with make bench-three in the esbuild repo:

Bundler Time Relative slowdown Absolute speed Output size
esbuild 0.33s 1x 1658.9 kloc/s 5.80mb
parcel 2 32.48s 98x 16.9 kloc/s 5.87mb
rollup + terser 34.95s 106x 15.7 kloc/s 5.81mb
webpack 5 41.53s 126x 13.2 kloc/s 5.84mb

关于 HMR 热重载

基于打包器启动时,重建整个包的效率很低。原因显而易见:因为这样更新速度会随着应用体积增长而直线下降。

一些打包器的开发服务器将构建内容存入内存,这样它们只需要在文件更改时使模块图的一部分失活,但它也仍需要整个重新构建并重载页面。这样代价很高,并且重新加载页面会消除应用的当前状态,所以打包器支持了动态模块热重载(HMR):允许一个模块 “热替换” 它自己,而不会影响页面其余部分。这大大改进了开发体验 —— 然而,在实践中我们发现,即使采用了 HMR 模式,其热更新速度也会随着应用规模的增长而显著下降。

Vite 通过在一开始将应用中的模块区分为依赖源码两类,改进了开发服务器启动时间:

在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活(大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新。

Vite 同时利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。

Dependency pre-bundling

vite 依赖预构建(Dependency pre-bundling) 的目的有两个:

  1. CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
  2. 性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

针对第二点,一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。而通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求了!

依赖预构建仅会在开发模式下应用,并会使用 esbuild 将依赖转为 ESM 模块。在生产构建中则会使用 @rollup/plugin-commonjs

如果你想要显式地从列表中包含 / 排除依赖项的情况下, 请使用 optimizeDeps 配置项。例如,import 可能是插件转换的结果。这意味着 Vite 无法在初始扫描时发现 import —— 它只能在浏览器请求文件时转换后才能发现。这将导致服务器在启动后立即重新打包。include 和 exclude 都可以用来处理这个问题。如果依赖项很大(包含很多内部模块)或者是 CommonJS,那么你应该包含它;如果依赖项很小,并且已经是有效的 ESM,则可以排除它,让浏览器直接加载它。

CommonJS 的依赖不应该排除在优化外。如果一个 ESM 依赖被排除在优化外,但是却有一个嵌套的 CommonJS 依赖,则应该为该 CommonJS 依赖添加 optimizeDeps.include。例如:

export default defineConfig({
  optimizeDeps: {
    include: ['esm-dep > cjs-dep']
  }
})

Vite 会将预构建的依赖缓存到 node_modules/.vite。它根据几个源来决定是否需要重新运行预构建步骤:

  1. package.json 中的 dependencies 列表
  2. 包管理器的 lockfile
  3. 可能在 vite.config.js 相关字段中配置过的

只有在上述其中一项发生更改时,才需要重新运行预构建。如果出于某些原因,你想要强制 Vite 重新构建依赖,你可以用 --force 命令行选项启动开发服务器,或者手动删除 node_modules/.vite 目录。

对 TS 的支持

Vite 天然支持引入 .ts 文件。Vite 仅执行 .ts 文件的转译工作,并不执行任何类型检查。并假设类型检查已经被你的 IDE 或构建过程接管了(你可以在构建脚本中运行 tsc –noEmit 或者安装 vue-tsc 然后运行 vue-tsc –noEmit 来对你的 *.vue 文件做类型检查)。

Vite 使用 esbuild 将 TypeScript 转译到 JavaScript,约是 tsc 速度的 20~30 倍,同时 HMR 更新反映到浏览器的时间小于 50ms。

使用仅含类型的导入和导出形式的语法可以避免潜在的 “仅含类型的导入被不正确打包” 的问题,写法示例如下,详细见这里

import type { T } from 'only/types'
export type { T }

需要注意的是,必须在 tsconfig.json 中的 compilerOptions 下设置 "isolatedModules": true。如此做,TS 会警告你不要使用隔离(isolated)转译的功能。这是因为 esbuild 只执行没有类型信息的转译,它并不支持某些特性,如 const enum 和隐式类型导入。

基于 Vite 创建项目

可以直接通过 pnpm create vite 创建,也可以通过附加的命令行选项直接指定项目名称和你想要使用的模板:

# npm 6.x
npm create vite@latest my-vue-app --template vue

# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue

# yarn
yarn create vite my-vue-app --template vue

# pnpm
pnpm create vite my-vue-app -- --template vue

更多可以参考 awesome-vite 👈

vite.config.js

当以命令行方式运行 Vite 时,Vite 会自动解析项目根目录下名为 vite.config.js 的文件。注意即使项目没有在 package.json 中开启 type: "module",Vite 也支持在配置文件中使用 ESM 语法。这种情况下,配置文件会在被加载前自动进行预处理。

如果配置文件需要基于(dev/serve 或 build)命令或者不同的模式来决定选项,则可以选择导出这样一个函数。具体配置可查阅官网

export default defineConfig(({ command, mode }) => {
  if (command === 'serve') {
    return {
      // dev 独有配置
    }
  } else {
    // command === 'build'
    return {
      // build 独有配置
    }
  }
})

参考链接

  1. vite 下一代前端开发与构建工具(一) By 薛定谔的猫_
  2. [Vite 总结] 帅小伙花了一个月时间总结的 Vite 知识点和迁移方案 By 墨痕m