Rollup 打包原理与插件
Published in:2024-04-01 | category: 前端 工程化

插件机制

仅仅使用 Rollup 内置的打包能力很难满足项目日益复杂的构建需求。对于一个真实的项目构建场景来说,我们还需要考虑到模块打包之外的问题,比如路径别名(alias)全局变量注入代码压缩等等。
可要是把这些场景的处理逻辑与核心的打包逻辑都写到一起,一来打包器本身的代码会变得十分臃肿,二来也会对原有的核心代码产生一定的侵入性,混入很多与核心流程无关的代码,不易于后期的维护。
因此,Rollup 设计出了一套完整的插件机制,将自身的核心逻辑与插件逻辑分离,让你能按需引入插件功能,提高了 Rollup 自身的可扩展性。体现了 Rollup 本身小而美的风格。

Rollup 的打包过程中,会定义一套完整的构建生命周期,从开始打包到产物输出,中途会经历一些标志性的阶段,并且在不同阶段会自动执行对应的插件钩子函数 Hook。
对 Rollup 插件来讲,最重要的部分是钩子函数,一方面它定义了插件的执行逻辑,也就是 **”做什么”**;另一方面也声明了插件的作用阶段,即 **”什么时候做”**,这与 Rollup 本身的构建生命周期息息相关。

Rollup 整体构建阶段

要真正理解插件的作用范围和阶段,首先需要了解 Rollup 整体的构建过程中到底做了些什么。

在执行 rollup 命令之后,在 cli 内部的主要逻辑简化如下:

1
2
3
4
5
6
7
8
// Build 阶段
const bundle = await rollup.rollup(inputOptions);

// Output 阶段
await Promise.all(outputOptions.map(bundle.write));

// 构建结束
await bundle.close();

Rollup 内部主要经历了 BuildOutput 两大阶段

Build 阶段

首先,Build 阶段主要负责创建模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系。
下面我们用一个简单的例子来感受一下:

1
2
3
4
5
6
// src/index.js
import { a } from './module-a';
console.log(a);

// src/module-a.js
export const a = 1;

然后执行如下的构建脚本:

1
2
3
4
5
6
7
8
9
const rollup = require('rollup');
const util = require('util');
async function build() {
const bundle = await rollup.rollup({
input: ['./src/index.js'],
});
console.log(util.inspect(bundle));
}
build();

可以看到这样的 bundle 对象信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
cache: {
modules: [
{
ast: 'AST 节点信息,具体内容省略',
code: 'export const a = 1;',
dependencies: [],
id: '/Users/code/rollup-demo/src/data.js',
// 其它属性省略
},
{
ast: 'AST 节点信息,具体内容省略',
code: "import { a } from './data';\n\nconsole.log(a);",
dependencies: [
'/Users/code/rollup-demo/src/data.js'
],
id: '/Users/code/rollup-demo/src/index.js',
// 其它属性省略
}
],
plugins: {}
},
closed: false,
// 挂载后续阶段会执行的方法
close: [AsyncFunction: close],
generate: [AsyncFunction: generate],
write: [AsyncFunction: write]
}

从上面的信息中可以看出,目前经过 Build 阶段的 bundle 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时暴露 write 和 generate 方法,用以进入到后续的 Output 阶段

write 和 generate 方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会。

Output 阶段

真正进行打包的过程会在 Output 阶段进行,即在 bundle 对象的 write 或 generate 方法中进行。

还是以上面的 demo 为例,我们稍稍改动一下构建逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
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();

执行后可以得到如下的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
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',
// 其余属性省略
}
]
}

这里可以看到,生成的 output 数组即为打包完成的结果。
当然,如果使用 bundle.write 会根据配置将最后的产物写入到指定的磁盘目录中

因此,对于一次完整的构建过程而言,Rollup 会先进入到 Build 阶段,解析各模块的内容及依赖关系,然后进入 Output 阶段,完成打包及输出的过程

