logo

深入浅出 Vite [WIP]

Thu Oct 19 2023 Posted last year

本文是关于《深入浅出 Vite》这本小册的简要记录。

模块标准 #

CommonJSAMDCMDUMDES Module

无模块化标准 #

文件划分 #

// module-a.js
let data = "data";
// module-b.js
function method() {
  console.log("execute method");
}
// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./module-a.js"></script>
    <script src="./module-b.js"></script>
    <script>
      console.log(data);
      method();
    </script>
  </body>
</html>

缺点:

  1. 模块进行了全局声明和定义,会出现变量名冲突的问题。
  2. 变量全都定义在全局,无法直观的看出某个变量属于哪一个模块。
  3. 无法清晰地管理模块间的依赖关系和加载顺序。

命名空间 #

// module-a.js
window.moduleA = {
  data: "moduleA",
  method: function () {
    console.log("execute A's method");
  },
};
// module-b.js
window.moduleB = {
  data: "moduleB",
  method: function () {
    console.log("execute B's method");
  },
};
// index.html
...
  <body>
    <script src="./module-a.js"></script>
    <script src="./module-b.js"></script>
    <script>
      console.log(data);
      method();
    </script>
  </body>
...

相较于直接使用文件划分,命名空间可以避免变量名冲突的问题,同时能较为直观地看出某个变量的所属模块。

IIFE #

// module-a.js
(function () {
  let data = "moduleA";

  function method() {
    console.log(data + "execute");
  }

  window.moduleA = {
    method: method,
  };
})();
// module-b.js
(function () {
  let data = "moduleB";

  function method() {
    console.log(data + "execute");
  }

  window.moduleB = {
    method: method,
  };
})();
// index.html
...
  <body>
    <script src="./module-a.js"></script>
    <script src="./module-b.js"></script>
    <script>
      // 此时 window 上已经绑定了 moduleA 和 moduleB
      console.log(moduleA.data);
      moduleB.method();
    </script>
  </body>
...

相较于命名空间,IIFE 的安全性要更高,因为它实现了私有作用域,模块内部的变量只有在模块内部才能够访问,不会被外部环境直接读取或修改。

然而,无论是命名空间还是 IIFE 都无法解决模块依赖和加载顺序的问题。

CommonJS #

最早的模块化规范,同时提供了代码规范和 loader。

// module-a.js
var data = "hello world";
function getData() {
  return data;
}
module.exports = {
  getData,
};

// index.js
const { getData } = require("./module-a.js");
console.log(getData());

缺点:

  1. 原生提供的 loader 只支持 Node.js 环境,如果想在浏览器中使用则需要依赖 browserify
  2. CommonJS 本身约定以同步的方式进行模块加载,所以在浏览器中使用时会造成阻塞

AMD #

AMD 全称为 Asynchronous Module Definition,即异步模块定义规范。与 CommonJS 不同,它支持异步加载模块。

// main.js
define(["./print"], function (printModule) {
  printModule.print("main");
});

// print.js
define(function () {
  return {
    print: function (msg) {
      console.log("print " + msg);
    },
  };
});

AMD 规范并没有得到浏览器的原生支持,需要第三方 loader 来实现,比如 requireJS

同时期出现的规范还有 CMD,它是由淘宝开源的 SeaJS 实现的。

而后来的 UMD 规范则是兼容 AMDCommonJS 的一个模块化方案,可以同时运行在浏览器和 Node.js 环境。

ES Module #

ES Module 被绝大部分的现代浏览器(批评的就是你!IE!)支持。只需要在 script 标签中加入 type=module 浏览器就会按照 ES Module 来加载模块。

使用方法就不在赘述,天天用。

初识 Vite #

在 Vite 项目中,每一个 import 语句都代表一个 HTTP 请求。这是因为 Vite 倡导 no-buldle 理念,在开发环境中直接使用 ES Module 实现模块加载而不是先整体打包再加载(比如 webpack)。

