插件机制
仅仅使用 Rollup 内置的打包能力很难满足项目日益复杂的构建需求。对于一个真实的项目构建场景来说,我们还需要考虑到模块打包之外的问题,比如路径别名(alias) 、全局变量注入和代码压缩等等。
可要是把这些场景的处理逻辑与核心的打包逻辑都写到一起,一来打包器本身的代码会变得十分臃肿,二来也会对原有的核心代码产生一定的侵入性,混入很多与核心流程无关的代码,不易于后期的维护。
因此,Rollup 设计出了一套完整的插件机制,将自身的核心逻辑与插件逻辑分离,让你能按需引入插件功能,提高了 Rollup 自身的可扩展性。体现了 Rollup 本身小而美的风格。
Rollup 的打包过程中,会定义一套完整的构建生命周期,从开始打包到产物输出,中途会经历一些标志性的阶段,并且在不同阶段会自动执行对应的插件钩子函数 Hook。
对 Rollup 插件来讲,最重要的部分是钩子函数,一方面它定义了插件的执行逻辑,也就是 **”做什么”**;另一方面也声明了插件的作用阶段,即 **”什么时候做”**,这与 Rollup 本身的构建生命周期息息相关。
Rollup 整体构建阶段
要真正理解插件的作用范围和阶段,首先需要了解 Rollup 整体的构建过程中到底做了些什么。
在执行 rollup 命令之后,在 cli 内部的主要逻辑简化如下:
1 | // Build 阶段 |
Rollup 内部主要经历了 Build 和 Output 两大阶段
Build 阶段
首先,Build 阶段主要负责创建模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系。
下面我们用一个简单的例子来感受一下:
1 | // src/index.js |
然后执行如下的构建脚本:
1 | const rollup = require('rollup'); |
可以看到这样的 bundle 对象信息:
1 | { |
从上面的信息中可以看出,目前经过 Build 阶段的 bundle 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时暴露 write 和 generate 方法,用以进入到后续的 Output 阶段
write 和 generate 方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会。
Output 阶段
真正进行打包的过程会在 Output 阶段进行,即在 bundle 对象的 write 或 generate 方法中进行。
还是以上面的 demo 为例,我们稍稍改动一下构建逻辑:
1 | const rollup = require('rollup'); |
执行后可以得到如下的输出:
1 | { |
这里可以看到,生成的 output 数组即为打包完成的结果。
当然,如果使用 bundle.write 会根据配置将最后的产物写入到指定的磁盘目录中。
因此,对于一次完整的构建过程而言,Rollup 会先进入到 Build 阶段,解析各模块的内容及依赖关系,然后进入 Output 阶段,完成打包及输出的过程。
对于不同的阶段,Rollup 插件会有不同的插件工作流程,接下来我们就来拆解一下 Rollup 插件在 Build 和 Output 两个阶段的详细工作流程。
拆解插件工作流
插件 Hook 的类型
首先需要了解不同插件 Hook 的类型,这些类型代表了不同插件的执行特点,是我们理解 Rollup 插件工作流的基础。
所以 Build 和 Output 这两个阶段到底跟插件机制有什么关系呢?
实际上,插件的各种 Hook 可以根据这两个构建阶段分为两类:Build Hook 与 Output Hook。
- Build Hook 即在 Build 阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块 module 级别,也就是单文件级别。
- 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 的工作流程:
- 首先经历 options 钩子进行配置的转换,得到处理后的配置对象。
- 随之 Rollup 会调用 buildStart 钩子,正式开始构建流程。
- Rollup 先进入到 resolveId 钩子中解析文件路径(从 input 配置指定的入口文件开始)。
- Rollup 通过调用 load 钩子加载模块内容(load 中会通过 resolveId 解析后的路径来加载模块内容)。
- 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比如 babel 转译。
- 现在 Rollup 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子
- 如果是普通的 import,则执行 resolveId 钩子,继续回到步骤 3
- 如果是动态 import,则执行 resolveDynamicImport 钩子解析路径。如果解析成功,则回到步骤 4 加载模块;否则回到步骤 3 通过 resolveId 解析路径。
- 直到所有的 import 都解析完毕,Rollup 执行 **buildEnd **钩子,Build 阶段结束。
:::danger
当然,在 Rollup 解析路径的时候,即执行 resolveId 或者 resolveDynamicImport 的时候,有些路径可能会被标记为 external(翻译为排除),也就是说不参加 Rollup 打包过程,这个时候就不会进行 load、transform 等等后续的处理了。
:::
在流程图最上面,不知道大家有没有注意到 watchChange 和 closeWatcher 这两个 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 的官网自行查阅,毕竟这里的主线还是分析插件的执行流程,掺杂太多的使用细节反而不易于理解。
下面我结合一张完整的插件流程图具体分析一下:
- 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。
- 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。
- 并发执行所有插件的 banner、footer、intro、outro 钩子(底层用 Promise.all 包裹所有的这四种钩子函数)。这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。
- 从入口模块开始扫描,针对动态 import 语句执行 **renderDynamicImport **钩子,来自定义动态 import 的内容。
- 对每个即将生成的 chunk,执行 **augmentChunkHash **钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会多次打包的场景下,这个钩子会比较适用。
- 如果没有遇到 import.meta 语句,则进入下一步;如果遇到了,则:
- 对于 import.meta.url 语句,调用 resolveFileUrl 来自定义 url 解析逻辑
- 对于其他 import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析
- 接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的 **renderChunk **方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。
- 随后会调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk(打包后的代码)、asset(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。
- 前面提到了 rollup.rollup 方法会返回一个 bundle 对象,这个对象是包含 generate 和 write 两个方法的,这两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发 writeBundle 钩子,传入所有的打包产物信息,包括 chunk 和 asset,和 generateBundle 钩子非常相似。不过值得注意的是,这个钩子执行的时候,产物已经输出了,而 generateBundle 执行的时候产物还并没有输出。顺序如下图所示:
- 当上述 bundle 的 close 方法被调用时,会触发 **closeBundle **钩子,到这里 Output 阶段正式结束。
注意,当打包过程中任何阶段出现错误时,都会触发 renderError 钩子,然后执行 **closeBundle **钩子结束打包
到这里,我们终于梳理完了 Rollup 当中完整的插件工作流程,从一开始在构建生命周期中对两大构建阶段的感性认识,到现在插件工作流的具体分析,不禁感叹 Rollup 看似简单,实则内部细节繁杂。
希望你能对照流程图好好复习几遍,彻底消化这部分的知识点,不仅仅能加深你对 Rollup 插件机制的理解,并且对 Rollup 本身打包原理的掌握也会更上一层楼。