侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

从vue-cli3迁移到vite谈起(二):按需加载与分包与插件

lumozx
2022-06-28 / 0 评论 / 0 点赞 / 120 阅读 / 83472 字

注:本文使用的vite版本为2.9.14

上一篇我们认识到从vue-cli3迁移到vite的种种坑,当我们跋山涉水,披荆斩棘后,终于将项目更换为vite,让我们的启动速度和打包速度更上一层。

但是,这并非终结,而只是一个开始,现在我们几乎跟用vite初始化的新项目站在了同一个起跑线,我们学要学习的东西依然很多。

一、初次加载的优化

当我们怀着期待的心情执行 npm run dev的时候,会发现vite启动非常迅速,只要vue-cli3六分之一的时间,本地服务器就启动了,但是,当我们访问本地开发网址的时候,会发现,依然需要等待大约十倍的启动服务器的时间。

这反而比vue-cli3慢了不少。尽管只有第一次启动会如此慢,因为有强缓存的原因,之后的更改导致热更新速度依然很快,但第一次启动变慢就与我们的初衷背道而驰了,我们更换为vite的初衷就是让一切快起来。

在启动中打开Network,很容易发现导致这一切的元凶:

上千个请求阻塞了vue页面的挂载,由于本地开发环境并没有开启http/2,最多只有8个链接(常规是6个),导致资源挂起时间很长。

这些请求极大部分是来自于router中引入的vue文件以及深层依赖,上一篇文章我们提到过,Vite负责将业务代码转成ESM,因此在main.ts中,递归到这些依赖,需要将他们变为ESM,所以main.ts也占了极大的时间。

最终导致首次进入极慢。

同时由于使用了私有UI,遍历引入了所有组件的css,没有vue-cli的按需引入,因此也被阻塞了好久

以webapp项目举例,启动dev server使用了1700ms,但首屏渲染用了16s

因此,我们对症下药:

  1. 对于路由组件加载阻塞的情况,我们更换为http/2

  2. 对于递归依赖,来进行ESM转换导致main.ts阻塞时间过长,我们使用异步路由

HTTP2

http/2http/1.1对比优势很多,但我们这里只讨论并发的情况,http/2能够在一个TCP连接上,发送无数个HTTP请求,而非http/1.x的一个TCP链接发送一个HTTP请求,这样就可以避免同源并发的问题。

我们在上一篇提到过在vue-cli3中,http/2功能的devServer是基于webpack-dev-server封装的,但由于使用的是spdy提供http/2服务,无法使用Node 15.0.0以及以上的版本。因此需要等待Express支持内建HTTP/2,才会进行迁移,但从express 的issues中看起来遥遥无期。

vite中,server中的选项是基于不同的包进行实现的,负责实现的http/2的https是基于node的httpshttp/2实现的,但由于实现的proxyhttp-proxy并不支持http/2,因此proxy选项与https选项冲突,导致屏蔽http/2,仅https生效。因此实现http/2需要nginx的介入。

异步路由

更换异步路由,我们只要跟vue-router文档中所说的,使用import()即可。

将所有的路由替换为异步路由,首屏渲染变为了5s,同时请求降为450个。到目前为止,开发环境下,vite从执行服务器启动命令到首页数据加载完毕,时间减小到vue-cli3的三分之一。

注:加载时间与接口响应有关系,但同一表格中,为同一时间调用接口,因此接口响应时间相同

启动时间(ms)

Finish(s)

DOMContentLoaded(s)

Load(s)

vite

1617

3.40

1.89

1.92

vue-cli3

10287

3.55

1.95

1.97

从上个表格可以看出,同样使用异步路由后,vitevue-cli快很多,但这并非一个完美的解决方案,因为异步路由会有一个副作用,那就是打包产物会自动分包,这本身没有不是什么大问题,但如果所有的路由都按需加载了,打包产物是极其碎片化,那么在生产环境中,最耗时的并非产物体积过大引起的慢加载,而是频繁的http请求。因此我们需要做到碎片化合包。

注:加载时间与接口响应有关系,但同一表格中,为同一时间调用接口,因此接口响应时间相同

无异步路由

异步路由

http/1.1

main.ts 14.56s load 17.34s

main.ts 2.42s loading 3.51s

http/2

main.ts 16.29s load 19.29s

main.ts 3.8s loading 4.83s

因此根据差值,可以看到http/2的确可以加速非入口函数的组件加载,但如果用异步路由,那么带来的提升是巨大的。

二、代码分割与合包

分包

