前言

这一篇为什么要叫 vite 官方文档浓缩呢?一方面是自己不太了解 vite,想通过官方文档进行 vite 的学习。一方面是靠自己的理解处理一下文档的细节。话不多说,开刷

前置知识:CJS/ESM,HMR,ESM 的引入方式,FOUC

为什么选 Vite

Vite 诞生的背景是大型应用中 webpack 构建速度变慢,需要很久才能开启服务器,即使通过 HMR 也需要很久浏览器才能反馈出内容。以上都是基于打包的学说,而 Vite 诞生的原因则是浏览器开始逐步支持 JS 模块,他使用非打包的方式构建,以冷启动的方式启动,因此构建速度上面极大快于前者。

冷启动

Vite 先将应用中的模块分为依赖和源码两类,改进服务器启动时间。

  • 依赖:依赖大多数为开发不会变动的纯 js。一些比较大的依赖(比如组件库)处理的成本也很高,依赖通常有多种模块化的格式(eg:CJS/ESM)

Vite 在依赖处理这方面使用的是 esbuild,由 go 语言编写,比 js 编写的打包器预构建快 10-100 倍

  • 源码:源码通常包含一些非 js 文件,比如 css,jsx 或者 vue/svelte 组件,特点是时常会被编辑。同时不是所有的源码都需要同时被加载(比如基于路由拆分的代码模块)

Vite 以原生 ESM 的方式提供源码。相当于让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码的时候才去转换并提供源码。根据情景动态导入源码,大部分是当前屏幕中有的操作才会被处理。

以下是常规打包构建和非打包预构建的示例图

看图可以发现 Vite 应该是只构建了当前视图中路由和与该路由相关的模块

缓慢更新

当你更新应用的时候,常规的打包器使用 HMR,让一个模块热替换自己,而不会影响页面的其他部分,大大的改善了开发体验。不过当应用程序越来越大的时候,这种方式也显得捉襟见肘。

边界链失活

Vite 中,HMR 是在原生 ESM 上面执行的。当编辑一个文件的时候,vite 只需要对已编辑的模块与其最近的 HMR 边界的链失活(不需要全部重构建)使得无论应用大小如何,HMR 始终保持快速更新

加速页面重载

Vite 使用 HTTP 请求头来使得页面快速重载,对于依赖使用的是Cache-Control: max-age=31536000,immutable进行强缓存,源码模块通过 304 进行协商缓存。

仍需要打包的生产环境

上面的 Bundless 只是基于开发环境的,正式上线还是需要进行打包。原因很简单:

嵌套打包会导致额外的网络往返,即使使用了 HTTP2 也是效率低下

所以为了在生产环境中获得更好的加载性能,最好还是对代码进行 tree-shaking,懒加载和 chunk 分割(webpack 那套)

为什么不用 esbuild 打包?

esbuild 很快,但是还不够完善,尤其是在 css 和代码分割方面。目前采用的是比较稳定的 rollup

NPM 依赖解析和预构建

我们知道 esm 是不支持裸导入的,想要将 es 模块在浏览器中使用,有三种方式

  1. import from URL
  2. importmap
  3. assertion

其中第二种方式使得后续的裸导入成为可能。

但是 vite 的思路是这样的,检测所有裸导入的模块,对其进行以下操作:

  1. 使用 esbuild 预构建依赖,将 cjs 和 umd 转换为 esm。
  2. 重写导入为合法的 URL(利用第一种方式),eg:/node_modules/.vite/deps/my-dep.js?v=f3sf2ebd让浏览器能够识别它们

更快的 Typescript 转译

vite 使用 esbuild 转 ts 到 js,比 tsc 速度快 20-30 倍。

注意事项

css

导入 css 文件将会把内容插入到 style 标签中,同时也带有 HMR 支持。也能以字符串的形式检索处理后的,作为其模块默认导出的 css

@import 内联和变基

