深入浅出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
CommonJS 规范
存在的问题:
- 模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现,比如文件系统,如果 CommonJS 模块直接放到浏览器中是无法执行的。当然, 业界也产生了 browserify 这种打包工具来支持打包 CommonJS 模块,从而顺利在浏览器中执行,相当于社区实现了一个第三方的 loader。
- CommonJS 本身约定以同步的方式进行模块加载,这种加载机制放在服务端是没问题的。但如果这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。也就是说,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。
AMD 规范
AMD
全称为Asynchronous Module Definition
,即异步模块定义规范。
存在的问题:
- 没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader 来实现,最经典的就是 requireJS 库了
- 代码阅读和书写都比较困难
ES6 Module
由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module
已经得到了现代浏览器的内置支持。
在现代浏览器中,如果在 HTML 中加入含有type="module"
属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码。
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 所倡导的no-bundle
理念的真正含义:
利用浏览器原生 ES 模块的支持,实现开发阶段的 Dev Server,进行模块的按需加载,而不是先整体打包再进行加载。
相比 Webpack 这种必须打包再加载的传统构建模式,Vite 在开发阶段省略了繁琐且耗时的打包过程,这也是它为什么快的一个重要原因。
开发环境:Vite项目中一个import表示一个http请求,Dev Server拦截这些请求,并将请求资源转译为浏览器能运行的文件后返回。实现开发环境不打包(no-bundle),快速预览项目运行结果。
生产环境:在生产环境中Vite会使用Rollup对文件进行打包,在配置文件中通过 root 选项可以指定项目入口文件,本节的项目中还使用了tsc做ts的类型检查(只进行类型检查,不输出产物)。
vite dev server 的本质就是拦截请求,并将请求内容转成浏览器支持的格式。
4、PostCSS
由于有 CSS 代码的 AST (抽象语法树)解析能力,PostCSS 可以做的事情非常多,甚至能实现 CSS 预处理器语法和 CSS Modules,社区当中也有不少的 PostCSS 插件,常见的插件包括:
- postcss-pxtorem: 用来将 px 转换为 rem 单位,在适配移动端的场景下很常用。
- autoprefixer:用来自动为不同的目标浏览器添加样式前缀,解决的是浏览器兼容性的问题
- postcss-preset-env: 通过它,你可以编写最新的 CSS 语法,不用担心兼容性问题。
- cssnano: 主要用来压缩 CSS 代码,跟常规的代码压缩工具不一样,它能做得更加智能,比如提取一些公共样式进行复用、缩短一些常见的属性值等等。
CSS In JS
社区中有两款主流的CSS In JS
方案: styled-components
和emotion
。
5、预构建
no-bundle
只是对于源代码(业务代码)而言,对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程。
为什么需要预构建:
- 将其他格式(如 UMD 和 CommonJS)的产物转换为 ESM 格式,使其在浏览器通过
<script type="module"><script>
的方式正常加载。 - 打包第三方库的代码,将各个第三方库分散的文件合并到一起,减少 HTTP 请求数量,避免页面加载性能劣化。
在这种依赖层级深
、涉及模块数量多
的情况下,会触发成百上千个网络请求,巨大的请求量加上 Chrome 对同一个域名下只能同时支持6
个 HTTP 并发请求的限制,导致页面加载十分缓慢
预构建产物:
根目录下的node_modules
中发现.vite
目录,这就是预构建产物文件存放的目录。
对于依赖的请求结果,Vite 的 Dev Server 会设置强缓存,缓存过期时间被设置为一年。
除了 HTTP 缓存,Vite 还设置了本地文件系统的缓存,所有的预构建产物默认缓存在node_modules/.vite
目录中。如果以下 3 个地方都没有改动,Vite 将一直使用缓存文件:
- package.json 的
dependencies
字段 - 各种包管理器的 lock 文件
optimizeDeps
配置内容
清除缓存:
- 删除
node_modules/.vite
目录。 - 在 Vite 配置文件中,将
server.force
设为true
。(注意,Vite 3.0 中配置项有所更新,你需要将optimizeDeps.force
设为true
) - 命令行执行
npx vite --force
或者npx vite optimize
。
自定义配置:
1、入口文件----entries:
在项目第一次启动时,Vite 会默认抓取项目中所有的 HTML 文件(如当前脚手架项目中的index.html
),
将 HTML 文件作为应用入口,然后根据入口文件扫描出项目中用到的第三方依赖,最后对这些依赖逐个进行编译。
当默认扫描 HTML 文件的行为无法满足需求的时候,比如项目入口为vue
格式文件时,你可以通过 entries 参数来配置。
2、添加一些依赖----include
Vite 会根据应用入口(entries
)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include
来达到完美的预构建效果了。
场景:动态import
3、依赖包被手动exclude
遇到了要在预构建中排除某个包的情况,需要注意它所依赖的包
是否具有 ESM 格式。
第三方包出现了问题该怎么办,分别给你介绍了两个解决思路: 通过patch-package
修改库代码和编写 Esbuild 插件
修改模块加载的内容。
6、vite双引擎架构
开发环境:esbuild(性能好,速度快)
Esbuild 作为打包工具的缺点:
- 不支持降级到
ES5
的代码。这意味着在低端浏览器代码会跑不起来。 - 不支持
const enum
等语法。这意味着单独使用这些语法在 esbuild 中会直接抛错。 - 不提供操作打包产物的接口,像 Rollup 中灵活处理打包产物的能力(如
renderChunk
钩子)在 Esbuild 当中完全没有。 - 不支持自定义 Code Splitting 策略。传统的 Webpack 和 Rollup 都提供了自定义拆包策略的 API,而 Esbuild 并未提供,从而降级了拆包优化的灵活性。
Vite 在开发阶段使用它成功启动项目并获得极致的性能提升,生产环境处于稳定性考虑当然是采用功能更加丰富、生态更加成熟的 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 中就做不到。
7、Esbuild
8、Rollup
Rollup 具有天然的 Tree Shaking
功能,可以分析出未使用到的模块并自动擦除。这是由于Rollup 可以在编译阶段分析出依赖关系,对 AST 语法树中没有使用到的节点进行删除,从而实现 Tree Shaking。
虽然 Rollup 能够打包输出
出 CommonJS
格式的产物,但对于输入
给 Rollup 的代码并不支持 CommonJS,仅仅支持 ESM。
2个核心插件:
@rollup/plugin-node-resolve
是为了允许我们加载第三方依赖,否则像import React from 'react'
的依赖导入语句将不会被 Rollup 识别。@rollup/plugin-commonjs
的作用是将 CommonJS 格式的代码转换为 ESM 格式。
其他常用的插件库:
- @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:压缩代码。
9、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阶段
示例代码执行打包结果后的输出如下:
{ 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。
根据不同的 Hook 执行方式也会有不同的分类,主要包括Async
、Sync
、Parallel
、Squential
、First
这五种
Build Hook:
即在Build
阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块
级别,也就是单文件级别。Ouput Hook
(官方称为Output Generation Hook
):则主要进行代码的打包,对于代码而言,操作粒度一般为chunk
级别(一个 chunk 通常指很多文件打包到一起的产物)。
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 的内容往往动态性不够,适合比较静态的站点,比如文档、博客等场景。