分包是一个非常合理的需求,因为如果打包产物巨大的话,会使得加载缓慢,同时在入口处就加载项目的深层资源,或者访问频率较低的资源,这本身是不太合理的。

一般来说,分包策略是根据体积大小、共用率、更新频率重新分割我们的项目,使尽可能利用浏览器的缓存机制。

根据这个策略,将js分成以下三个模块:

  1. 第三方依赖(node_modules

  2. UI库(kite、antd)

  3. 业务代码

因此,更新项目后,大部分情况下被更新的只有业务代码,1、2走浏览器的缓存。

但是,这种策略并不适合当前情况——我们已经在vite中将所有路由进行了异步化。这让打包工具,不论是webapck还是rollup,都将他们分别打包成一个个独立文件,产生了大量的异步路由碎片。

webpack中,默认分包策略是:

  1. 新的chunk是否被共享,或者是来自node_modules

  2. 新的chunk体积被压缩之前是否大于30kb

  3. 按需加载 chunk 的并发请求数量小于等于 5 个

  4. 页面初始加载时的并发请求数量小于等于 3 个

不过由于splitChunks插件的存在,我们很容易地进行代码人工分割,对于过于碎片化的异步组件,webpack也提供了webpackChunkName webpackIgnorewebpackMode等魔术注释,让这些异步组件进行合并、极大的提高代码打包的自由度。然后webpack在html中,会把所有的js文件引入进去,然后增加preload标识,让这些文件预加载。

在vite中,并没有webpack这样多元化配置,因为vite的生产打包是基于Rollup,所以vite的打包是Rollup的打包 + vite定制实现的。

vite2.8及之前,vite的默认分包策略是:

  1. node_module放入vendor

  2. 业务代码放入index包,也就是说业务代码和入口代码放在一起

在2.9中,所有的代码全部放入index包,可以使用manualChunks进行自定义拆包,或者使用vite官方提供的splitVendorChunkPlugin插件,来延续2.8之前的逻辑.

而对应的webpack的魔术注释是没有的,虽然可以通过引入插件vite-plugin-webpackchunkname来实现部分魔法注释,但既然是Rollup,那么就该用rollup的方法,而不是用webpack的方法。

合包

为什么合包?我们在分包里面提到过,由于使用了异步路由,因此产生了大量的碎片文件,大大增加了http的负载,由于webapp面向的是移动端用户,并不能期望有一个很好的网速,因此我们需要合并一些包,减小http的请求次数。

事实上,vite本身是针对异步chunk增加了优化的。

举个例子:

在非优化方案中,当导入异步A的时候,浏览器会请求并解析A,然后再加载C,会有额外网络往返,entry -> A -> C

但vite的优化方案中,请求A的时候会自动预加载C,entry -> (A + C)

也就是说,vite并没有类似webpack把所有的产物用preload进行加载,而是在需要的时候,动态添加带有preload的标签,用来加载对用所需要的js和css文件。

因此尤雨溪说异步组件产生很多chunk这不是一个问题:https://github.com/vitejs/vite/issues/1835#issuecomment-770899843

此观点我表示一定的认同,但针对并没有相互依赖的地方,比如两个并列的TAB栏,第一次点击切换路由的时候,依然能感觉到卡顿,这个是vite并没有优化到的场景。因此我们需要自定义合包。

在vite中,如果使用了异步组件,那么就会独立成包,否则就会被打包进index中。

如果使用了manualChunks(包括splitVendorChunkPluginsplitVendorChunkPlugin也是基于manualChunks写的插件),那么就优先执行manualChunks手动分包,再处理自动分包。当处理默认分包的时候,会检查当前模块是否在手动分包中,是的话就忽略该模块。

manualChunks

我们一直在提到manualChunks,那么这个是一个什么东西呢?

manualChunks准确说并非vite的配置项,而是rollup的配置项,vitebuild.rollupOptions暴露了rollup的配置项,所以我们可以在build.rollupOptions.output.manualChunks进行配置。

manualChunks主要有两种配置的形式,可以配置为一个对象或者一个函数。

对象:

在对象中,key就是chunk的名称,value就是一个字符串数组,每一项是一个包名。

函数:

其中,函数的配置方式更加灵活,splitVendorChunkPlugin就是通过函数的方式来配置的(如上图)。

Rollup 会对每一个模块调用 manualChunks 函数,在 manualChunks 的函数入参中可以拿到模块 id 及模块详情信息,经过一定的处理后返回 chunk 的名称,然后当前 id 代表的模块便会打包到你所指定的chunk文件中。

inlineDynamicImports

Rollup并非没有处理异步组件的方法, 但此方法处理起来简单粗暴,build.rollupOptions.output.inlineDynamicImports就是提到的选项,如果置为true,那么就会全部打包到index里面,也就是说执行了默认分包方案。

但此选项不支持manualChunks,若设置了manualChunks就会抛出异常,因此无法进行手动分包,有些鸡肋。

assetFileNames、chunkFileNames、entryFileNames

他们依然是build.rollupOptions.output下的选项,当我们进行打包之后,会发现产物并非处在我们想要的文件夹,那么就需要用到这三个配置项。

  • assetFileNames,静态文件命名,支持占位符,默认值是assets/[name]-[hash][extname],可以接受一个函数,入参是asset信息,需要返回一个字符串,占位符:

  • extname:完整扩展名,比如.css

  • ext:没有点的扩展名,比如css

  • hash:基于名称和内容的哈希

  • name:文件名

  • chunkFileNames,代码拆分命名,比如设置了manualChunks或者使用了异步组件的情况,都会在此文件夹产生对应的打包产物,支持占位符,默认值是[name]-[hash].js,可以在前面添加正斜杠,来设置生成的目录,可以接受一个函数,入参是chunk信息,需要返回一个字符串,占位符:

  • format:输出渲染格式,比如es

  • hash:基于内容和所有依赖项的内容的哈希

  • name:块的名称,可以通过manualChunks设置或者异步模块的文件名称

  • entryFileNames,入口文件命名,支持占位符,默认值是[name].js,可以在前面添加正斜杠,来设置生成的目录,可以接受一个函数,入参是entryChunk信息,是不依赖文件名的简化版本,需要返回一个字符串,占位符:

  • format:输出渲染格式,比如js

  • name:文件名

  • ext:没有点的扩展名,比如js

  • extname:完整扩展名,比如.js

  • assetExtname:完整扩展名,如果是js、jsx、ts、tsx的话会显示空值

cssCodeSplit

这个为vite本身的配置,在build.cssCodeSplit中进行处理,默认为true,意味着默认拆分css代码,如果是异步组件的话,css代码将在异步代码加载的时候引入。

目前项目中的异步路由极多,同时产出的css也是更加碎片化,所以如果拆分css的话,会增加http的开销,因此我们将他变为false。

循环依赖

webpack的代码分割灵活是较多的配置项来体现的。

Rollup的代码分割灵活是通过手动分割来实现的,那么就产生一个问题:手动分割是万无一失的、随心所欲的吗?

当时不是的,并且里面有坑,坑很大。

我们试着按照需求来实现一遍拆包逻辑:当是node_modules的包并且非css还是静态入口模块的,打包到vendor中,业务代码相关的打包到src中,剩余代码执行默认打包逻辑,打包到index中。

在这里,我们粗浅地用路径来进行判断,其中views和components是存放业务代码的地方,执行打包命令后,打包结果如下:

看起来没什么问题,代码比例也大致符合预期,似乎是成功了,但如果使用npx vite preview来预览产物,会发现控制台直接报错:


似乎有个值是undefined,但项目本身是没问题的,说明是打包过程中除了什么问题。

通过对打包产物的排查,我们发现了问题所在,在vendor包中,我们发现引用了src包

在src包中,我们发现了引用了vendor包

在我们的预想中,src引用vendor包是没问题的,毕竟业务代码引用node_modules是很正常的,但是为什么node_modules会引用src业务代码的东西呢。

通过对引用的src包的信息追溯,我们发现是引用的是R_URL,那这就说通了。

在上一篇中,我们对R_URL这个常量进行了劫持,将他重定向到业务代码中

那么就说通了,所以我们需要对打包逻辑进行修改,将R_URL打包进vendor包中

打包结束后运行产物预览,会发现项目已经可以运行,说明问题已经被我们解决掉了。

为什么循环引用会报错这个错误呢?

举一个例子:

a.js 引用了 b.js,然后b.js引用了a.js

  • 当JS引擎执行a.js的时候,发现引入b.js,因此去执行b.js

  • 执行b.js的时候,发现引入了a.js,JS引擎就认为a.js已经加载完成(但实际并没有加载完,还卡在引入b.js那里),继续往下执行

  • 当b.js需要a.js中定义的变量的时候,发现是undefined

这次我们出现的问题也是类似的原因,R_URL在业务代码中,业务代码引入node_modules中的第三方包,而第三方包也需要R_URL的变量,所以也引用了R_URL所在的业务代码包

实际上,不止业务代码和第三方包进行拆分会出现这个问题,第三方包的拆包和独立也可能会出现循环引用问题。

比如想将Vue独立出来,拆成Vue、index.js两个包。

而仅仅在路径中匹配Vue的路径是不可以的,因为vue依赖了lodash,而业务代码也依赖了lodash,因此会出现,index依赖vue,而vue依赖index里面的lodash,产生了循环引用。

因此,如果需要对第三方依赖拆包,我们需要一个一个引用递归,将第三方包所依赖的模块统统打包到自己包中。

其中的isDepInclude和官方的splitVendorChunkPlugin出现方式类似,不过是增加了异步引用的情况,大家可以针对此处逻辑进行取舍。

产物分析

我们可以使用rollup-plugin-visualizer 来对产物进行分析,以此来更加精确地分包

import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
  plugins: [
    react(),
    visualizer({
      // 打包完成后自动打开浏览器,显示报告
      open: true,
    }),
  ],
})

