vite学习笔记
深入浅出vite
1、前端构建工具的痛点
-
模块化方面,提供模块加载方案,并兼容不同的模块规范。
-
语法转译方面,配合
Sass
、TSC
、Babel
等前端工具链,完成高级语法的转译功能,同时对于静态资源也能进行处理,使之能作为一个模块正常加载。 -
产物质量方面,在生产环境中,配合
Terser
等压缩工具进行代码压缩和混淆,通过Tree Shaking
删除未使用的代码,提供对于低版本浏览器的语法降级处理等等。 -
开发效率方面,构建工具本身通过各种方式来进行性能优化,包括
使用原生语言 Go/Rust
、no-bundle
等等思路,提高项目的启动性能和热更新的速度。
一般的项目使用 Webpack 之后,启动花个几分钟都是很常见的事情,热更新也经常需要等待十秒以上。这主要是因为:
- 项目冷启动时必须递归打包整个项目的依赖树
- JavaScript 语言本身的性能限制,导致构建性能遇到瓶颈,直接影响开发效率
Vite 很好地解决了这些问题。
- Vite 在开发阶段基于浏览器原生 ESM 的支持实现了
no-bundle
服务 - 借助 Esbuild 超快的编译速度来做第三方库构建和 TS/JSX 语法编译,从而能够有效提高开发效率
-
模块化方面,Vite 基于浏览器原生 ESM 的支持实现模块加载,并且无论是开发环境还是生产环境,都可以将其他格式的产物(如 CommonJS)转换为 ESM。
-
语法转译方面,Vite 内置了对 TypeScript、JSX、Sass 等高级语法的支持,也能够加载各种各样的静态资源,如图片、Worker 等等。
-
产物质量方面,Vite 基于成熟的打包工具 Rollup 实现生产环境打包,同时可以配合
Terser
、Babel
等工具链,可以极大程度保证构建产物的质量。
2、前端三大模块规范:CommonJS
、AMD
和 ES Module
2-1、无模块化标准阶段
1、文件划分:将应用的状态和逻辑分散到不同的文件中,然后通过 script 引入。
// module-a.js let data = "data"; // index.html <script src="./module-a.js"></script>
2、命名空间:可以解决 文件划分 带来的全局变量定义所带来的问题,变量冲突以及变量作用域不明确等。
// module-a.js window.moduleA = { data: "moduleA", method: function () { console.log("execute A's method"); }, };
3、立即执行函数(IIFE):模块化安全性更高,对于模块作用域的区分更加彻底。
立即执行函数会创建一个私有的作用域,在私有作用域中的变量外界是无法访问到的,只有在模块内部可以访问到。
// module-a.js (function () { let data = "moduleA"; function method() { console.log(data + "execute"); } window.moduleA = { method: method, }; })(); // index.html <script src="./module-a.js"></script>
弊端:如果模块间存在依赖关系,那么script标签的加载顺序就需要受到严格的控制。
2-2、CommonJS 规范
对于模块规范而言,一般会包含两个内容:
- 统一的模块化代码规范
- 实现自动加载模块的加载器(loader)
使用 require 来导入一个模块,用 module.exports 来导出一个模块。
存在的问题:
- 模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现,比如文件系统,如果 CommonJS 模块直接放到浏览器中是无法执行的。当然, 业界也产生了 browserify 这种打包工具来支持打包 CommonJS 模块,从而顺利在浏览器中执行,相当于社区实现了一个第三方的 loader。
- CommonJS 本身约定以同步的方式进行模块加载,这种加载机制放在服务端是没问题的。但如果这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。也就是说,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。
2-3、AMD 规范
AMD
全称为Asynchronous Module Definition
,即异步模块定义规范。
存在的问题:
- 没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader 来实现,最经典的就是 requireJS 库了
- 代码阅读和书写都比较困难
2-4、ES6 Module
由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module
已经得到了现代浏览器的内置支持。
在现代浏览器中,如果在 HTML 中加入含有type="module"
属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码。
如今的ES Module可以同时在浏览器与Node.js(12版本及以上)环境中执行,拥有天然的跨平台的能力。
2-5、总结:
- 文件划分没有解决变量冲突的问题,命名空间和IIFE虽然解决了变量冲突但是没有解决模块间的依赖问题。
- CommonJS规范适用于服务端,在浏览器端没有原生支持,并且因为需要同步模块资源,会阻塞页面解析;AMD、CMD等异步加载方案,也没有浏览器原生支持,使用方式稍显复杂;ESModule方案使用简单,并且大多数浏览器都原生支持,设置在Node环境下也可以运行,是主流的前端模块化方案。
3、vite从0开始搭建前端项目
命令:
pnpm create vite
Vite 默认会把项目根目录下的index.html
作为入口文件。也就是说,当你访问http://localhost:3000
的时候,Vite 的 Dev Server 会自动返回这个 HTML 文件的内容。
index.html:在 body
标签中除了 id 为 root 的根节点之外,还包含了一个声明了type="module"
的 script
标签。
由于现代浏览器原生支持了 ES 模块规范,因此原生的 ES 语法也可以直接放到浏览器中执行,只需要在 script 标签中声明 type="module"
即可。
比如index.html文件中的 script 标签就声明了 type="module",同时 src 指向了/src/main.tsx
文件,此时相当于请求了http://localhost:3000/src/main.tsx
这个资源,Vite 的 Dev Server 此时会接受到这个请求,然后读取对应的文件内容,进行一定的中间处理,最后将处理的结果返回给浏览器。
<script type="module" src="/src/main.tsx"></script>
在vite项目中,一个 import 语句代表一个HTTP请求。 vite的 Dev Server 来接收这些请求、进行文件的转译以及返回浏览器可以运行的代码。
Vite 所倡导的no-bundle
理念的真正含义:
利用浏览器原生 ES 模块的支持,实现开发阶段的 Dev Server,进行模块的按需加载,而不是先整体打包再进行加载。
相比 Webpack 这种必须打包再加载的传统构建模式,Vite 在开发阶段省略了繁琐且耗时的打包过程,这也是它为什么快的一个重要原因。
开发环境:Vite项目中一个import表示一个http请求,Dev Server拦截这些请求,并将请求资源转译为浏览器能运行的文件后返回。实现开发环境不打包(no-bundle),快速预览项目运行结果。
生产环境:在生产环境中Vite会使用Rollup对文件进行打包,在配置文件中通过 root 选项可以指定项目入口文件,本节的项目中还使用了tsc做ts的类型检查(只进行类型检查,不输出产物)。
vite dev server 的本质就是拦截请求,并将请求内容转成浏览器支持的格式。
4、vite中接入现代化的CSS工程方案
4-1、样式方案的意义
原生CSS存在的几大问题:
- 开发体验欠佳:原生CSS不支持选择器的嵌套。
- 样式污染问题:如果出现同样的类名,会造成样式的覆盖和污染。
- 浏览器兼容问题:一些属性(比如transition)需要加上不同的浏览器前缀(-webkit-)。
- 打包后代码体积问题:如果不使用任何的CSS工程化方案,所有的CSS代码都将打包到产物中。
针对以上痛点,社区中诞生的5大解决方案有:
- CSS预处理器:主流的包括 Sass/Scss、Less,这些方案定义了一套语法,让CSS可以嵌套,甚至可以定义变量等,解决了原生CSS开发体验问题。
- CSS Modules:能将CSS类名处理成哈希值,可以避免同名情况下的污染问题。
- CSS处理器 PostCSS:用来解析和处理CSS代码,比如将 px 转化为 rem,根据目标浏览器自动加上属性前缀(--moz--)等。
- CSS In JS:可以直接在JS中写代码,基本包含了CSS预处理器和CSS Modules的各项优点。
- CSS原子化框架:比如 Tailwind CSS,通过类名指定样式,大大简化了样式的写法,提高样式开发的效率。
4-2、CSS预处理器——Sass/Less、Less
vite本身对CSS各种预处理器语言做了内置支持,即使不经过任何配置也可以直接使用各种CSS预处理器。
pnpm i sass -D
安装后即可使用
4-3、CSS Modules
CSS Modules 在vite 也是开箱即用,vite 会对后缀带有 .module 的样式文件自动应用 CSS Moduls。
// index.tsx 重命名为 index.module.scss import styles from './index.module.scss'; export function Header() { return <p className={styles.header}>This is Header</p> };
打开浏览器,可以看到标签的类名已经处理成了哈希值的形式,也可以在 vite.config.ts 中配置文件中的 css.modules 的功能,比如:
// vite.config.ts export default { css: { modules: { // 一般我们可以通过 generateScopedName 属性来对生成的类名进行自定义 // 其中,name 表示当前文件名,local 表示类名 generateScopedName: "[name]__[local]___[hash:base64:5]" }, preprocessorOptions: { // 省略预处理器配置 } } }
此时的类名变成了:
4-4、PostCSS
由于有 CSS 代码的 AST (抽象语法树)解析能力,PostCSS 可以做的事情非常多,甚至能实现 CSS 预处理器语法和 CSS Modules,社区当中也有不少的 PostCSS 插件,常见的插件包括:
- postcss-pxtorem: 用来将 px 转换为 rem 单位,在适配移动端的场景下很常用。
- autoprefixer:用来自动为不同的目标浏览器添加样式前缀,解决的是浏览器兼容性的问题
- postcss-preset-env: 通过它,你可以编写最新的 CSS 语法,不用担心兼容性问题。
- cssnano: 主要用来压缩 CSS 代码,跟常规的代码压缩工具不一样,它能做得更加智能,比如提取一些公共样式进行复用、缩短一些常见的属性值等等。
4-5、CSS In JS
社区中有两款主流的CSS In JS
方案: styled-components
和emotion
。这两种方案已经提供了对应的 babel 插件来解决这些问题,vite要做的就是集成这些babel插件。
plugins: [ react({ babel: { // 加入 babel 插件 // 以下插件包都需要提前安装 // 当然,通过这个配置你也可以添加其它的 Babel 插件 plugins: [ // 适配 styled-component "babel-plugin-styled-components" // 适配 emotion "@emotion/babel-plugin" ] }, // 注意: 对于 emotion,需要单独加上这个配置 // 通过 `@emotion/react` 包编译 emotion 中的特殊 jsx 语法 jsxImportSource: "@emotion/react" }) ]
4-6、CSS原子化框架
css原子化框架主要包括 Tailwind CSS 和 Windi CSS(停止维护)。
1、windi的引入
pnpm i windicss vite-plugin-windicss -D
2、Tailwind css 的引入
pnpm install -D tailwindcss postcss autoprefixer
5、预构建
no-bundle
只是对于源代码(业务代码)而言,对于第三方依赖(node_modules)而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。
vite是基于浏览器原生的 ESM 模块规范实现的 Dev Server,不论是应用代码,还是第三方依赖的代码,都应当符合ESM规范才能正常运行。比如 CoomonJS 格式的代码在vite中无法直接运行,需要转换为 ESM 格式的产物
5-1、为什么需要预构建
- 将其他格式(如 UMD 和 CommonJS)的产物转换为 ESM 格式,使其在浏览器通过
<script type="module"><script>
的方式正常加载。 - 打包第三方库的代码(比如lodash),将各个第三方库分散的文件合并到一起,减少 HTTP 请求数量,避免页面加载性能劣化。
在这种依赖层级深
、涉及模块数量多
的情况下,会触发成百上千个网络请求,巨大的请求量加上 Chrome 对同一个域名下只能同时支持6
个 HTTP 并发请求的限制,导致页面加载十分缓慢
预构建这件事情都是由性能优异的 Esbuild 完成,是vite项目启动飞快的一个核心原因。
ps:Vite 1.x 版本使用了Rollup 来进行依赖预构建,在2.x 版本换成了 Esbuild ,编译速度提升了近100倍。
5-2、如何开启预构建以及预构建的产物
1、自动开启
第一次启动项目的时候,可以看到在根目录 node_modules 中发现 .vite目录,这就是预构建产物文件存放的目录,此时里面第三方包的引入路径已经被重写。
预构建产物的缓存:
- HTTP 缓存:对于依赖请求的结果,Vite 的 Dev Server 会设置强缓存,缓存过期时间设置为一年,表示缓存过期前浏览器对react预构建的产物的请求不会再经过Dev Server,直接用缓存结果。
- 本地文件系统的缓存:所有的预构建产物默认缓存在 node_modules/.vite 目录中,如果一下3个文件均无改动,将一直使用缓存文件:
- package.json 的 dependencies 字段
- 各种包管理器的lock文件
- optimizeDeps配置内容
2、手动开启
预构建中本地文件系统的产物缓存机制,在少数场景下我们不希望用本地的缓存文件,比如调试某个包的预构建结果,清除缓存方式有以下3种:
- 删除 node_modules/.vite 目录
- 在 vite 配置文件中,将 server.force 设为 true(vite3.0中是optimizeDeps.force设为true)
- 命令行执行 npx vite --force
5-3、自定义配置
1、入口文件 entries:自定义预构建的入口文件
// vite.config.ts { optimizeDeps: { // 为一个字符串数组 entries: ["./src/main.vue"]; } }
在项目第一次启动时,Vite 会默认抓取项目中所有的 HTML 文件(如当前脚手架项目中的index.html
),
将 HTML 文件作为应用入口,然后根据入口文件扫描出项目中用到的第三方依赖,最后对这些依赖逐个进行编译。
当默认扫描 HTML 文件的行为无法满足需求的时候,比如项目入口为vue
格式文件时,你可以通过 entries 参数来配置。
2、添加一些依赖 include:决定可以强制预构建的依赖项
Vite 会根据应用入口(entries
)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include
来达到完美的预构建效果了。
需要配置 include 的使用场景:
- 动态import
- 某些包被手动exclude。比如某个被exclude的包,依赖的某个组件的产物没有提供ESM的格式,导致运行时加载失败,这个时候强制对该依赖包进行预构建。
{ optimizeDeps: { include: [ // 间接依赖的声明语法,通过`>`分开, 如`a > b`表示 a 中依赖的 b "@loadable/component > hoist-non-react-statics", ]; } }
5-4、第三方包出现问题
1、改第三方库的代码
使用 patch-package 解决这类问题,可以记录第三方库代码的改动,还能将改动同步到团队成员。
{ "scripts": { // 省略其它 script "postinstall": "patch-package" } }
2、手写一个 Esbuild 插件
比如 react-virtualized 这个库包多出了一行无用代码
// vite.config.ts const esbuildPatchPlugin = { name: "react-virtualized-patch", setup(build) { build.onLoad( { filter: /react-virtualized\/dist\/es\/WindowScroller\/utils\/onScroll.js$/, }, async (args) => { const text = await fs.promises.readFile(args.path, "utf8"); return { contents: text.replace( 'import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";', "" ), }; } ); }, }; // 插件加入 Vite 预构建配置 { optimizeDeps: { esbuildOptions: { plugins: [esbuildPatchPlugin]; } } }
6、vite双引擎架构
6-1、开发环境:Esbuild——性能利器
1、依赖预构建——作为 Bundle 工具
开发环境:esbuild(性能好,速度快)
Esbuild 作为打包工具的缺点:
- 不支持降级到
ES5
的代码。这意味着在低端浏览器代码会跑不起来。 - 不支持
const enum(连一起的)等
语法。这意味着单独使用这些语法在 esbuild 中会直接抛错。 - 不提供操作打包产物的接口,像 Rollup 中灵活处理打包产物的能力(如
renderChunk
钩子)在 Esbuild 当中完全没有。 - 不支持自定义 Code Splitting 策略。传统的 Webpack 和 Rollup 都提供了自定义拆包策略的 API,而 Esbuild 并未提供,从而降级了拆包优化的灵活性。
Vite 在开发阶段使用它成功启动项目并获得极致的性能提升,生产环境处于稳定性考虑当然是采用功能更加丰富、生态更加成熟的 Rollup 作为依赖打包工具了。
2、单文件编译——作为TS 和 JSX 编译工具
Esbuild 转译 TS 或者 JSX 的能力通过 Vite 插件提供,这个vite 插件在开发环境和生产环境都会执行。虽然Esbuild Transform 能带来巨大的性能提升,但是自身也有局限性,最大的局限在于 TS 中的类型检查问题。Esbuild没有实现TS的类型检查,在编译TS时抹掉了类型相关的代码。
3、代码压缩——作为压缩工具
在生产环境中,Esbuild 压缩器通过插件的形式融入到了 Rollup 的打包流程中。
传统的压缩方式都是使用 Terser 这种JS开发的压缩器来实现,在Webpack 或者 Rollup 中作为一个 Plugin 来完成代码打包后的压缩工作,但速度很慢,主要原因有:
- 压缩这项工作涉及大量的AST 操作,并且在传统的构建流程中,AST 在各个工具之间无法共享,比如Terser 无法与Babel 共享同一个 AST,造成了很多重复解析的工作。
- JS本身属于解释型+JIT(即时编译)的语言,对于压缩这种CPU密集的工作,其性能远远比不上 Golang这种原生语言。
Esbuild从头到尾共享 AST 以及原生语言编写的minifier,在性能上能甩开传统工具的几十倍。
总结:Vite 将 Esbuild 作为自己的性能利器,将Esbuild 各个垂直方向的能力(Bundler、Transformer、Minifier)利用的淋漓尽致,给Vite 高性能提供了有利保证。
6-2、生产环境:Rollup——构建基石
Rollup既是生产环境打包的核心工具,也直接决定了 Vite 插件机制的设计。
1、生产环境 Bundle
虽然ESM 已经得到众多浏览器的原生支持,但生产环境做到完全 no-bundle 也不行,会有网络性能问题。为了在生产环境能取得优秀的产物性能,Vite 默认选择在生产环境中利用 Rollup 进行打包,并基于 Rollup 本身成熟的打包能力进行扩展和优化。
基于 Rollup 本身成熟的打包能力进行扩展和优化,主要包含 3 个方面:
-
CSS 代码分割。如果某个异步模块中引入了一些 CSS 代码,Vite 就会自动将这些 CSS 抽取出来生成单独的文件,提高线上产物的
缓存复用率
。 -
自动预加载。Vite 会自动为入口 chunk 的依赖自动生成预加载标签
<link rel="modulepreload">
。 - 异步 Chunk 加载优化。比如A和B同时以来了C。Rollup 打包之后,会先请求 A,然后浏览器在加载 A 的过程中才决定请求和加载 C。但 Vite 进行优化之后,请求 A 的同时会自动预加载 C,通过优化 Rollup 产物依赖加载方式可以减少请求的延迟。
Esbuild除了在开发环境进行依赖预构建,还可以介入生产环境的代码转译和代码压缩,凭借的正是它无比优异的构建性能,也就是快!
在 Esbuild 打包的过程会有几个关键环节,TS/JS 编译、模块打包、代码压缩,这几个环节在 Esbuild 都能够达到 AST 的复用,而在 webpack 中就做不到。
2、兼容插件机制
无论是开发环境还是生产环境,Vite 都根植于 Rollup 的插件机制和生态。
在开发阶段,Vite 借鉴了 WMR 的思路,实现了一个plugin container,用来模拟Rollup 调度各个vite 插件的执行逻辑,而vite的插件完全兼容Rollup。
Rollup的插件不一定能兼容 Vite。
结论:Esbuild 作为构建的性能利器,Vite 利用其 Bundler 的功能进行依赖预构建,用其 Transformer 的能力进行 TS 和 JSX 文件的转译,也用到它的压缩能力对JS和CSS代码进行压缩。
Rollup,在Vite当中,无论是插件机制,还是底层的打包手段,都基于Rollup 来实现。
7、Esbuild
Esbuild是基于Golang开发的一款打包工具,相比传统的打包工具,主打性能优势,在构建速度上比传统工具快10-100倍,超高的构建性能主要有以下一个原因:
- 使用Golang开发:构建逻辑代码直接被编译为原生机器码,不用像JS一样先代码解析为字节码,然后转换为机器码,大大节省了程序运行时间。
- 多核并行:内核打包算法充分利用多核CPU优势,所有的步骤尽可能的并行,这也得益于Go当中多线程共享内存的优势。
- 从零造轮子:几乎没有使用任何第三方库,所有逻辑自己编写,大到AST解析,小到字符串的操作。
- 高效的内存利用:Esbuild从头到尾尽可能地复用一份AST节点数据,而不用像JS打包工具中频繁的解析和传递AST数据,造成内存的大量浪费。
7-1、Esbuild功能的使用
7-1-1、项目打包API
const { build, buildSync, serve } = require("esbuild");
- build:异步打包。
- buildSync:同步打包,容易造成线程阻塞,丧失并发任务处理的优势;并且esbuild的所有插件都不能使用任何异步操作,给插件开发增加了限制。
- serve:开启serve模式,将在指定的端口和目录上搭建一个静态文件服务,性能比Nodejs更好;类似于 webpack-dev-server,所有的产物都默认不会写到磁盘里,而是放在内存中,通过请求服务来访问;每次请求到来时,都会进行重新构建,返回新的产物。serve 这个API只适合在开发阶段使用,不适合生产阶段。
7-1-2、单文件转译—— Transform
const {transform,transformSync} = require('esbuild') async function runTransform(){ const content = await transform("const isNull = (str: string): boolean => str.length > 0;", { sourcemap:true, loader:'tsx' }) console.log(content) // const isNull = (str) => str.length > 0 } runTransform()
7-2、Esbuild 插件开发
7-2-1、基本概念
基本概念:插件开发就是在原有的体系结构中进行扩展和自定义。
Esbuild插件,可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。
Esbuild插件被设计为一个对象,里面有 name 和 setup 两个属性。
let envPlugin = { name: 'env', setup(build) { build.onResolve({ filter: /^env$/ }, args => ({ path: args.path, namespace: 'env-ns', })) build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', })) }, } // 插件的使用 require('esbuild').build({ entryPoints: ['src/index.jsx'], bundle: true, outfile: 'out.js', // 应用插件 plugins: [envPlugin], }).catch(() => process.exit(1))
7-2-2、onResolve 钩子和 onLoad 钩子
onResolve 钩子和 onLoad 钩子是非常重要的两个钩子,分别控制路径解析和模块内容加载的过程。
这两个钩子函数都必须传入两个参数:Options 和 Callback。
1、Options:是一个对象,包含了 filter 和 namespace 两个属性。
interface Options { filter: RegExp; // 必传,是一个正则表达式,决定了要过滤出的特征文件,该正则使用的是 Go开发的,和JS正则有所区别 namespace?: string; }
2、namespace 为选填参数,一般在 onResolve 的钩子中的回调参数返回 namespace 属性作为标识,可以在 onLoad 钩子中通过 namespace 将模块过滤出来。
3、回调函数 callback:不同的钩子类型不一样。
onResolve:args: onResolveArgs
onLoad:args.onLoadArgs
7-2-3、其他钩子
onStart 和 onEnd 两个钩子用来在构建开启和结束时执行一些自定义的逻辑。
8、Rollup
Rollup 具有天然的 Tree Shaking
功能,可以分析出未使用到的模块并自动擦除。这是由于Rollup 可以在编译阶段分析出依赖关系,对 AST 语法树中没有使用到的节点进行删除,从而实现 Tree Shaking。
虽然 Rollup 能够打包输出
出 CommonJS
格式的产物,但对于输入
给 Rollup 的代码并不支持 CommonJS,仅仅支持 ESM。
8-1、常用配置解读
8-1-1、多产物配置
可以对外暴露出不同格式的产物供他人使用,不仅包括ESM,也包括CommonJS、UMD等格式。
// rollup.config.js const buildOptions = { input: ["src/index.js"], // 将 output 改造成一个数组 output: [ { dir: "dist/es", format: "esm", }, { dir: "dist/cjs", format: "cjs", }, ], }; export default buildOptions;
8-1-2、多入口配置
{ input: ["src/index.js", "src/util.js"] } // 或者 { input: { index: "src/index.js", util: "src/util.js", }, }
8-1-3、依赖external
不用rollup进行打包
{ external: ['react', 'react-dom'] }
8-1-4、接入插件能力
在 rollup 日常使用中,会遇到一些 rollup 本身不支持的场景,比如 兼容CommonJS打包、注入环境变量、配置路径别名、压缩产物代码等。这时候需要引入相应的 rollup 插件。
2个核心插件:
@rollup/plugin-node-resolve
是为了允许我们加载第三方依赖,否则像import React from 'react'
的依赖导入语句将不会被 Rollup 识别。@rollup/plugin-commonjs
的作用是将 CommonJS 格式的代码转换为 ESM 格式。
// 通过 plugins 参数添加插件 plugins: [resolve(), commonjs()],
其他常用的插件库:
- @rollup/plugin-json: 支持
.json
的加载,并配合rollup
的Tree Shaking
机制去掉未使用的部分,进行按需打包。 - @rollup/plugin-babel:在 Rollup 中使用 Babel 进行 JS 代码的语法转译。
- @rollup/plugin-typescript: 支持使用 TypeScript 开发。
- @rollup/plugin-alias:支持别名配置。
- @rollup/plugin-replace:在 Rollup 进行变量字符串的替换。
- rollup-plugin-visualizer: 对 Rollup 打包产物进行分析,自动生成产物体积可视化分析图。
- rollup-plugin-terser:压缩代码。
8-1-5、使用JS的API方式调用
以上是通过 rollup.config.js 配合 rollup -c 完成了 Rollup的打包过程,但有些场景下我们需要基于 Rollup 定制一些打包过程。配置文件就不够灵活了,这时候需要用到 JS的API来调用Rollup。
主要分为 rollup.rollup 和 rollup.watch 两个API。
// build.js const rollup = require("rollup"); // 常用 inputOptions 配置 const inputOptions = { input: "./src/index.js", external: [], plugins:[] }; const outputOptionsList = [ // 常用 outputOptions 配置 { dir: 'dist/es', entryFileNames: `[name].[hash].js`, chunkFileNames: 'chunk-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]', format: 'es', sourcemap: true, globals: { lodash: '_' } } // 省略其它的输出配置 ]; async function build() { let bundle; let buildFailed = false; try { // 1. 调用 rollup.rollup 生成 bundle 对象 bundle = await rollup.rollup(inputOptions); for (const outputOptions of outputOptionsList) { // 2. 拿到 bundle 对象,根据每一份输出配置,调用 generate 和 write 方法分别生成和写入产物 const { output } = await bundle.generate(outputOptions); await bundle.write(outputOptions); } } catch (error) { buildFailed = true; console.error(error); } if (bundle) { // 最后调用 bundle.close 方法结束打包 await bundle.close(); } process.exit(buildFailed ? 1 : 0); } build();
rollup.rollup的主要步骤有:
- 通过 rollup.rollup 方法,传入 inputOptions,生成bundle 对象
- 通过bundle对象的generate和write 方法,传入 outputOptions,分别完成产物的生成和磁盘写入
- 调用bundle 对象的close 方法来结束打包
- 执行node build.js
rollup.watch 开启rollup的watch模式。
8-2、Rollup的插件机制
插件机制:Rollup 设计出了一套完整的插件机制,将自身的核心逻辑与插件逻辑分离,让你能按需引入插件功能,提高了 Rollup 自身的可扩展性。
生命周期钩子:Rollup 的打包过程中,会定义一套完整的构建生命周期,从开始打包到产物输出,中途会经历一些标志性的阶段,并且在不同阶段会自动执行对应的插件钩子函数(Hook)。
Rollup整体构建阶段的逻辑可以简化为 Build
和 Output
两大阶段:
// Build 阶段 const bundle = await rollup.rollup(inputOptions); // Output 阶段 await Promise.all(outputOptions.map(bundle.write)); // 构建结束 await bundle.close();
打包如下代码为例:
// src/index.js import { a } from './module-a'; console.log(a); // src/module-a.js export const a = 1;
1、Build阶段——分析模块依赖关系
Build 阶段主要负责创建模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系。
下面为一个bundle对象的信息:
{ ast: 'AST 节点信息,具体内容省略', code: 'export const a = 1;', dependencies: [], id: '/Users/code/rollup-demo/src/data.js', // 其它属性省略 },
目前经过 Build 阶段的 bundle
对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时暴露generate
和write
方法,以进入到后续的 Output
阶段(write
和generate
方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会)。
所以,真正进行打包的过程会在 Output
阶段进行,即在bundle
对象的 generate
或者write
方法中进行。
2、Output阶段——打包的过程
const rollup = require('rollup'); async function build() { const bundle = await rollup.rollup({ input: ['./src/index.js'], }); const result = await bundle.generate({ format: 'es', }); console.log('result:', result); } build();
示例代码执行打包结果后的输出如下:
{ output: [ { exports: [], facadeModuleId: '/Users/code/rollup-demo/src/index.js', isEntry: true, isImplicitEntry: false, type: 'chunk', code: 'const a = 1;\n\nconsole.log(a);\n', dynamicImports: [], fileName: 'index.js', // 其余属性省略 } ] }
对于一次完整的构建过程而言, Rollup 会先进入到 Build 阶段,解析各模块的内容及依赖关系,然后进入Output
阶段,完成打包及输出的过程。
对于不同的阶段,Rollup 插件会有不同的插件工作流程。
3、插件Hook类型
Rollup 为了追求扩展性和可维护性,引入了插件机制。
分类:
插件的各种 Hook 可以根据这两个构建阶段分为两类: Build Hook
与 Output Hook。
- Build Hook:即在 Build 阶段执行的钩子函数,主要是进行模块代码的转换、AST解析以及模块依赖的解析。操作粒度为 模块 级别。
- Output Hook:主要进行代码的打包,操作粒度一般为 chunk 级别(一个chunk 通常指很多文件打包到一起的产物)。
根据不同的 Hook 执行方式也会有不同的分类,主要包括 Async
、Sync
、Parallel
、Squential
、First
这五种。
1)、async 和 sync
两者其实是相对的,分别代表异步
和同步
的钩子函数,两者最大的区别在于同步钩子里面不能有异步逻辑,而异步钩子可以有。
2)、Parallel
并行的钩子函数。如果有多个插件实现了这个钩子的逻辑,一旦有钩子函数是异步逻辑,则并发执行钩子函数,不会等待当前钩子完成(底层使用 Promise.all
)。
它的执行时机其实是在构建刚开始的时候,各个插件可以在这个钩子当中做一些状态的初始化操作,但其实插件之间的操作并不是相互依赖的,也就是可以并发执行,从而提升构建性能。反之,对于需要依赖其他插件处理结果的情况就不适合用 Parallel
钩子了,比如 transform
。
3)、Sequential
串行的钩子函数。这种 Hook 往往适用于插件间处理结果相互依赖的情况,前一个插件 Hook 的返回值作为后续插件的入参,这种情况就需要等待前一个插件执行完 Hook,获得其执行结果,然后才能进行下一个插件相应 Hook 的调用,如transform
。
4)、First
多个插件实现了这个 Hook,那么 Hook 将依次运行,直到返回一个非 null 或非 undefined 的值为止。比较典型的 Hook 是 resolveId
,一旦有插件的 resolveId 返回了一个路径,将停止执行后续插件的 resolveId 逻辑。
4、build阶段的工作流
-
首先经历
options
钩子进行配置的转换,得到处理后的配置对象。 -
随之 Rollup 会调用
buildStart
钩子,正式开始构建流程。 -
Rollup 先进入到
resolveId
钩子中解析文件路径。(从input
配置指定的入口文件开始)。 -
Rollup 通过调用
load
钩子加载模块内容。 -
紧接着 Rollup 执行所有的
transform
钩子来对模块内容进行进行自定义的转换,比如 babel 转译。 -
现在 Rollup 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子:
- 6.1 如果是普通的 import,则执行
resolveId
钩子,继续回到步骤3
。 - 6.2 如果是动态 import,则执行
resolveDynamicImport
钩子解析路径,如果解析成功,则回到步骤4
加载模块,否则回到步骤3
通过resolveId
解析路径。
- 6.1 如果是普通的 import,则执行
-
直到所有的 import 都解析完毕,Rollup 执行
buildEnd
钩子,Build 阶段结束
5、Output阶段的工作流
-
执行所有插件的
outputOptions
钩子函数,对output
配置进行转换。 -
执行
renderStart
,并发执行 renderStart 钩子,正式开始打包。 -
并发执行所有插件的
banner
、footer
、intro
、outro
钩子(底层用 Promise.all 包裹所有的这四种钩子函数),这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。 -
从入口模块开始扫描,针对动态 import 语句执行
renderDynamicImport
钩子,来自定义动态 import 的内容。 -
对每个即将生成的
chunk
,执行augmentChunkHash
钩子,来决定是否更改 chunk 的哈希值,在watch
模式下即可能会多次打包的场景下,这个钩子会比较适用。 -
如果没有遇到
import.meta
语句,则进入下一步,否则:- 6.1 对于
import.meta.url
语句调用resolveFileUrl
来自定义 url 解析逻辑 - 6.2 对于其他
import.meta
属性,则调用resolveImportMeta
来进行自定义的解析。
- 6.1 对于
-
接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的
renderChunk
方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。 -
随后会调用
generateBundle
钩子,这个钩子的入参里面会包含所有的打包产物信息,包括chunk
(打包后的代码)、asset
(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。 -
前面提到了
rollup.rollup
方法会返回一个bundle
对象,这个对象是包含generate
和write
两个方法,两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发writeBundle
钩子,传入所有的打包产物信息,包括 chunk 和 asset,和generateBundle
钩子非常相似。不过值得注意的是,这个钩子执行的时候,产物已经输出了,而 generateBundle 执行的时候产物还并没有输出。顺序如下图所示:
- 当上述的
bundle
的close
方法被调用时,会触发closeBundle
钩子,到这里 Output 阶段正式结束。
Rollup 的插件开发整体上是非常简洁和灵活的,总结为以下几个方面:
- 插件逻辑集中管理。各个阶段的 Hook 都可以放在一个插件中编写,比如上述两个 Webpack 的 Loader 和 Plugin 功能在 Rollup 只需要用一个插件,分别通过 transform 和 renderChunk 两个 Hook 来实现。
- 插件 API 简洁,符合直觉。Rollup 插件基本上只需要返回一个包含 name 和各种钩子函数的对象即可,也就是声明一个 name 属性,然后写几个钩子函数即可。
- 插件间的互相调用。比如刚刚介绍的
alias
插件,可以通过插件上下文对象的resolve
方法,继续调用其它插件的resolveId
钩子,类似的还有load
方法,这就大大增加了插件的灵活性。
常见的Hook实战:resolveId、load、transform、renderChunk、generateBundle。
问答题:
1.为什么Rollup设计了一套完整的插件机制?
2.在Rollup一次完整的构建过程中,Rollup会经历哪两个阶段?每个阶段的作用是什么?
3.谈谈你对插件Hook类型的理解?
4.根据Hook执行方式可以把插件分成哪几类?
5.请描述一下Rollup插件在build阶段的工作流程?
6.请描述一下Rollup插件在Output阶段的工作流程?
答案:
1. 将核心逻辑和插件逻辑分离,按需引入插件,提高拓展性。
2. 两个阶段,分别是构建(build)阶段和打包(output)阶段;build阶段主要进行模块代码的转换、AST解析以及模块依赖的解析;output阶段主要进行代码打包;
3. 不同的插件Hook类型代表了不同的执行特点,会围绕在build和output两个阶段起至关重要的作用。
4. Build Hook和Output Hook
5. 首先经历options配置转换得到配置对象,随后调用buildStart开始构建流程,进入resolveId中解析文件路径,加载模块内容,接着执行所有的transform来对模块内容进行自定义转换,最后进行AST分析得到所有的import内容,一直等待解析完毕执行buildEnd,build阶段结束。
6. 首先执行所有插件的outputOptions对配置进行转换,然后执行renderStart进行正式打包,随后并发执行所有插件的banner、footer、intro、outro钩子往打包产物中插入一些自定义内容,针对动态import语句执行renderDynamicImport钩子,对每个生成的chunk执行augmentChunHash钩子,决定是否更改chunk的哈希值,生成所有chunk内容后,调用renderChunk方法进行自定义操作,这时你可以直接操作打包产物了,随后调用generateBundle钩子,可以在这里删除一些chunk或者asset,这些东西不会作为产物输出,最后调用close出发closeBundle钩子,output阶段结束。
10、如何开发完整的vite插件
1、插件和插件执行的顺序
vite独有的的5个钩子:
config
: 用来进一步修改配置。configResolved
: 用来记录最终的配置信息。configureServer
: 用来获取 Vite Dev Server 实例,添加中间件。transformIndexHtml
: 用来转换 HTML 的内容。handleHotUpdate
: 用来进行热更新模块的过滤,或者进行自定义的热更新处理。
Vite 插件的执行顺序:
- 服务启动阶段:
config
、configResolved
、options
、configureServer
、buildStart
- 请求响应阶段: 如果是
html
文件,仅执行transformIndexHtml
钩子;对于非 HTML 文件,则依次执行resolveId
、load
和transform
钩子。相信大家学过 Rollup 的插件机制,已经对这三个钩子比较熟悉了。 - 热更新阶段: 执行
handleHotUpdate
钩子。 - 服务关闭阶段: 依次执行
buildEnd
和closeBundle
钩子。
2、插件应用的位置
默认情况下 Vite 插件同时被用于开发环境和生产环境,你可以通过apply
属性来决定应用场景。
{ // 'serve' 表示仅用于开发环境,'build'表示仅用于生产环境 apply: 'serve' }
apply
参数还可以配置成一个函数,进行更灵活的控制:
apply(config, { command }) { // 只用于非 SSR 情况下的生产环境构建 return command === 'build' && !config.build.ssr }
可以通过enforce
属性来指定插件的执行顺序:
{ // 默认为`normal`,可取值还有`pre`和`post` enforce: 'pre' }
vite中插件的执行顺序:
- Alias (路径别名)相关的插件。
- ⭐️ 带有
enforce: 'pre'
的用户插件。 - Vite 核心插件。
- ⭐️ 没有 enforce 值的用户插件,也叫
普通插件
。 - Vite 生产环境构建用的插件。
- ⭐️ 带有
enforce: 'post'
的用户插件。 - Vite 后置构建插件(如压缩插件)。
11、HMR----模块级别的局部更新
HMR 的全称叫做Hot Module Replacement
,即模块热替换
或者模块热更新,实现
局部刷新
和状态保存。
import.meta
对象为现代浏览器原生的一个内置对象,Vite 所做的事情就是在这个对象上的 hot
属性中定义了一套完整的属性和方法。因此,在 Vite 当中,你就可以通过import.meta.hot
来访问关于 HMR 的这些属性和方法,比如import.meta.hot.accept()。
1)模块更新逻辑:hot.accept
接受模块更新:
- 接受自身模块的更新
- 接受某个子模块的更新
- 接受多个子模块的更新
2)模块销毁时的逻辑:hot.dispose
3)共享数据:hot.data
4)其他方法:
- import.meta.hot.decline():此模块不可热更新
- import.meta.hot.invalidate():强制刷新页面 自定义事件:
- import.meta.hot.on 来监听HMR的自定义事件,内部有以下事件会自动触发:
vite:beforeUpdate
当模块更新时触发;vite:beforeFullReload
当即将重新刷新页面时触发;vite:beforePrune
当不再需要的模块即将被剔除时触发;vite:error
当发生错误时(例如,语法错误)触发。
12、拆包解决打包产物体积过大
生产环境下,为了提高页面加载性能,构建工具一般将项目的代码打包(bundle)到一起,这样上线之后只需要请求少量的 JS 文件,大大减少 HTTP 请求。
vite利用底层打包引擎 Rollup拆包API——manualChunks 来完成项目的模块打包。
在生产环境下 Vite 完全利用 Rollup 进行构建,因此拆包也是基于 Rollup 来完成的,但 Rollup 本身是一个专注 JS 库打包的工具,对应用构建的能力还尚为欠缺,Vite 正好是补足了 Rollup 应用构建的能力。
构建领域的专业概念:
bundle
指的是整体的打包产物,包含 JS 和各种静态资源。chunk
指的是打包后的 JS 文件,是bundle
的子集。vendor
是指第三方包的打包产物,是一种特殊的 chunk。
传统的单 chunk 打包模式下的问题:
- 无法做到按需加载,即使是当前页面不需要的代码也会进行加载。
无论是Initial Chunk
还是Async Chunk
,都会打包进同一个产物。 - 线上缓存复用率极低,改动一行代码即可导致整个 bundle 产物缓存失效。
服务端一般在响应资源时加上一些 HTTP 响应头,最常见的响应头之一就是cache-control
,它可以指定浏览器的强缓存。
构建工具一般会根据产物的内容生成哈希值,一旦内容变化就会导致整个 chunk 产物的强缓存失效,所以单 chunk 打包模式下的缓存命中率极低,基本为零。
一般而言,把不经常变化的第三方包拆出来。
1)vite默认的拆包策略
产物的结构:
- Vite 实现了自动 CSS 代码分割的能力,即实现一个 chunk 对应一个 css 文件,比如上面产物中
index.js
对应一份index.css
,而按需加载的 chunkDanamic.js
也对应单独的一份Danamic.css
文件,与 JS 文件的代码分割同理,这样做也能提升 CSS 文件的缓存复用率。 -
Vite 基于 Rollup 的
manualChunks
API 实现了应用拆包
的策略:-
对于
Initital Chunk
而言,业务代码和第三方包代码分别打包为单独的 chunk,在上述的例子中分别对应index.js
和vendor.js
。需要说明的是,这是 Vite 2.9 版本之前的做法,而在 Vite 2.9 及以后的版本,默认打包策略更加简单粗暴,将所有的 js 代码全部打包到index.js
中。 -
对于
Async Chunk
而言 ,动态 import 的代码会被拆分成单独的 chunk,如上述的Dynacmic
组件。
-
Vite 默认拆包的优势在于实现了 CSS 代码分割与业务代码、第三方库代码、动态 import 模块代码三者的分离,但缺点也比较直观,第三方库的打包产物容易变得比较臃肿。
2)自定义拆包策略
{ build: { rollupOptions: { output: { // manualChunks 配置 manualChunks: { // 将 React 相关库打包成单独的 chunk 中 'react-vendor': ['react', 'react-dom'], // 将 Lodash 库的代码单独打包 'lodash': ['lodash-es'], // 将组件库的代码打包 'library': ['antd', '@arco-design/web-react'], }, }, } }, }
第三方包更新的时候,也只会更新其中一个 chunk 的 url,而不会全量更新,从而提高了第三方包产物的缓存命中率。但是此方式需要手动解决循环依赖的问题。
产物结构如下图:
3)Vite 自定义拆包的终极解决方案——vite-plugin-chunk-split
import { chunkSplitPlugin } from 'vite-plugin-chunk-split'; export default { chunkSplitPlugin({ // 指定拆包策略 customSplitting: { // 1. 支持填包名。`react` 和 `react-dom` 会被打包到一个名为`render-vendor`的 chunk 里面(包括它们的依赖,如 object-assign) 'react-vendor': ['react', 'react-dom'], // 2. 支持填正则表达式。src 中 components 和 utils 下的所有文件被会被打包为`component-util`的 chunk 中 'components-util': [/src\/components/, /src\/utils/] } }) }
开箱即用的拆包方案,无需手动解决循环依赖的问题。
13、联合前端编译工具链,消灭低版本浏览器兼容问题
通过 Vite 构建我们完全可以兼容各种低版本浏览器,打包出既支持现代(Modern
)浏览器又支持旧版(Legacy
)浏览器的产物。
旧版浏览器的语法兼容问题主要分两类: 语法降级问题和 Polyfill 缺失问题。前者比较好理解,比如某些浏览器不支持箭头函数,我们就需要将其转换为function(){}
语法;而对后者来说,Polyfill
本身可以翻译为垫片
,也就是为浏览器提前注入一些 API 的实现代码,如Object.entries
方法的实现,这样可以保证产物可以正常使用这些 API,防止报错。
这两类问题本质上是通过前端的编译工具链(如Babel
)及 JS 的基础 Polyfill 库(如corejs
)来解决的,不会跟具体的构建工具所绑定。也就是说,对于这些本质的解决方案,在其它的构建工具(如 Webpack)能使用,在 Vite 当中也完全可以使用。
解决上述提到的两类语法兼容问题,主要需要用到两方面的工具,分别包括:
-
编译时工具:代表工具有
@babel/preset-env
和@babel/plugin-transform-runtime
。 -
运行时基础库:代表库包括
core-js
和regenerator-runtime
。
编译时工具的作用是在代码编译阶段进行语法降级及添加 polyfill
代码的引用语句。由于这些工具只是编译阶段用到,运行时并不需要,我们需要将其放入package.json
中的devDependencies
中。
运行时基础库是根据 ESMAScript
官方语言规范提供各种Polyfill
实现代码,主要包括core-js
和regenerator-runtime
两个基础库,不过在 babel 中也会有一些上层的封装,包括:
- @babel/polyfill
- @babel/runtime
- @babel/runtime-corejs2
- @babel/runtime-corejs3 看似各种运行时库眼花缭乱,其实都是
core-js
和regenerator-runtime
不同版本的封装罢了(@babel/runtime
是个特例,不包含 core-js 的 Polyfill)。这类库是项目运行时必须要使用到的,因此一定要放到package.json
中的dependencies
中!
1)、@babel/preset-env方案
// .babelrc.json { "presets": [ [ "@babel/preset-env", { // 指定兼容的浏览器版本 "targets": { "ie": "11" }, // 基础库 core-js 的版本,一般指定为最新的大版本 "corejs": 3, // Polyfill 注入策略,后文详细介绍 "useBuiltIns": "usage", // 不将 ES 模块语法转换为其他模块语法 "modules": false } ] ] }
useBuiltIns
,它决定了添加 Polyfill 策略,默认是 false
,即不添加任何的 Polyfill。可以手动将useBuiltIns
配置为entry
或者usage。
usage表示
按需Polyfill导入的配置。
以上配置利用@babel/preset-env
进行了目标浏览器语法的降级和Polyfill
注入,但是@babel/preset-env方案存在局限性:
- 如果使用新特性,往往是通过基础库(如 core-js)往全局环境添加 Polyfill,如果是开发应用没有任何问题,如果是开发第三方工具库,则很可能会对全局空间造成污染。
- 很多工具函数的实现代码(如上面示例中的
_defineProperty
方法),会在许多文件中重现出现,造成文件体积冗余。
2)更优的 Polyfill 注入方案: transform-runtime
transform-runtime
方案可以作为@babel/preset-env
中useBuiltIns
配置的替代品,也就是说,一旦使用transform-runtime
方案,你应该把useBuiltIns
属性设为 false
。
// babelrc.json { "plugins": [ // 添加 transform-runtime 插件 [ "@babel/plugin-transform-runtime", { "corejs": 3 } ] ], "presets": [ [ "@babel/preset-env", { "targets": { "ie": "11" }, "corejs": 3, // 关闭 @babel/preset-env 默认的 Polyfill 注入 "useBuiltIns": false, "modules": false } ] ] }
transform-runtime
一方面能够让我们在代码中使用非全局版本
的 Polyfill,这样就避免全局空间的污染,这也得益于core-js
的 pure 版本产物特性。
3)viet语法降级与Polyfill注入
Vite 官方已经为我们封装好了一个开箱即用的方案: @vitejs/plugin-legacy
,我们可以基于它来解决项目语法的浏览器兼容问题。这个插件内部使用 @babel/preset-env
以及 core-js
等一系列基础库来进行语法降级和 Polyfill 注入。底层也是通过@babel/preset-env
来完成兼容方案的。
import legacy from "@vitejs/plugin-legacy;"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [legacy({ targets: ["ie >= 11"] })], });
legacy
插件是一个相对复杂度比较高的插件,插件在各个钩子阶段的流程图如下:
- 首先是在
configResolved
钩子中调整了output
属性,这么做的目的是让 Vite 底层使用的打包引擎 Rollup 能另外打包出一份Legacy 模式
的产物。 - 在
renderChunk
阶段,插件会对 Legacy 模式产物进行语法转译和 Polyfill 收集,值得注意的是,这里并不会真正注入Polyfill
,而仅仅只是收集Polyfill。
- 进入
generateChunk
钩子阶段,现在 Vite 会对之前收集到的Polyfill
进行统一的打包,实现也比较精妙,主要逻辑集中在buildPolyfillChunk
函数。 - 通过
transformIndexHtml
钩子来将这些产物插入到 HTML 的结构中。
14、vite系统的进行性能优化
对于项目的加载性能优化而言,常见的优化手段可以分为下面三类:
- 网络优化。包括
HTTP2
、DNS 预解析
、Preload
、Prefetch
等手段。 - 资源优化。包括
构建产物分析
、资源压缩
、产物拆包
、按需加载
等优化方式。 - 预渲染优化,本文主要介绍
服务端渲染
(SSR)和静态站点生成
(SSG)两种手段。
1-1)、HTTP2
传统的 HTTP 1.1
存在队头阻塞的问题,同一个 TCP 管道中同一时刻只能处理一个 HTTP 请求,也就是说如果当前请求没有处理完,其它的请求都处于阻塞状态,另外浏览器对于同一域名下的并发请求数量都有限制,比如 Chrome 中只允许 6
个请求并发(这个数量不允许用户配置),也就是说请求数量超过 6 个时,多出来的请求只能排队、等待发送。
因此,在 HTTP 1.1 协议中,队头阻塞和请求排队问题很容易成为网络层的性能瓶颈。而 HTTP 2 的诞生就是为了解决这些问题,它主要实现了如下的能力:
- 多路复用:将数据分为多个二进制帧,多个请求和响应的数据帧在同一个 TCP 通道进行传输,解决了之前的队头阻塞问题。而与此同时,在 HTTP2 协议下,浏览器不再有同域名的并发请求数量限制,因此请求排队问题也得到了解决。
- Server Push:即服务端推送能力。可以让某些资源能够提前到达浏览器,比如对于一个 html 的请求,通过 HTTP 2 我们可以同时将相应的 js 和 css 资源推送到浏览器,省去了后续请求的开销。
vite-plugin-mkcert
插件仅用于开发阶段,在生产环境中我们会对线上的服务器进行配置,从而开启 HTTP2 的能力,如 Nginx 的 HTTP2 配置。
1-2)、DNS解析
浏览器在向跨域的服务器发送请求时,首先会进行 DNS 解析,将服务器域名解析为对应的 IP 地址。我们通过 dns-prefetch
技术将这一过程提前,降低 DNS 解析的延迟时间,具体使用方式如下:
<!-- href 为需要预解析的域名 --> <link rel="dns-prefetch" href="https://fonts.googleapis.com/">
一般情况下 dns-prefetch
会与preconnect
搭配使用,前者用来解析 DNS,而后者用来会建立与服务器的连接,建立 TCP 通道及进行 TLS 握手,进一步降低请求延迟。使用方式如下所示:
<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin> <link rel="dns-prefetch" href="https://fonts.gstatic.com/">
1-3)、Preload/Prefetch
对于一些比较重要的资源,我们可以通过 Preload
方式进行预加载,即在资源使用之前就进行加载,而不是在用到的时候才进行加载,这样可以使资源更早地到达浏览器。
<link rel="preload" href="style.css" as="style"> <link rel="preload" href="main.js" as="script">
与普通 script 标签不同的是,对于原生 ESM 模块,浏览器提供了modulepreload
来进行预加载:
<link rel="modulepreload" href="/src/app.js" />
2-1)、产物分析报告
// 注: 首先需要安装 rollup-plugin-visualizer 依赖 import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { visualizer } from "rollup-plugin-visualizer"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), visualizer({ // 打包完成后自动打开浏览器,显示产物体积报告 open: true, }), ], });
2-2)、资源压缩
// vite.config.ts export default { build: { // 类型: boolean | 'esbuild' | 'terser' // 默认为 `esbuild` minify: 'esbuild', // 产物目标环境 target: 'modules', // 如果 minify 为 terser,可以通过下面的参数配置具体行为 // https://terser.org/docs/api-reference#minify-options terserOptions: {} } }
target参数:Vite 默认的参数是modules。
设置合适的 target
特别重要,一旦目标环境的设置不能覆盖所有的用户群体,那么极有可能在某些低端浏览器中出现语法不兼容问题,从而发生线上事故。
为了线上的稳定性,推荐大家最好还是将 target 参数设置为ECMA
语法的最低版本es2015
/es6
。
图片压缩:Vite 中我们一般使用 vite-plugin-imagemin
来进行图片压缩。
2-3)、产物拆包
一般来说,如果不对产物进行代码分割
(或者拆包
),全部打包到一个 chunk 中,会产生如下的问题:
- 首屏加载的代码体积过大,即使是当前页面不需要的代码也会进行加载。
- 线上缓存复用率极低,改动一行代码即可导致整个 bundle 产物缓存失效。
而 Vite 中内置如下的代码拆包能力:
- CSS 代码分割,即实现一个 chunk 对应一个 css 文件。
- 默认有一套拆包策略,将应用的代码和第三方库的代码分别打包成两份产物,并对于动态 import 的模块单独打包成一个 chunk。
// vite.config.ts { build { rollupOptions: { output: { // 1. 对象配置 manualChunks: { // 将 React 相关库打包成单独的 chunk 中 'react-vendor': ['react', 'react-dom'], // 将 Lodash 库的代码单独打包 'lodash': ['lodash-es'], // 将组件库的代码打包 'library': ['antd'], }, // 2. 函数配置 需要注意循环引用的问题 if (id.includes('antd') || id.includes('@arco-design/web-react')) { return 'library'; } if (id.includes('lodash')) { return 'lodash'; } if (id.includes('react')) { return 'react'; } }, } }, }
2-4)、按需加载
一个比较好的方式是对路由组件进行动态引入。
3-1)、预渲染优化
SSG 可以在构建阶段生成完整的 HTML 内容,它与 SSR 最大的不同在于 HTML 的生成在构建阶段完成,而不是在服务器的运行时。
SSG 同样可以给浏览器完整的 HTML 内容,不依赖于 JS 的加载,可以有效提高页面加载性能。
不过相比 SSR,SSG 的内容往往动态性不够,适合比较静态的站点,比如文档、博客等场景。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET10 - 预览版1新功能体验(一)