对于不同的阶段,Rollup 插件会有不同的插件工作流程,接下来我们就来拆解一下 Rollup 插件在 Build 和 Output 两个阶段的详细工作流程。

拆解插件工作流

插件 Hook 的类型

首先需要了解不同插件 Hook 的类型,这些类型代表了不同插件的执行特点,是我们理解 Rollup 插件工作流的基础。

所以 Build 和 Output 这两个阶段到底跟插件机制有什么关系呢?
实际上,插件的各种 Hook 可以根据这两个构建阶段分为两类:Build HookOutput Hook

  1. Build Hook 即在 Build 阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块 module 级别,也就是单文件级别。
  2. Ouput Hook(官方称为 Output Generation Hook)则主要进行代码的打包,对于代码而言,操作粒度一般为 chunk 级别(一个 chunk 通常指很多文件打包到一起的产物)

除了根据构建阶段可以将 Rollup 插件进行分类,根据不同的 Hook 执行方式也会有不同的分类,主要包括 Async、Sync、Parallel、Squential、First 这五种。
我们将在 Rollup 中接触各种各样的插件 Hook,但无论哪个 Hook 都离不开这五种执行方式。

Async & Sync

Async 和 Sync 钩子函数,两者其实是相对的,分别代表异步和同步的钩子函数,两者最大的区别在于同步钩子里面不能有异步逻辑,而异步钩子可以有。

Parallel

Parallel 指并行的钩子函数。如果有多个插件实现了这个钩子的逻辑,一旦有钩子函数是异步逻辑,则并发执行钩子函数,不会等待当前钩子完成(底层使用 Promise.all)。
例如,对于 Build 阶段的 buildStart 钩子,它的执行时机其实是在构建刚开始的时候,各个插件可以在这个钩子当中做一些状态的初始化操作,但其实插件之间的操作并不是相互依赖的,也就是可以并发执行,从而提升构建性能。反之,对于需要依赖其他插件处理结果的情况就不适合用 Parallel 钩子了,比如 transform。

Sequential

Sequential 指串行的钩子函数。这种 Hook 往往适用于插件间处理结果相互依赖的情况,前一个插件 Hook 的返回值作为后续插件的入参,这种情况就需要等待前一个插件执行完 Hook,获得其执行结果,然后才能进行下一个插件相应 Hook 的调用,例如 transform。

First

如果有多个插件实现了这个 Hook,那么 Hook 将依次运行,直到返回一个非 null 或非 undefined 的值为止。
比较典型的 Hook 是 resolveId,一旦有插件的 resolveId 返回了一个路径,将停止执行后续插件的 resolveId 逻辑。


实际上不同的类型是可以叠加的,Async/Sync 可以搭配后面几种类型中的任意一种,比如一个 Hook 既可以是 Async 也可以是 First 类型。
接着我们将来具体分析 Rollup 当中的插件工作流程,里面会涉及到具体的一些 Hook,大家可以具体地感受一下。

Build 阶段工作流

首先,我们来分析 Build 阶段的插件工作流程。

对于 Build 阶段,插件 Hook 的调用流程如下图所示。每个方块代表了一个 Hook。边框的颜色可以表示 Async 和 Sync 类型,方块的填充颜色可以表示 Parallel、Sequential 和 First 类型。

接下来一步步分析 Build Hooks 的工作流程:

  1. 首先经历 options 钩子进行配置的转换,得到处理后的配置对象。
  2. 随之 Rollup 会调用 buildStart 钩子,正式开始构建流程。
  3. Rollup 先进入到 resolveId 钩子中解析文件路径(从 input 配置指定的入口文件开始)。
  4. Rollup 通过调用 load 钩子加载模块内容(load 中会通过 resolveId 解析后的路径来加载模块内容)。
  5. 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比如 babel 转译。
  6. 现在 Rollup 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子
    1. 如果是普通的 import,则执行 resolveId 钩子,继续回到步骤 3
    2. 如果是动态 import,则执行 resolveDynamicImport 钩子解析路径。如果解析成功,则回到步骤 4 加载模块;否则回到步骤 3 通过 resolveId 解析路径。
  7. 直到所有的 import 都解析完毕,Rollup 执行 **buildEnd **钩子,Build 阶段结束。