当我们打包后,会自动打开浏览器,出现分析页面

20231008171105.jpg从色块上进行分析,我们的分包成果比较显著,红色的入口模块加载了入口相关的代码,绿色的业务模块很好地包含了vue组件相关的业务代码,蓝色的则是第三方依赖,这比一部分代码的哈希一般不会变动,因此可以使用浏览器的缓存。

同时产物分析可以暴露一些问题,比如我们注意到lodash占比过大,因此需要按需加载。

三、使用插件进一步优化

编写插件一直都是进一步了解一个框架的方式,通过插件我们可以了解到vite的生态、核心特性以及更高阶的使用方式。我们现在已经了解到,Rollup是vite生产环境下的打包工具,vite也兼容了Rollup的插件机制。

但大家有没有认识到一个点:rollup是一个生产环境打包工具,那么vite开发环境是如何利用Rollup的插件,而Rollup又是如何进行打包的呢?

Rollup

配置文件打包

我们在本地安装rollup,并新增src/index.js、src/utils.js、rollup.config.js三个文件

rollup.config.js文件内容如下

业务文件内容如下:

// src/index.js
import { add } from "./utils"
const a = 1
console.log(add(1 + 2))

// src/utils.js
export const add = (a, b) => a + b; 
export const multi = (a, b) => a * b;

