6.3 Vite 功能介绍
Vite 的特点是使用原生 ESM 来实现模块化构建,这样核心的模块化逻辑交给浏览器实现,提升了打包速度。但同时原生 ESM 也有不足之处,Vite 则在此基础上提供了许多增强功能,使打包构建的整体能力更加完善。
裸模块解析
原生 ES 导入不支持下面这样的裸模块导入:
import { someMethod } from "app";
裸模块(bare module)是指没有指定相对或者绝对路径的模块。如上面代码中的 “app”,浏览器不认识这是什么。可能你想导入的模块是当前目录下的 app.js,那么必须这样写:
import { someMethod } from "./app.js";
但我们在 Node.js 或者打包工具(Webpack)中经常使用没有任何路径的裸模块,这是因为它们有自己的查找模块路径的方法。为此 Vite 也实现了对裸模块的使用,其转换逻辑如下:
- 预构建:将 CommonJS/UMD 转换为 ESM 格式。
- 修改裸模块路径,比如将路径指向 node_modules 文件夹下。
为什么要使用预构建呢?因为我们安装的第三方模块并不都是 ESM,有可能是 CommonJS 或者 UMD,这类模块 Vite 无法解析,因此需要先将其转换为 ESM。
既然裸模块 ESM 不认识,那么 Vite 便按照一定的规则修改裸模块路径。假设导入模块 dayjs,修改后的模块路径如下:
// 源码中使用时
import dayjs from "dayjs";
// 浏览器解析时
import dayjs from "/node_modules/dayjs/dayjs.min.js";
依赖预构建
在解析模块时,如果模块是非原生 ES 模块,则要使用预构建将其转换为 ESM,然后才能交给浏览器运行。那么预构建是如果实现的呢?
Vite 使用 esbuild 来执行预构建,这使我们在开发中几乎感受不到打包过程,因为它的速度非常快。Vite 中的预构建与 Webpack 中的编译概念可以类比,区别是 Vite 只需要将模块构建成 ESM,构建内容比 Webpack 更少且速度更快。
注意:在生产环境下不会使用依赖预构建,而是借助 rollup 的 rollup/plugin-commonjs 插件来打包代码。
为什么在生产环境不使用 esbuild?这一点尤雨溪在直播中介绍过,主要是因为当前 esbuild 对打包功能支持不够完善,如生成哈希值、处理资源文件、分析包文件等无法用 esbuild 实现。如果在未来 esbuild 的打包功能更完善稳定,Vite 则会考虑在生产环境使用 esbuild。
预构建在性能和提速方面几乎做到了极致。除了构建工具本身速度快,Vite 还做了许多优化方案来提高构建效率,代表性的有自动缓存和依赖优化。
- 自动缓存
预构建会将依赖自动缓存以避免重复构建,提升加速度。当你导入一个模块时,预构建会自动分析模块的依赖关系,构建后并将其缓存起来,下一次使用模块时便可绕过构建直接从缓存读取。如果在开发过程中导入新的依赖(重新导入模块或模块更新),预构建会重新执行并缓存。
预构建缓存有浏览器和文件系统两层缓存,以此来最大程度地提升构建效率和页面重载性能,它们的区别如下:
- 文件系统缓存:将依赖缓存到 node_modules/.vite 文件夹下。
- 浏览器缓存:以 HTTP 头 “max-age=31536000,immutable” 强缓存。
有时候我们需要强制清除缓存来重新构建代码,那么这两层缓存都需要清除。首先在浏览器调试工具的 Network 选项卡中禁用缓存,然后启动开发服务器时使用 --force 命令选项,此时进入页面所有依赖会被重新构建。
- 依赖优化
默认情况下,Vite 会从 index.html 开始抓取项目的依赖项并执行预构建,这个过程是根据模块间的导入关系自动实现的。Vite 只会从 node_modules 目录下抓取依赖,有时候我们可能需要修改依赖路径,比如将 src 目录下的某个模块添加为依赖,或者排除某个 node_modules 目录下的 ESM 模块,这时就要用到依赖优化。
依赖优化通过 optimizeDeps 配置项来实现,具体用法我们会在后面的配置部分展开介绍。
模块热替换
模块热替换(HRM)在 Webpack 中大家都体验过。当在开发模式下修改代码后,构建工具会自动检测到文件变化,并将变化的部分重新构建并在页面中更新,而无需刷新浏览器,这种方式大大提高了前端的开发效率。
Vite 提供了一套使用原生 ESM 实现的 “HMR API”,主流框架可以使用该 API 来实现更快更精准的模块热替换。Vite 提供了官方插件分别实现了 Vue、React、Preact 等常用框架的模块热替换,我们可以拿来就用。
框架与实现模块热替换插件对应关系如下:
- Vue:@vitejs/plugin-vue。
- React:@vitejs/plugin-react。
- Preact:@prefresh/vite。
使用脚手架创建项目时默认会启动模块热替换功能,并不需要手动处理。但我们应该知道如何在一个全新的 Vite 项目中集成模块热替换。比如在 Vue 项目中,实现模块热替换只需要在 vite.config.ts 中添加入下代码:
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
TypeScript
Vite 天然支持 TypeScript,它支持直接导入 .ts 文件,不需要其他任意插件的协助。我们谈到的支持 TypeScript 主要是对以下两部分的支持:
- 类型转译:将 TypeScript 代码转译为 JavaScript 代码。
- 类型检查:检查 TypeScript 代码中是否有类型错误。
TypeScript 本身提供了一个 tsc 命令来实现这两部分的功能。不过 Vite 没有借助该命令,而是使用 esbuild 来实现类型转译,速度比 tsc 快了 20~30 倍,这使得 HMR 的更新速度小于 50ms(官方数据)。
Vite 专注于构建,仅执行 .ts 文件的转译工作,却并不执行任何类型检查,通常类型检查的工作还是交给编辑器执行(VSCode 中的 TypeScript 插件)。因此 Vite 为 TypeScript 提供了更快的类型转译,而类型检查却要自己实现。
在 Vite 中使用 TypeScript 还有以下两个注意事项。
- 编译器选项配置。
在 Vite 中使用 TypeScript,需要注意在 tsconfig.json 配置文件中的 compilerOptions 选项下,有几个需要注意的配置选项。
(1)isolatedModules。
TypeScript 将没有 import/export 的文件视为旧脚本文件。这样的文件不是 ESM,它们的任何定义都会合并到全局名称空间中。请设置该选项的值为 true,以保证 esbuild 在转译 TypeScript 时,转译对象都是标准的 ESM。当用户在编写一个没有 import/export 关键字的文件时,编辑器会提示错误,这样会强制规范用户编写 ESM 代码。
(2)useDefineForClassFields。
在一个类中,假设要定义和修改一个 tag 属性,我们会通过 this.tag = xxx 这种方式实现。然而这种方式在有继承关系的类中,可能会同时修改子类和父类的 tag 属性。当我们只想修改当前类而不想影响到父类时,就要设置 "useDefineForClassFields": true。
该属性设置为 true 后,TypeScript 会将 this.tag = xxx 编译为 Object.defineProperty(this, 'tag', {}),这会保证属性设置只对当前类生效,Vite 推荐使用这种方式,我们只需要了解该选项的意义并设置为 true 即可。
- 客户端类型
Vite 默认的类型定义是面向 Node.js API 的,因为大多时候 Vite 是在 Node.js 环境下使用。但有一部分类型需要在客户端中使用,比较有代表性的就是环境变量。在 Vite 中,定义的环境变量在浏览器环境下可以通过 import.meta.env 对象访问,那么该对象就要设置为环境变量的类型。
为此,Vite 单独为客户端提供了类型,我们在客户端的根目录添加一个 d.ts 声明文件,引入这个类型:
/// <reference types="vite/client" />
现在,在源码中使用 import.meta.env 就可以看到环境变量的类型了。Vite 的客户端类型还提供了以下类型定义补充:
- 资源导入(例如:导入一个 .svg 文件)
- import.meta.hot:HMR API 类型定义。
JSX/TSX 转译
在 Vite 中 .jsx 和 .tsx 文件同样开箱即用,它们的转译同样是通过 esbuild 实现。
在 Vue3 中,JSX 转译通过官方插件 @vitejs/plugin-vue-jsx 实现,该插件同时支持 JSX & TSX 语法。而在 React 中插件 @vitejs/plugin-react 同时支持了 HRM 和 JSX/TSX 转译,不需要单独的插件实现。
如果在非 Vue/React 项目中使用 JSX,则需要自定义 esbuild 配置。比如在 Preact 中使用 JSX 配置方法如下:
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
},
});
CSS 系列支持
在 Vite 中,不管是 .vue 文件中的 CSS 代码,还是在 JS 文件中单独导入一个 CSS 文件,最终样式都会被编译到 html 文件的 <style>
标签中。
Vite 对 CSS 提供了多项优化支持,主要包含三个方面。
- @import
@import 关键字的作用是在一个 CSS 文件中导入另一个 CSS 文件,这样可以让 CSS 像 JS 模块一样互相引用。比如在项目的 CSS 文件中引入 UI 框架的样式,就可以这样写:
@import "element-plus/dist/index.css";
#app {
font-size: 15px;
}
值得庆祝的是,@import 还支持使用别名。假设我们在 vite.config.ts 中配置了别名 “@” 指向 src 目录,那么该别名不光可以在 JS 文件中使用,在 @import 中也可以,如下:
// app.js
import '@/style/app.css'
// app.css
@import '@/style/base.css';
#app {
font-size: 15px;
}
Vite 通过 postcss-import 实现了 @import 关键字,因此在项目中 PostCSS 也是开箱即用的。你可以指定一个 postcss.config.js 文件,该配置将对所有的 CSS 文件生效。
- CSS Modules
任何以 .module.css 为后缀名的 CSS 文件都被认为是一个 CSS modules 文件,使用 CSS Modules 的作用是该文件在被 JavaScript 导入时,会自动导出一个 JavaScript 对象。该对象的属性是 CSS 中定义的类名,属性值则是一个生成的唯一类名。
也就是说,CSS Modules 中定义的样式在导入时不会直接生效,而是生成一个唯一的类名并将样式放入其中。若要使用这个样式,需要为元素添加这个类名,如下。
// test.module.css
.title {
color: red;
font-size: 16px;
}
// test.js
import styles from './test.module.css'
console.log(styles.title) // _red_hyqy7_1
document.querySelector('p').className = styles.title
CSS Modules 编译后会生成一个唯一的类名,并在该类名下放入样式。比如上面的 test.module.css,编译后生成了类名 “red_hyqy7_1”,最终样式如下:
._red_hyqy7_1 {
color: red;
font-size: 16px;
}
- CSS 预处理器
由于 Vite 的目标仅为现代浏览器,所以更推荐使用 CSS 变量、或者 PostCSS 插件提供的面向未来的 CSS 语法来实现高级的样式功能。比如要在项目中实现主题色切换,就可以将主题色定义为一个 CSS 变量,切换时修改这个变量即可。
即便如此,Vite 依然内置了对 Less、Sass 等预处理器的支持,无需安装特定的插件,只需要安装相应的预处理器依赖即可:
# sass
yarn add -D sass
# less
yarn add -D less
在 Vue 单文件组件中,开启预处理器只需要一个 lang 属性,例如 <style lang="sass"></style>
表示该部分样式使用 sass 预处理器,当然 lang 属性你也可以替换成 less 等其他预处理器名称。
Sass 和 Less 中使用 @import 时同样支持使用路径别名,这非常方便。除此之外,也可以使用预处理器版的 CSS Modules,比如:test.module.less。
静态资源导入
静态资源主要是图片,字体等非代码资源,导入静态资源的方式和导入 ESM 一样,区别是 Vite 会识别导入的静态资源,并对其进行特定的处理。
当导入图片或字体时,会返回解析后的 URL:
import imgUrl from "./img.png";
console.log(imgUrl); // /src/assets/img.png
当导入 JSON 时,会直接返回 JSON 对象,甚至可以直接将其解构,如下:
import json from "./test.json";
console.log(json); // { name: 'test' }
import { name } from "./test.json";
console.log(name); // test
Vite 还支持我们修改资源被引入的方式。比如设置导入资源返回的是资源路径还是字符串,通过以下方式实现:
import json from "./test.json?url";
console.log(json); // /src/assets/img.png
import json2 from "./test.json?raw";
console.log(json2); // "{ name: 'test' }"
- 动态导入
在 ESM 中标准的导入方式是使用 import 关键字,它是同步导入,只能在模块顶层使用。而有时候我们需要使用动态导入,也就是常说的懒加载、异步加载,比如动态导入页面路由、动态加载某个资源等,Vite 也支持我们这样做。
动态导入通过 import() 函数实现,该函数可全局使用。函数参数是一个要导入的资源路径,函数执行后返回一个 Promise,因此有以下两种方式获取导入后的资源。
// Promise
import("xxx.js").then((res) => {
console.log(res);
});
// async/await
const fun = async () => {
let res = await import("xxx.js");
};
在 Vite 中使用动态导入的模块,在构建时会拆分为单独的 chunk,这样可以避免生成一个过于庞大的文件,影响页面加载性能。
- Glob 导入
Vite 支持通过模糊匹配批量导入资源,这种方式被称为 “Glob 导入”。Glob 导入是用特殊的 import.meta.glob 函数实现,参数是一个模糊路径。假设有现在有一个 dir 目录,下面有 a.js 和 b.js 两个文件,那么 Glob 导入方式如下:
var modules = import.meta.glob("./dir/*.js");
上面代码返回一个对象,属性是匹配到的文件路径,值是一个 import() 动态导入函数。上面的 Glob 导入的效果等同于下面的代码:
var modules = {
"./dir/a.js": () => import("./dir/a.js"),
"./dir/b.js": () => import("./dir/a.js"),
};
由上述代码可见,Glob 导入模块默认使用动态导入。如果想直接导入所有模块,你可以将 { eager: true } 作为第二个参数传入。
var modules = import.meta.glob("./dir/*.js", { eager: true });