:::danger
当然,在 Rollup 解析路径的时候,即执行 resolveId 或者 resolveDynamicImport 的时候,有些路径可能会被标记为 external(翻译为排除),也就是说不参加 Rollup 打包过程,这个时候就不会进行 load、transform 等等后续的处理了。
:::
在流程图最上面,不知道大家有没有注意到 watchChangecloseWatcher 这两个 Hook。
这里其实是对应了 rollup 的 watch 模式。当你使用 rollup –watch 指令或者在配置文件配有 watch: true 的属性时,代表开启了 Rollup 的 watch 打包模式。这个时候 Rollup 内部会初始化一个 watcher 对象,当文件内容发生变化时,watcher 对象会自动触发 watchChange 钩子执行并对项目进行重新构建。
在当前打包过程结束时,Rollup 会自动清除 watcher 对象调用 closeWacher 钩子。

Element-plus 组件库中就使用了 unbuild 插件生成开发时 stub,开发调试用。这样就不用 watch 一直监听文件构建。相当于做了优化。

Output 阶段工作流

接下来我们来看看 Output 阶段的插件到底是如何来进行工作的。

这个阶段的 Hook 相比于 Build 阶段稍微多一些,流程上也更加复杂。

需要注意的是,其中会涉及的 Hook 函数比较多,可能会给你理解整个流程带来一些困扰,因此我会在 Hook 执行的阶段解释其大致的作用和意义,关于具体的使用大家可以去 Rollup 的官网自行查阅,毕竟这里的主线还是分析插件的执行流程,掺杂太多的使用细节反而不易于理解。

下面我结合一张完整的插件流程图具体分析一下:

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。
  2. 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。
  3. 并发执行所有插件的 bannerfooterintrooutro 钩子(底层用 Promise.all 包裹所有的这四种钩子函数)。这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。
  4. 从入口模块开始扫描,针对动态 import 语句执行 **renderDynamicImport **钩子,来自定义动态 import 的内容。
  5. 对每个即将生成的 chunk,执行 **augmentChunkHash **钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会多次打包的场景下,这个钩子会比较适用。
  6. 如果没有遇到 import.meta 语句,则进入下一步;如果遇到了,则:
    1. 对于 import.meta.url 语句,调用 resolveFileUrl 来自定义 url 解析逻辑
    2. 对于其他 import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析
  7. 接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的 **renderChunk **方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。
  8. 随后会调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk(打包后的代码)、asset(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。
  9. 前面提到了 rollup.rollup 方法会返回一个 bundle 对象,这个对象是包含 generate 和 write 两个方法的,这两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发 writeBundle 钩子,传入所有的打包产物信息,包括 chunk 和 asset,和 generateBundle 钩子非常相似。不过值得注意的是,这个钩子执行的时候,产物已经输出了,而 generateBundle 执行的时候产物还并没有输出。顺序如下图所示:
  10. 当上述 bundle 的 close 方法被调用时,会触发 **closeBundle **钩子,到这里 Output 阶段正式结束。

    注意,当打包过程中任何阶段出现错误时,都会触发 renderError 钩子,然后执行 **closeBundle **钩子结束打包


到这里,我们终于梳理完了 Rollup 当中完整的插件工作流程,从一开始在构建生命周期中对两大构建阶段的感性认识,到现在插件工作流的具体分析,不禁感叹 Rollup 看似简单,实则内部细节繁杂。
希望你能对照流程图好好复习几遍,彻底消化这部分的知识点,不仅仅能加深你对 Rollup 插件机制的理解,并且对 Rollup 本身打包原理的掌握也会更上一层楼。

Prev:
vercel部署跨域问题
Next:
Vite双引擎架构