在index文件中,我们引用了add,同时定义了一个未经使用的常量a.

接下来我们使用npx rollup -c来执行打包命令,打包成功后,可以看到打包产物如下

const add = (a, b) => a + b; 
console.log(add(1 + 2));

可以看到multi和a并没有被打包进入,因为Rollup具有天然的Tree Shaking功能,可以在编译阶段,分析出依赖关系,对AST语法树进行节点删除,从而实现没有用到的模块自动删除。

这么看来rollup具有不错的打包能力,但他本身并非十分全能,Rollup本身无法支持很多常用的场景,比如兼容CommonJs环境变量别名压缩等。既然他本身不支持,那么我们就得需要接入插件了。

Rollup虽然可以输出CommonJs格式的产物,但输入并不支持,仅仅支持ESM,虽然业务代码使用ESM可以,但我们无法让第三方包也使用ESM,所以就需要两个插件来解决这个问题。

@rollup/plugin-node-resolve @rollup/plugin-commonjs
  • @rollup/plugin-node-resolve是为了允许我们加载第三方依赖,否则像import lodash from 'lodash' 的依赖导入语句将不会被 Rollup 识别。

  • @rollup/plugin-commonjs 的作用是将 CommonJS 格式的代码转换为 ESM 格式

在rollup.config.js配置如下

API打包

在上一节我们使用rollup -c来进行打包,但仅仅使用配置文件的话,对于多种场景就不是很灵活了,因此我们需要定制一些打包过程,rollup提供api调用,可以使我们打包更加灵活。他提供了两个api,rollup.rolluprollup.watch

rollup.rollup

这个api可以一次性地进行打包,我们新建build.js,内容如下:

这些虽然看着很长,但实际上大部分我们在vite的时候就已经了解过了。

  1. 在步骤开始,传入了配置项,Rollup可以接受多个配置项,生成多个打包产物。

  2. 调用bundlegeneratewrite方法,完成产物的生成和磁盘写入

  3. 最后使用close结束打包

rollup.watch

watch模式可以在源文件变动后,自动重新打包,内容如下

属性配置和rollup基本一致,但增加了watch配置,同时,也增加了事件监听,如果触发了事件,可以执行对应的回调函数。

构建

我们一直在提到:vite实现了rollup的插件机制,那么rollup的插件机制到底是什么呢?