与 webpack 不同,Vite 本身对 CSS 各种预处理器语言(Sass/ScssLessStylus)做了内置支持,也就是说我们不需要像 webpack 那样去安装 sass-loader 之类的东西,只需要安装一个 sass 就可以在项目中使用 sass 语法。PostCSS 也一样,我们可以直接在 vite.config 文件里进行配置,不需要额外安装 loader。

通过 vite-svg-loader 可以在项目中把 svg 文件当作组件使用。

Vite 内置了对 JSON 文件的解析,可以直接具名导入 import { version } from '../package.json';

Vite 中引入静态资源时,也支持在路径最后加上一些特殊的 query 后缀,包括:

  • ?url: 表示获取资源的路径,这在只想获取文件路径而不是内容的场景将会很有用。
  • ?raw: 表示获取资源的字符串内容,如果你只想拿到资源的原始内容,可以使用这个后缀。
  • ?inline: 表示资源强制内联,而不是打包成单独的文件。

.env 文件中定义的以 VITE_ 开头的环境变量可以在项目中使用 import.meta.env 访问。

Vite 内置了 base64 的打包压缩方案,对 4kb 以下的文件会进行 base64 编码处理,4kb 以上则提取为单独文件。(通过 assetsInlineLimit 控制,svg 不受限制,始终都会打包为单文件)

使用 vite-plugin-imagemin 可以对图片进行压缩。

可以使用 vite-plugin-svg-icons 实现雪碧图(即多个 svg 合并为一个 svg,减少网络请求)。同时 Vite 内部也提供了同时导入多文件的方法:

const icons = import.meta.globEager('../../assets/icons/logo-*.svg'); // 同步
const icons = import.meta.glob('../../assets/icons/logo-*.svg'); // 异步

const iconUrls = Object.values(icons).map(mod => mod.default);

{iconUrls.map((item) => (
  <img src={item} key={item} width="50" alt="" />
))}

预构建 #

Vite 在开发环境下使用 esbuild 进行预构建。

原因:

  1. 开发环境下,Vite 会将所有代码视为原生 ES 模块,因此需要把 node_modules 中使用 CommonJS 或 UMD 规范的依赖项转换为 ES 模块处理。
  2. 有些包会将它们的 ES 模块构建为许多单独的文件,并且互相依赖,比如 lodash-es 有超过 600 个内置模块,当我们在代码里执行 import { debounce } from 'lodash-es' 时,浏览器会同时发出 600 多个 HTTP 请求,严重阻塞了进程,所以我们需要把它预构建为一个单独的模块来减少网络请求。

在第一次启动项目时,Vite 会自动进行预构建,并将预构建的产物存放到 node_modules/.vite/deps 目录下作为缓存,同时对于这些依赖的请求也会通过设置 Cache-Control 响应头来设置强缓存。

重新进行预构建的情况:

  1. package.json 的 dependencies 字段变更
  2. .lock 文件变更
  3. NODE_ENV 的值变更
  4. 修改了 vite.config 文件中的 optimizeDeps 相关配置

除了以上情况外,还可以通过手动删除 .vite 目录或者启动开发服务器时指定 --force 选项来强制 Vite 重新进行预构建。

entries #

默认情况下 Vite 会抓取 index.html 作为入口来扫描需要预构建的依赖项。当默认行为无法满足需求时可以optimizeDeps.entries 来配置入口:

{
  optimizeDeps: {
    // type: string | string[]
    entries: ["./src/main.vue"];
    // 支持 fast-glob 模式
    // entries: ["**/*.vue"];
  }
}

include & exclude #

默认情况下,Vite 只会对 node_modules 下的依赖进行自动扫描并预构建。但如果需要预构建非 node_modules 下的文件或者是 Vite 无法自动搜集到依赖的情况下就需要配置 optimizeDeps.include ,比如涉及到运行时的动态 import 就无法被 Vite 自动扫描:

// moduleA.js
import objectAssign from 'object-assign';
console.log(objectAssign)

// index.vue
const importModule = (m) => import(`./${m}`)

importModule('moduleA.js')