Vite 通过 postcss-import 预配置支持了 css 的 @import 内联,意味着所有 css 的 url 引用,即使导入的文件在不同的目录内,也总是自动变基

postcss

如果项目包含有效的 PostCSS 配置 (任何受 postcss-load-config 支持的格式,例如 postcss.config.js),它将会自动应用于所有已导入的 CSS。

cssmodule

任何以 .module.css 为后缀名的 CSS 文件都被认为是一个 CSS modules 文件。导入这样的文件会返回一个相应的模块对象:

CSS 预处理器

vite 提供了对.scss, .sass, .less, .styl.stylus 文件的内置支持。没必要安装特定的 vite 插件,但需要安装相应的预处理器依赖

1
2
3
4
5
6
7
8
9
# .scss and .sass
npm add -D sass

# .less
npm add -D less

# .styl and .stylus
npm add -D stylus

构建性能优化

下面所罗列的功能会自动应用为构建过程的一部分,除非你想禁用它们,否则没有必要显式配置。

css 代码分割

vite 会自动的将一个异步的 chunk 模块中使用到的 css 代码抽离出来并为其生成一个单独的文件。这个 css 文件将在异步 chunk 加载完成的时候自动通过一个 link 标签载入,该异步 chunk 会保证只在 css 加载完成后执行,避免发生 FOUC

不过也可以单独抽离所有的 css 到一个文件中,通过设置:build.cssCodeSplit 为 false 来禁用 css 代码分割。

预加载指令生成

vite 会为入口 chunk 和它们打包出的 html 中直接引入自动生成<link ref = 'modulepreload'>指令。

异步 chunk 加载优化

实际项目中,rollup 通常会生成共用 chunk—-被两个或者两个以上的其他 chunk 共享的 chunk。与动态导入相结合,很容易出现下面的情况

在无优化的情况下,当异步 chunkA 被加载的时候,浏览器必须先解析 A 才知道 A 和 C 的共用关系,这会导致额外的网络往返。

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

1
(Entry) => A + C;

C 里面还可能有更多的嵌套共用内容,所以这种预加载处理是很重要的?

Vite 具体是这么操作的?预加载的原理?

Vite 使用插件

Vite 的插件是基于 Rollup 系统的,并且添加了自己额外的选项

具体如果想要添加一个插件,需要添加到项目的devDependencies中,并在 vite.config.js 配置文件中的 plugins 数组里面引入他。例如,想要为传统浏览器提供支持可以使用@vitejs/plugin-legacy

1
$ npm add -D @vitejs/plugin-legacy
1
2
3
4
5
6
7
8
9
10
11
// vite.config.js
import legacy from "@vitejs/plugin-legacy";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
legacy({
targets: ["defaults", "not IE 11"],
}),
],
});

plugin 可以接收多个插件并且会在里面自动扁平化这个数组。

获取方式
Vite 插件:传送门
Rollup 插件:传送门

强制插件排序

Vite 的插件可能和 Rollup 的插件冲突,需要修改插件的执行顺序或者只在构建的时候使用。使用修饰符 enforce 来完成这件事

  • pre:在 vite 插件之前
  • 默认/post:在 vite 插件之后
1
2
3
4
5
6
7
8
9
10
11
12
// vite.config.js
import image from "@rollup/plugin-image";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
{
...image(),
enforce: "pre",
},
],
});

查看兼容性传送门

按需应用

指定插件在开发(serve)还是生产(build)中使用:apply 修饰符

1
2
3
4
5
6
7
8
9
10
11
12
// vite.config.js
import typescript2 from "rollup-plugin-typescript2";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
{
...typescript2(),
apply: "build",
},
],
});

依赖预构建

首次启动 vite 会打印相关信息:

1
2
3
4
5
Pre-bundling dependencies: (正在预构建依赖:)
react
react-dom
(this will be run only when your dependencies or config have changed)(这将只会在你的依赖或配置发生变化时执行)