其实,rollup在打包的时候,会定义一套完整的生命周期,从开始到写入磁盘,都在不同的阶段执行不同的钩子函数,对于插件来讲,如何利用好这些钩子,就是实现插件本身功能的关键。

在我们执行build的时候,Rollup内部就经历了buildoutput两个阶段

build

我们之前提到过,我们可以调用bundlegeneratewrite方法,完成产物的生成和磁盘写入,那么问题来了,bundle里面是什么呢,是一个完整的打包信息吗?经过console,我们可以得到一个简单文件的bundle

{
  cache: {
    modules: [
      {
        ast: 'AST 节点信息,具体内容省略',
        code: 'export const a = 1;',
        dependencies: [],
        id: 'XXX',
        // 其它属性省略
      },
      {
        ast: 'AST 节点信息,具体内容省略',
        code: "import { a } from './data';\n\nconsole.log(a);",
        dependencies: [
          'XXXX'
        ],
        id: 'XXXX',
        // 其它属性省略
      }
    ],
    plugins: {}
  },
  closed: false,
  // 挂载后续阶段会执行的方法
  close: [AsyncFunction: close],
  generate: [AsyncFunction: generate],
  write: [AsyncFunction: write]
}

我们我可以看到,生成的bundle并没有对代码进行打包,而是储存了各个模块的内容和依赖关系,然后暴露generatewrite方法,然后在output阶段完成打包。generatewrite唯一区别是,后者打包产物会写入磁盘,前者不会。

output

也就是说,Rollup在build阶段收集依赖,真正的打包阶段是在output阶段进行,那么我们看一下generate的返回值

{
  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',
    }
  ]
}

可以看到这里生成了chunk,以及确定了是否为静态入口模块,同时列出了异步依赖和同步依赖,如果使用write的话,就会写入磁盘目录中。

由此可见,Rollup的构建过程是先进入build阶段机械模块内容和依赖,然后在output阶段,完成打包和输出,对于不同的阶段,Rollup有着不同的钩子函数,让不同的插件起到不同的作用。

hook

依照阶段不同,钩子函数可以直观分为两种:

  • Build Hook可以在Build阶段执行的钩子,这个阶段一般是模块代码的转换、AST的解析、模块以来的解析,一次这个阶段的插件一般操作的是模块级别。

  • Output Generation Hook ,主要是进行代码打包,因此操作是chunk级别的

依照Hook的执行方式,也会范围吴磊,分别是AsyncSyncParallelSquential、First五种

Async、Sync

这个分别代表了异步和同步的钩子函数,里面最大的区别是同步里面不能有异步逻辑,异步里面可以有。

Parallel

这里指的是并行钩子函数,如果有多个插件实现了这个钩子逻辑,同时有钩子函数是异步逻辑,那么就会并发执行钩子函数,并不会等待当前钩子完成,这里听起来就如同Promise.all一样,实际上,这个钩子的确是Promise.all进行包装,这个类型的钩子中,并不会产生常见相互依赖的情况,因此可以并行触发。

Squential

这里指的是穿行钩子,这种钩子适合插件相互依赖的情况,前一个插件的hook返回值作为后一个的入参,因此需要等待前一个插件执行完毕,比如transform

First

如果有多个插件实现了这个hook,那么hook就会依次执行,直到返回一个非空值,一旦有非空值,那么就停止后续插件针对此入参的执行,也就是说,这种类型的钩子,入参相同且有明确返回值的钩子,只能存在一个。

以上五个类型,其实并非单一类型,AsyncSync可以跟后三个类型任意搭配。

Build工作流程

我们来看一张官方的插件Hook调用流程图,左上角声明了不同hook类型,一站不同颜色进去区分,边框颜色用来区分asyncsync