在上面这个例子中,动态 import 的路径只有在运行时才能确认,无法在预构建阶段就扫描出来,所以 Vite 会在运行服务器后再进行一次预构建,然而二次预构建会把所有流程都重新运行一遍,严重拖慢项目的启动速度,因此我们可以手动把依赖放入 include 中。

optimizeDeps.exclude 用来设置预构建中强制排除的依赖项,需要注意的是,CommonJS 的依赖不应该排除在优化外。如果一个 ESM 依赖被排除在优化外,但是却有一个嵌套的 CommonJS 依赖,则应该为该 CommonJS 依赖添加 optimizeDeps.include。例如:

{
  optimizeDeps: {
    include: ['esm-dep > cjs-dep'],
  },
}

Vite 整体架构 #

vite

开发环境:esbuild 生产环境:rollup + esbuild

esbuild #

esbuild 的优势:快,很快,非常快。

缺点:

  1. 不支持降级到 ES5 的代码
  2. 不支持 const enum 等 ts 语法
  3. 不提供操作打包产物的接口,无法像 rollup 那样灵活地处理打包产物(如 renderChunk
  4. 不支持自定义 Code Splitting 策略

在预构建阶段,esbuild 作为 bundler 的角色存在。而对于 TS(X)/JS(X) 的文件编译上,Vite 也会使用 esbuild 进行语法转译,尽管 esbuild 可以对 ts 文件进行编译,但是无法实现类型检查,因此打包生产环境时还是需要运行 tsc 命令。

在生产环境下 esbuild 压缩器通过插件的形式融入到了 Rollup 的打包流程中,与传统的 Terser 作比较,esbuild 作为压缩器的优点如下:

  1. 压缩这项工作涉及大量的 AST 操作,但 Terser 无法与 babel 共享一个 AST,造成了很多重复编译的过程。而 esbuild 可以做到从头到尾共享 AST
  2. 压缩属于 CPU 密集型工作,JS 的效率远远不如原生的 Golang

rollup #

Vite 使用 rollup 进行生产环境的打包,并且做了许多优化,主要有三点:

  1. CSS 代码分割。如果某个异步模块中引入了一些 CSS 代码,Vite 就会自动将这些 CSS 抽取出来生成单独的文件,提高线上产物的缓存复用率
  2. 自动预加载。Vite 会自动为入口 chunk 的依赖自动生成预加载标签<link rel="modulepreload"> ,如:
    <head>
      <!-- 省略其它内容 -->
      <!-- 入口 chunk -->
      <script type="module" crossorigin src="/assets/index.250e0340.js"></script>
      <!--  自动预加载入口 chunk 所依赖的 chunk-->
      <link rel="modulepreload" href="/assets/vendor.293dca09.js">
    </head>
    
  3. 异步 Chunk 加载优化。在异步引入的 Chunk 中,通常会有一些公用的模块,如现有两个异步引入的 Chunk: AB,而且两者有一个公共依赖 C,如下图:

vite_chunk

在无优化的情境下,当异步 chunk A 被导入时,浏览器将必须请求和解析 A,然后它才能弄清楚它也需要共用 chunk C。这会导致额外的网络往返:

Entry ---> A ---> C

Vite 将使用一个预加载步骤自动重写代码,来分割动态导入调用,以实现当 A 被请求时,C 也将 同时 被请求:

Entry ---> (A + C)

Vite 的插件可以完全兼容 rollup 的,但是 rollup 的插件不一定兼容 Vite。在开发阶段,Vite 借鉴了 WMR 的思路,自己实现了一个 Plugin Container,用来模拟 Rollup 调度各个 Vite 插件的执行逻辑。

题外话:为什么 esbuild 使用了 go 编写却能在 node 环境运行?因为是在 node 中通过 child_process 调用了 go 打包好的二进制文件。

Esbuild 的基本用法 #

具体可见 way2high/esbuild-playround

Vite 的基本用法 #

具体可见 way2high/vite-playround

Rollup 的基本用法 #

具体可见 way2high/rollup-playround