vite学习笔记

发布时间 2023-10-12 15:25:45作者: 聂丽芳


深入浅出vite

1、前端构建工具的痛点

  • 模块化方面,提供模块加载方案,并兼容不同的模块规范。

  • 语法转译方面,配合 SassTSCBabel 等前端工具链,完成高级语法的转译功能,同时对于静态资源也能进行处理,使之能作为一个模块正常加载。

  • 产物质量方面,在生产环境中,配合 Terser等压缩工具进行代码压缩和混淆,通过 Tree Shaking 删除未使用的代码,提供对于低版本浏览器的语法降级处理等等。

  • 开发效率方面,构建工具本身通过各种方式来进行性能优化,包括使用原生语言 Go/Rustno-bundle等等思路,提高项目的启动性能和热更新的速度。

一般的项目使用 Webpack 之后,启动花个几分钟都是很常见的事情,热更新也经常需要等待十秒以上。这主要是因为:

  • 项目冷启动时必须递归打包整个项目的依赖树
  • JavaScript 语言本身的性能限制,导致构建性能遇到瓶颈,直接影响开发效率

 Vite 很好地解决了这些问题。

  • Vite 在开发阶段基于浏览器原生 ESM 的支持实现了no-bundle服务
  • 借助 Esbuild 超快的编译速度来做第三方库构建和 TS/JSX 语法编译,从而能够有效提高开发效率
  • 模块化方面,Vite 基于浏览器原生 ESM 的支持实现模块加载,并且无论是开发环境还是生产环境,都可以将其他格式的产物(如 CommonJS)转换为 ESM。

  • 语法转译方面,Vite 内置了对 TypeScript、JSX、Sass 等高级语法的支持,也能够加载各种各样的静态资源,如图片、Worker 等等。

  • 产物质量方面,Vite 基于成熟的打包工具 Rollup 实现生产环境打包,同时可以配合TerserBabel等工具链,可以极大程度保证构建产物的质量。

2、前端三大模块规范:CommonJSAMD 和 ES Module

CommonJS 规范

存在的问题:

  1. 模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现,比如文件系统,如果 CommonJS 模块直接放到浏览器中是无法执行的。当然, 业界也产生了 browserify 这种打包工具来支持打包 CommonJS 模块,从而顺利在浏览器中执行,相当于社区实现了一个第三方的 loader。
  2. 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-componentsemotion

 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 将一直使用缓存文件:

  1. package.json 的 dependencies 字段
  2. 各种包管理器的 lock 文件
  3. optimizeDeps 配置内容

清除缓存:

  1. 删除node_modules/.vite目录。
  2. 在 Vite 配置文件中,将server.force设为true。(注意,Vite 3.0 中配置项有所更新,你需要将 optimizeDeps.force 设为true)
  3. 命令行执行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

基于 Rollup 本身成熟的打包能力进行扩展和优化,主要包含 3 个方面:

  1. CSS 代码分割。如果某个异步模块中引入了一些 CSS 代码,Vite 就会自动将这些 CSS 抽取出来生成单独的文件,提高线上产物的缓存复用率

  2. 自动预加载。Vite 会自动为入口 chunk 的依赖自动生成预加载标签<link rel="modulepreload"> 。

  3. 异步 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 格式。

其他常用的插件库:

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 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时暴露generatewrite方法,以进入到后续的 Output 阶段(writegenerate方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会)。

所以,真正进行打包的过程会在 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 执行方式也会有不同的分类,主要包括AsyncSyncParallelSquentialFirst这五种

  • 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阶段的工作流

  1. 首先经历 options 钩子进行配置的转换,得到处理后的配置对象。

  2. 随之 Rollup 会调用buildStart钩子,正式开始构建流程。

  3. Rollup 先进入到 resolveId 钩子中解析文件路径。(从 input 配置指定的入口文件开始)。

  4. Rollup 通过调用load钩子加载模块内容。

  5. 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比如 babel 转译。

  6. 现在 Rollup 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子:

    • 6.1 如果是普通的 import,则执行 resolveId 钩子,继续回到步骤3
    • 6.2 如果是动态 import,则执行 resolveDynamicImport 钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过 resolveId 解析路径。
  7. 直到所有的 import 都解析完毕,Rollup 执行buildEnd钩子,Build 阶段结束

5、Output阶段的工作流

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。

  2. 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。

  3. 并发执行所有插件的bannerfooterintrooutro 钩子(底层用 Promise.all 包裹所有的这四种钩子函数),这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。

  4. 从入口模块开始扫描,针对动态 import 语句执行 renderDynamicImport钩子,来自定义动态 import 的内容。

  5. 对每个即将生成的 chunk,执行 augmentChunkHash钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会多次打包的场景下,这个钩子会比较适用。

  6. 如果没有遇到 import.meta 语句,则进入下一步,否则:

    • 6.1 对于 import.meta.url 语句调用 resolveFileUrl 来自定义 url 解析逻辑
    • 6.2 对于其他import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析。
  7. 接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的renderChunk方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。

  8. 随后会调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。

  9. 前面提到了rollup.rollup方法会返回一个bundle对象,这个对象是包含generatewrite两个方法,两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发writeBundle钩子,传入所有的打包产物信息,包括 chunk 和 asset,和 generateBundle钩子非常相似。不过值得注意的是,这个钩子执行的时候,产物已经输出了,而 generateBundle 执行的时候产物还并没有输出。顺序如下图所示:

           

  1. 当上述的bundleclose方法被调用时,会触发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 插件的执行顺序:

 

  • 服务启动阶段: configconfigResolvedoptionsconfigureServerbuildStart
  • 请求响应阶段: 如果是 html 文件,仅执行transformIndexHtml钩子;对于非 HTML 文件,则依次执行resolveIdloadtransform钩子。相信大家学过 Rollup 的插件机制,已经对这三个钩子比较熟悉了。
  • 热更新阶段: 执行handleHotUpdate钩子。
  • 服务关闭阶段: 依次执行buildEndcloseBundle钩子。

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,而按需加载的 chunk Danamic.js也对应单独的一份Danamic.css文件,与 JS 文件的代码分割同理,这样做也能提升 CSS 文件的缓存复用率。
  • Vite 基于 Rollup 的manualChunksAPI 实现了应用拆包的策略:

    • 对于 Initital Chunk 而言,业务代码和第三方包代码分别打包为单独的 chunk,在上述的例子中分别对应index.jsvendor.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-jsregenerator-runtime

编译时工具的作用是在代码编译阶段进行语法降级及添加 polyfill 代码的引用语句。由于这些工具只是编译阶段用到,运行时并不需要,我们需要将其放入package.json中的devDependencies中。

运行时基础库是根据 ESMAScript官方语言规范提供各种Polyfill实现代码,主要包括core-jsregenerator-runtime两个基础库,不过在 babel 中也会有一些上层的封装,包括:

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-envuseBuiltIns配置的替代品,也就是说,一旦使用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系统的进行性能优化

对于项目的加载性能优化而言,常见的优化手段可以分为下面三类:

  1. 网络优化。包括 HTTP2DNS 预解析PreloadPrefetch等手段。
  2. 资源优化。包括构建产物分析资源压缩产物拆包按需加载等优化方式。
  3. 预渲染优化,本文主要介绍服务端渲染(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 的内容往往动态性不够,适合比较静态的站点,比如文档、博客等场景。