虽然看起来很复杂,但实际上这些步骤都是必要步骤,同时优化了一些边界情况。

  1. options钩子进行配置转换,因为插件在功能上可能会修改配置选项,所以是一个具有前后依赖关系的钩子,因此是个串行钩子。同时,这是唯一一个无法访问插件上下文的钩子,因为他在完整的配置选项整合之前运行。这里提到了一个新的功能:插件上下文,这个之后我们会讲。

  2. 之后,rollup会调用buildStart钩子,正式开始构建,由于这个没有插件的相互依赖,因此是并行钩子。

  3. 构建开始后,rollup需要从入口文件开始,解析文件路径,调用resloveId钩子,由于一个路径只能有一个解析方式,因此这个是一个first类型,在这里,我们第一次遇到了多个情况。如果路径被标记为external,那么就不会参与loadtransform的后续处理。

  4. 之后,通过load加载模块内容,由于一个模块也只能被加载一次,因此这个也是first类型。

  5. 在load之后,Rollup会判断有无缓存,如果没有缓存,就会进入transform钩子,这个钩子会对模块内容进行转换,比如babel,当babel执行完后,会执行混淆、压缩的插件,使用的正是babel后的代码,因此这个钩子是一个转换产物依赖前一个转换产物的钩子,因此是个串行钩子。

  6. 在load之后,Rollup判断有缓存,那么就判断shouldTransformCachedModule是否针对这个路径返回true,如果是的话,那么此路径的文件会被进入transform流程,否则就跳过transform,这钩子使用目的是在rollup.watch下,如果缓存和加载代码相同,默认会跳过transform来优化性能,而此钩子就是用来定制这个默认行为的。

  7. 执行完transform后,rollup拿到了最后的模块内容,进行AST分析,得到import内容,调用了moduleParsed钩子,由于已经拿到了模块内容,并且这个钩子并不允许我们通过返回值来修改什么,因此是一个并行钩子。

  8. 在执行完moduleParsed,解析了import内容后,Rollup遇到了几种情况

  9. 如果是普通import,如果有缓存,就走load流程,没有缓存就走resloveId流程。

  10. 如果是异步模块,就执行resolveDynamicImport钩子解析路径,逻辑与resloveId类似,因此也是一个first类型,如果解析成功就走load流,没有成功就走resloveId流程。

  11. 如果transform后,发现没有import,或者resolveDynamicImport后发现依赖被标记为external,那么就触发buildEnd钩子,这个是并行钩子。结束build。

  12. 流程图上还有watchChangecloseWatcher这两个钩子函数,这个对应了watch模式,挡在watch模式下,Rollup会再内部初始化一个watcher对象,当文件发生变化,watcher会自动触发watchChange,使项目重新构建。而closeWatcher可以在调用watcher.close()时候触发。

Output工作流程

我们来看看output怎么进行工作,这个需要处理的情况可能多一些,涉及的hook也比较多,但并不是非常复杂。

左上角依然有图例,可以方便我们实时了解不同hook的特点。

  1. 首先,会执行所有的outputOptions 钩子,因为涉及到配置相关,插件可以修改配置,所以这是一个串行钩子

  2. 然后并行并行执行renderStart,开始打包。

  3. 并行执所有插件的bannerfooterintrooutro钩子,这四种钩子都被Promise.all包裹,都返回一个字符串

  4. banner的字符串用于在产物顶部写入

  5. footer的字符串用于在产物底部写入

  6. intro的字符串用在代码顶部写入

  7. outro的字符串用在代码底部写入

  8. bannerfooterintrooutro是两组类似的功能,区别是位置,一个是产物,一个是代码,举个例子,我有一些代码,需要打包成cjs格式的,那么会自动往头部添加'use strict',那么这个'use strict'就位于banner和intro之间,因此introoutro主要用出来增加包裹性质的代码,bannerfooter更多用来做注释。

  9. 执行完外层操作,接下来就开始解析代码,从入口模块开始扫描,针对动态import,会执行renderDynamicImport钩子,因为涉及到模块的引入,因此这个是一个first类型。

  10. 解析后,马上生成chunk,所以要调用augmentChunkHash,来决定要不要更改chunk的哈希值,针对多次打包的情况,比如watch模式下,这个钩子比较适用。由于只要有一个插件返回真值就会让哈希值无效,结束这个钩子的执行,因此这本身也是对结果的一种依赖,所以是串行类型。

  11. 如果遇到了import.meta,就需要分情况了

  12. 如果是import.meta.url,需要调用resolveFileUrl钩子来自定义解析url的逻辑

  13. 如果是其他属性。就是使用resolveImportMeta来自定义解析

  14. 由于涉及到路径解析,因此也是first类型

  15. 然后调用render,来生成chunk,这个类似transform,依次执行,因此是串行类型

  16. 接着调用generateBundle,这个钩子入参有所有打包信息,包括chunk、asset等信息,在这里可以自定义删除一些内容

  17. 我们一直在提到chunk生成,这里需要提醒一点是这个生成是生成到内存中,同时前面还提到过bundle里面有generatewrite两个方法,他们唯一区别是后者可以写入磁盘中,他们都会触发generateBundle,但write可以在产物写入磁盘之后,触发writeBundle,入参和generateBundle相似,但无法修改,因为writeBundle触发的时候已经输出了。

  18. 最后bundleclose方法调用,会触发closeBundle钩子,结束打包,如果在打包过程中出现错误,也会触发closeBundle钩子,结束打包。