因为此时 vite 正在执行预构建,且每次有新的插件加进来,都会重新预构建。

原因

vite 的依赖预构建有两个原因

  1. CJS 和 UMD 兼容性:开发阶段,vite 要将所有的 cjs 模块转化成为 esm,vite 会智能分析所有的 cjs 或者 umd 依赖然后生成对应 esm(关于怎么转 esm 的一些细节在 webpack 的 bundless 章节已经讲述过)
  2. 性能:除了对 cjs,umd 处理之外,vite 还对有许多内部模块的 esm 依赖关系转为单个模块,举个例子,执行 loadash 的时候,会发送六百多个请求,在浏览器端会造成很大的压力,导致页面的加载速度变慢。

通过预构建 loadash-es 成为一个模块,那么只需要发送一个请求即可。

注意:开发环境使用的是 esbuild 转化 esm 模块,生产环境使用的是@rollup/plugin-commonjs

自动依赖搜寻

如果 vite 没有找到你的缓存,也就是没有 304 协商缓存,也没有强缓存,那么 vite 会自动抓取你的源码,找到引入的依赖项,比如裸导入的模块,并将这些依赖项作为预构建包的入口点,预构建通过 esbuild 进行,所以很快。

在服务器启动之后,如果发现一个新的依赖项进来,且他没有在缓存中,vite 会重新执行依赖构建过程并重载浏览器。

Monorepo 和链接依赖

在一个 Monorepo 启动中,该仓库的某个依赖可能会成为另一个包的依赖,vite 会自动侦测没有从 nodemodules 里面解析的依赖项,并将链接的依赖视为源码,他不会尝试打包被链接的依赖,而是会分析被链接依赖的依赖列表。

不过这需要被链接的依赖被导出为 esm 格式。如果不是,那么你可以在配置中将此依赖添加到 optimizeDeps.include 和 build.commonjsOptions.include 这两项中。optimizeDeps.includebuild.commonjsOptions.include 这两项中。

1
2
3
4
5
6
7
8
9
10
export default defineConfig({
optimizeDeps: {
include: ["linked-dep"],
},
build: {
commonjsOptions: {
include: [/linked-dep/, /node_modules/],
},
},
});

当这个被链接的依赖发生变更后,在重启开发服务器时在命令中带上 --force 选项让所有更改生效。

重复删除

由于对链接依赖的解析方式不同,传递性的依赖项可能会不正确地进行重复数据删除,而造成运行时的问题。如果你偶然发现了这个问题,请使用 npm pack 来修复它。

自定义行为

默认的依赖项为启发式可能并不总是可取的,可以使用 optimizeDeps 配置项:include 和 exclude 去配置你想要寻找的依赖或者排除依赖。

建议:如果依赖项很大(有很多内部模块)或者是 CJS,你应该包含他让 vite 自动去处理,如果依赖项很小并且已经是 esm,那么可以排除他,让浏览器自动加载他。

缓存

包含文件系统缓存和浏览器缓存
vite 会将预构建的依赖缓存到node_modules/.vite.它根据几个源来决定是否重新执行预构建

  1. packgejson 中的 dependencies 列表
  2. 包管理器的 lockfile
  3. 在 vite.config.js 中相关字段配置过的(比如刚刚说的 optimize:inclde/exclude)

如果想要 vite 强制依赖预构建,需要使用--force来启动开发服务器,或者手动删除node_modules/.vite

浏览器缓存

解析后的依赖请求会以 http 头max-age:31536000 immutable进行强缓存,用来提高开发时的页面性能。一旦被缓存,这些请求将永远不会到达开发服务器。如果安装了不同版本的则附加的版本 query 会自动使他们失效(保证只能有一个版本)。如果要调试依赖,可以使用:

  1. 通过浏览器调试工具的 Network 选项卡暂用缓存
  2. 重启 vite dev server 并--force命令以重新进行依赖预构建。
  3. 重载页面