这就是Rollup的插件工作流,由钩子开始,由钩子结束。

插件上下文

我们之前提到了一个词,叫做插件上下文,实际上可以理解成为一种功能强大的帮助函数,就拿官方插件alias插件举例。

在看到官方的例子之前,我们来思考下,自己如何实现这个插件呢?

既然解析别名,那么就肯定得需要resolveId钩子,但是既然自己使用了resolveId来解析对应别名的模块,那么,这个模块就不会被其他插件处理了,因为resolveId是一个first类型的钩子。而且resolveId并不会处理其他resolveId的返回值。

因此我们需要alisa插件使用resolveId处理后的路径,依然可以被其他插件解析。

我们来看官方怎么做的:

代码经过简化,但每行都有注释,最主要的就是返回值,this.resolve就是一个插件上下文,他可以手动执行所有插件,除了当前插件的resolveId钩子。

除了resolveId,还有this.getModuleInfo获取模块信息,this.parse转化AST等插件上下文。

Vite

铺垫差不多,那么就让我们回到vite上,前面一直在讲rollup的插件,同时也在反复强调,vite实现了rollup插件机制,那么大家有没有想过一个问题,Rollup是一个打包工具,主要领域是构建出生产环境,那么开发环境下呢?

所以vite实现了rollup插件机制这句话的还有一层意思:vite把Rollup的插件用到了开发环境下。

vite会调用一系列rollup兼容的钩子

  1. 本地服务器启动阶段,optionsbuildStart钩子会在服务启动时被调用。

  2. 请求响应阶段: 当浏览器发起请求时,Vite 内部依次调用resolveIdloadtransform钩子。

  3. 服务器关闭阶段: Vite 会依次执行buildEndcloseBundle钩子。

除了以上钩子,其他钩子均不会在开发阶段调用,而在生产环境下,所有钩子都会生效。

独有Hook

除了上文提到的Rollup的钩子,vite还有自己独有的钩子。

  • config是在vite读取完配置文件,执行的钩子,在钩子里面,可以对配置对象进行自定义操作。官方推荐在返回值中返回一个配置对象,这个对象会跟vite已有的配置深度合并。当然,也可以直接修改入参。

  • vite解析完配置后,会调用configResolved钩子,因为config存在被修改的可能,所以一般在这个钩子里面进行读取最终配置信息。

  • configureServer只会在开发环境调用,用于扩展Dev server,如果在函数体自定义逻辑,那么这些逻辑会在vite的内置中间件之前执行,如果返回一个函数,在返回的函数里面自定义一些逻辑,那么这些逻辑会在vite内置中间件之后执行

  • transformIndexHtml这个钩子可以自定义控制HTML内容

  • 当热更新触发的时候,会调用handleHotUpdate这个钩子,同时,我可以在入参拿到上下文,通过上下文发送websocket,然后前端代码通过import.meta.hot.on来监听接收。

在服务器启动阶段,会依次执行configconfigResolvedoptionsconfigureServerbuildStart钩子

在请求访问阶段,对于html文件会调用transformIndexHtml钩子,对于其他文件,就会调用resolveIdloadtransform,热更新会调用handleHotUpdate钩子,在服务关闭阶段依次执行buildEndcloseBundle钩子。

顺序

我们一直在讲钩子函数的执行顺序,但是,相同钩子的插件,是如何执行的呢?

vite中,解析的插件按照以下顺序进行执行:

  1. alias插件

  2. enforce:pre的用户插件

  3. vite核心插件

  4. 默认的用户插件

  5. vite生产环境插件

  6. enforce:post的用户插件

  7. vite内部后置插件

我们注意到,2、6阶段为enforce,指的是我们可以在插件中,在钩子同一级配置enforce参数,默认是normal,让插件在4阶段运行,如果配置是pre,那么就会在2阶段运行,如果是post,就会在6阶段运行。

同时,同一级还有apply参数,用来指定插件运行环境,默认情况下,生产环境和开发环境都可以 使用,如果是serve,那么会运行与开发环境,如果是build,会运行在生产环境,同时还可以配置成一个函数,具有更高灵活性。

虚拟模块

rollup作为构建工具,一般需要处理两种形式的模块,一种是存在与硬盘中的模块,另一种是存在于内存中的模块,这种我们叫他虚拟模块。

这种模块是在构建的时候生成的,同时经过了包装、计算,使我们获取数据更加灵活。