静态资源处理

将资源引入为 URL

服务时,引入一个静态资源会返回解析后的公共路径

1
2
import imgUrl from "./img.png";
document.getElementById("hero-img").src = imgUrl;

比如 imgURL 在开发时会是/img.png,生产构建后会是/assets/img.2d8efhg.png

这个类似于 webpack 的 file-loader,但是不一样的点在于既可以引入绝对公共路径,也可以引入相对路径

  • url()在 css 中的引用会以同样方式进行处理。
  • 如果 vite 引用了 vue 插件,vue sfc 模板中的资源都将自动转换为导入
  • 常见的图像,媒体,和字体文件类型将被自动检测。可以使用 assetsInclude 选项拓展内部列表。
  • 引用的资源作为构建资源图的一部分包括在内,将生成散列文件名,并可由插件处理优化。
  • 较小的资源体积与 assetsInlineLimit 选项值将会被内联为 base64URL

显式 URL 引入

未被包含在内部列表或 assetsInclude 中的资源,可以使用?url后缀显式导入为一个 URL。比如导入Houdini Paint Worklets 时:

1
2
import workletURL from "extra-scalloped-border/worklet.js?url";
CSS.paintWorklet.addModule(workletURL);

将资源引入为字符串

资源可以使用 ?raw 后缀声明作为字符串引入。

1
import shaderString from "./shader.glsl?raw";

导入脚本作为 Worker

脚本可以通过 ?worker?sharedworker 后缀导入为 web worker。

1
2
3
4
5
6
7
8
9
10
// 在生产构建中将会分离出 chunk
import Worker from "./shader.js?worker";
const worker = new Worker();

// sharedworker
import SharedWorker from "./shader.js?sharedworker";
const sharedWorker = new SharedWorker();

// 内联为 base64 字符串
import InlineWorker from "./shader.js?worker&inline";

public 目录

特点:

  1. 不会被源码引用(js 文件)
  2. 必须保持源文件名称(不被 hash)
  3. 或者只想要得到它的 URL

上述资源放入 public 最好。可以通过/在开发的时候直接访问到比如public/icon.png => /icon.png

默认目录:<root>/public,但可以通过publicDir配置

new URL(url, import.meta.url)

import.meta.url是 esm 的原生功能,可以暴露当前模块的 url,在 js 模块中可以通过相对路径获得一个完整的静态资源 url。

1
2
3
const imgUrl = new URL("./img.png", import.meta.url).href;

document.getElementById("hero-img").src = imgUrl;

这在现代浏览器中能够原生使用 - 实际上,Vite 并不需要在开发阶段处理这些代码!

这个模式同样还可以通过字符串模板支持动态 URL:

1
2
3
function getImageUrl(name) {
return new URL(`./dir/${name}.png`, import.meta.url).href;
}

在生产构建时,Vite 才会进行必要的转换保证 URL 在打包和资源哈希后仍指向正确的地址。

注意无法在 ssr 中使用,因为这个是 esm 的方法,也就意味着在 node 和浏览器有不同的语义,服务器也没办法预先确定客户端主机 url

构建生产版本

命令:vite build默认情况使用<root>/index.html作为构建入口。

浏览器兼容性

vite 的目标是支持 esm 的浏览器,按照条件进行 browerslist 查询的浏览器

也可以手动通过build.target配置项构建指定目标。最低支持 es2015

默认情况下 vite 不包含任何 polyfill,只负责转义,要使用相关的服务进行 polyfill 生成,Polyfill.io

传统浏览器需要插件@vitejs/plugin-legacy的支持,它将会自动生成传统版本的 chunk 与其对应 es 方面语言的 polyfill。

公共基础路径

配置 base 项所有的资源会按此路径重写,也可以通过命令行:vite build --base=/my/public/path/

总结