比如,我想获取配置信息,比如当前config钩子中的部分内容,打印在页面上。

那么我们就需要用到configResolved钩子,来获取最终配置,将他们保存在内存中,通过resolveId来解析并拦截虚拟模块,否则虚拟模块会被当做普通路径解析,从而报错。最后我们通过load钩子来返回我们需要的数据。

这里需要注意的是,虚拟模块通过被resolveId解析的路径,需要在最前面加入\0,用来标识是虚拟模块,当然,这个仅仅是一个约定,基本逻辑上并没有任何特殊处理。除非出现了基于这个约定来处理虚拟模块的插件。

我们在入口函数加载这个虚拟模块,并打印值。

import  env from 'virtual:test'
console.log(env)

会发现,这个虚拟模块很好地进行了执行。

四、语法降级

到目前为止,我们还没有谈论到语法降级相关的内容,仿佛vite只能在现代浏览器使用,放到旧版本浏览器就无法使用了。

实际上这是不对的,vite也能构建出兼容各种低版本的产物。

旧版本的浏览器主要分为两种问题:

  1. 语法降级问题,比如箭头函数

  2. polyfill缺失,比如没有Object.values

这两种问题其实可以通过编译工具,比如babel,以及polyfill库,比如corejs来解决的,因此准确说,语法降级和polyfillvite本身的能力无关。

而解决这类问题,衍生出两种工具,分别是:

  1. 编译时工具,比如@babel/preset-env@babel/plugin-transform-runtime,他们只会在编译阶段用到,在编译阶段,对代码进行语法降级和添加polyfill

  2. 运行时工具,比如core-jsregenerator-runtime,他们需要在运行时候用到,用于polyfill的添加。

而vite中,官方已经封装了一个开箱即用的方案@vitejs/plugin-legacy,这个插件内部使用了@babel/preset-envcore-js等一些列基础库来进行语法降级好和polyfill注入。

安装好插件后,我们可以在插件中引用他。

targets最终会被传给@babel/preset-env

引入插件后,我们尝试执行打包,经过较长时间的等待,我们会看到多了以下信息。

本次打包,每个js文件多了一个*-legacy版本,以及一个polyfills-legacy文件

我们打开html,看一下引入情况。

之前的分包产物,入口文件增加了type="module",其他增加了rel="modulepreload"来进行预加载。

但增加了legacy插件,多出来几处不同的地方

位于分包产物的下方:

位于页面底部:

通过官方的@vitejs/plugin-legacy插件, Vite 会分别打包出ESMLegacy模式的产物,然后两种产物会插入同一个 HTML 里面,ESM被放到 type="module"的 script 标签中,而Legacy产物则被放到带有 nomodule 的 script 标签中。

如果是现代浏览器,会加载type="module"的js文件,忽略nomodule 的 script 标签。

如果是旧版浏览器,会加载nomodule的js文件,忽略type="module"的 script 标签。

这样产物就在一个html中,放到不同的浏览器,通过识别不同的script标签,按需加载不同的js文件。

多出来js,产物下方的,是为了虽然支持module,但不支持动态import的浏览器所添加的垫片。

页面底部的是为了兼容ios10不支持nomodule,所添加的垫片。

原理

@vitejs/plugin-legacy本质上是一款插件,因此他是遵循vite插件的生命周期的。

  1. 首先在configResolved中,增加output选项,增加system格式的产物

  2. 然后在renderChunk的时候,通过@babel/preset-env进行语法转换,记录polyfill集合

  3. generateBundle的时候,收集所有的polyfill进行单独打包

  4. 最后在transformIndexHtml将生成的产物和polyfill插入HTML

需要注意的是,在renderChunk阶段,并不会注入垫片,而是进行收集,而在generateBundle,会对收集的垫片统一打包。

小结

在这里我们了解了在vite中如何进行按需加载,如何进行优雅地使用Rollup打包,以及他的逻辑是怎么样的,还学到了如何进行编写vite和Rollup插件,最后我们也看到了vite的语法降级功能以及了解他的实现原理。

但这依然不是vite的全部,因为我们一直在说vite是双引擎,因此我们还没有充分了解另一个引擎:Esbuild的能力,他的依赖预构建是怎么实现的,以及ESM的性能还没有被完全开发出来。

因此到目前为止,我们只停留在业务层面,仅仅是在使用层面。还有更多神奇的定制化选项需要我们去了解学习。

需要学习的东西有很多,这并非我们的终点,而是我们新的起点。

0
博主关闭了所有页面的评论