共计 10796 个字符,预计需要花费 27 分钟才能阅读完成。
上一节咱们学会了 Rollup 构建工具的应用,置信你曾经对 Rollup 的根底概念和应用有了根本的把握。同时咱们也晓得,仅仅应用 Rollup 内置的打包能力很难满足我的项目日益简单的构建需要。对于一个实在的我的项目构建场景来说,咱们还须要思考到模块打包之外的问题,比方门路别名(alias)、全局变量注入和代码压缩等等。
可要是把这些场景的解决逻辑与外围的打包逻辑都写到一起,一来打包器自身的代码会变得非常臃肿,二来也会对原有的外围代码产生肯定的侵入性,混入很多与外围流程无关的代码,不易于前期的保护。因而,Rollup 设计出了一套残缺的插件机制,将本身的外围逻辑与插件逻辑拆散,让你能按需引入插件性能,进步了 Rollup 本身的可扩展性。
那接下来,我会带你剖析 Rollup 的插件机制,相熟 Rollup 插件的残缺构建阶段和工作流程,并且联合案例深刻插件开发细节。Rollup 的打包过程中,会定义一套残缺的构建生命周期,从开始打包到产物输入,中途会经验一些标志性的阶段,并且在不同阶段会主动执行对应的插件钩子函数(Hook)。对 Rollup 插件来讲,最重要的局部是钩子函数,一方面它定义了插件的执行逻辑,也就是 ” 做什么 ”;另一方面也申明了插件的作用阶段,即 ” 什么时候做 ”,这与 Rollup 自身的构建生命周期非亲非故。
一、Rollup 构建阶段
在执行 rollup 命令之后,在 cli 外部的次要逻辑简化如下:
// Build 阶段
const bundle = await rollup.rollup(inputOptions);
// Output 阶段
await Promise.all(outputOptions.map(bundle.write));
// 构建完结
await bundle.close();
而 Rollup 外部次要经验了 Build 和 Output 两大阶段,如下图所示。
首先,Build 阶段次要负责创立模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系。上面咱们用一个简略的例子来感受一下:
// src/index.js
import {a} from './module-a';
console.log(a);
// src/module-a.js
export const a = 1;
而后,执行如下的构建脚本:
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 对象信息。
{
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 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时裸露 generate 和 write 办法,以进入到后续的 Output 阶段。
所以,真正进行打包的过程会在 Output 阶段进行,即在 bundle 对象的 generate 或者 write 办法中进行。还是以下面的 demo 为例,咱们稍稍改变一下构建逻辑:
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();
从新执行我的项目后能够失去如下的输入:
{
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 两个阶段的具体工作流程。
二、拆解插件工作流
2.1 插件 Hook 类型
在具体讲述 Rollup 插件工作流之前,我想先给大家介绍一下不同插件 Hook 的类型,这些类型代表了不同插件的执行特点,是咱们了解 Rollup 插件工作流的根底,因而有必要跟大家好好拆解一下。
通过上文的例子,置信你能够直观地了解 Rollup 两大构建阶段(Build 和 Output)各自的原理。可能你会有疑难,这两个阶段到底跟插件机制有什么关系呢?实际上,插件的各种 Hook 能够依据这两个构建阶段分为两类: Build Hook 与 Output Hook。
- Build Hook:在 Build 阶段执行的钩子函数,在这个阶段次要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度个别为模块级别,也就是单文件级别。
- Ouput Hook:(官网称为 Output Generation Hook),则次要进行代码的打包,对于代码而言,操作粒度个别为 chunk 级别,而一个 chunk 通常指很多文件打包到一起的产物。
除了依据构建阶段能够将 Rollup 插件进行分类,依据不同的 Hook 执行形式也会有不同的分类,次要包含 Async、Sync、Parallel、Squential、First 这五种。在理论的开发过程中,咱们将接触各种各样的插件 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 逻辑。
刚刚咱们介绍了 Rollup 当中不同插件 Hook 的类型,实际上不同的类型是能够叠加的,Async/Sync 能够搭配前面三种类型中的任意一种,比方一个 Hook 既能够是 Async 也能够是 First 类型,接着咱们来具体分析一下 Rollup 当中的插件工作流程。
2.2 Build 阶段工作流
首先,咱们来剖析 Build 阶段的插件工作流程。对于 Build 阶段,插件 Hook 的调用流程如下图所示。
流程图的最下面申明了不同 Hook 的类型,也就是咱们在下面总结的 5 种 Hook 分类,每个方块代表了一个 Hook,边框的色彩能够示意 Async 和 Sync 类型,方块的填充色彩能够示意 Parallel、Sequential 和 First 类型。
接下来,咱们一步步来剖析 Build Hooks 的工作流程,你能够对照着图一起看。
- 首先经验 options 钩子进行配置的转换,失去解决后的配置对象。
- 随之 Rollup 会调用 buildStart 钩子,正式开始构建流程。
- Rollup 先进入到 resolveId 钩子中解析文件门路。(从 input 配置指定的入口文件开始)。
- Rollup 通过调用 load 钩子加载模块内容。
- 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比方 babel 转译。
- 当初 Rollup 拿到了模块内容,接下来就是进行 AST 剖析,失去所有的 import 内容,调用 moduleParsed 钩子。如果是一般的 import,则执行 resolveId 钩子,持续回到步骤 3;如果是动静 import,则执行 resolveDynamicImport 钩子解析门路,如果解析胜利,则回到步骤 4 加载模块,否则回到步骤 3 通过 resolveId 解析门路。
- 直到所有的 import 都解析结束,Rollup 执行 buildEnd 钩子,Build 阶段完结。
当然,在 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 钩子。
2.3 Output 阶段工作流
好,接着咱们来看看 Output 阶段的插件到底是如何来进行工作的。这个阶段的 Hook 相比于 Build 阶段略微多一些,流程上也更加简单。须要留神的是,其中会波及的 Hook 函数比拟多,可能会给你了解整个流程带来一些困扰,因而我会在 Hook 执行的阶段解释其大抵的作用和意义,对于具体的应用大家能够去 Rollup 的官网自行查阅,毕竟这里的主线还是剖析插件的执行流程,掺杂太多的应用细节反而不易于了解。上面我联合一张残缺的插件流程图和你具体分析一下。
以下是对于 Output 阶段工作流的阐明:
- 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。
- 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。
- 并发执行所有插件的 banner、footer、intro、outro 钩子 (底层用 Promise.all 包裹所有的这四种钩子函数),这四个钩子性能很简略,就是往打包产物的固定地位(比方头部和尾部) 插入一些自定义的内容,比方协定申明内容、我的项目介绍等等。
- 从入口模块开始扫描,针对动静 import 语句执行 renderDynamicImport 钩子,来自定义动静 import 的内容。
- 对每个行将生成的 chunk,执行 augmentChunkHash 钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会屡次打包的场景下,这个钩子会比拟实用。
- 如果没有遇到 import.meta 语句,则进入下一步,否则执行如下的 Case。对于 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 阶段正式完结。
到这里,咱们终于梳理完了 Rollup 当中残缺的插件工作流程,从一开始在构建生命周期中对两大构建阶段的感性认识,到当初插件工作流的具体分析,不禁感叹 Rollup 看似简略,实则外部细节繁冗。
三、罕用 Hook
实际上开发 Rollup 插件就是在编写一个个 Hook 函数,你能够了解为一个 Rollup 插件根本就是各种 Hook 函数的组合。
1,门路解析: resolveId
resolveId 钩子个别用来解析模块门路,为 Async + First 类型即异步优先的钩子。这里咱们拿官网的 alias 插件 来阐明,这个插件用法演示如下:
// rollup.config.js
import alias from '@rollup/plugin-alias';
module.exports = {
input: 'src/index.js',
output: {
dir: 'output',
format: 'cjs'
},
plugins: [
alias({
entries: [
// 将把 import xxx from 'module-a'
// 转换为 import xxx from './module-a'
{find: 'module-a', replacement: './module-a.js'},
]
})
]
};
插件的代码简化后如下:
export default alias(options) {
// 获取 entries 配置
const entries = getEntries(options);
return {
// 传入三个参数,以后模块门路、援用以后模块的模块门路、其余参数
resolveId(importee, importer, resolveOptions) {
// 先查看能不能匹配别名规定
const matchedEntry = entries.find((entry) => matches(entry.find, importee));
// 如果不能匹配替换规定,或者以后模块是入口模块,则不会持续前面的别名替换流程
if (!matchedEntry || !importerId) {
// return null 后,以后的模块门路会交给下一个插件解决
return null;
}
// 正式替换门路
const updatedId = normalizeId(importee.replace(matchedEntry.find, matchedEntry.replacement)
);
// 每个插件执行时都会绑定一个上下文对象作为 this
// 这里的 this.resolve 会执行所有插件 (除以后插件外) 的 resolveId 钩子
return this.resolve(
updatedId,
importer,
Object.assign({skipSelf: true}, resolveOptions)
).then((resolved) => {
// 替换后的门路即 updateId 会通过别的插件进行解决
let finalResult: PartialResolvedId | null = resolved;
if (!finalResult) {
// 如果其它插件没有解决这个门路,则间接返回 updateId
finalResult = {id: updatedId};
}
return finalResult;
});
}
}
}
从这里你能够看到 resolveId 钩子函数的一些罕用应用形式,它的入参别离是以后模块门路、援用以后模块的模块门路、解析参数,返回值能够是 null、string 或者一个对象,上面咱们分状况讨论一下。
- 返回值为 null 时,会默认交给下一个插件的 resolveId 钩子解决。
- 返回值为 string 时,则进行后续插件的解决。这里为了让替换后的门路能被其余插件解决,特意调用了 this.resolve 来交给其它插件解决,否则将不会进入到其它插件的解决。
- 返回值为一个对象,也会进行后续插件的解决,不过这个对象就能够蕴含更多的信息了,包含解析后的门路、是否被 enternal、是否须要 tree-shaking 等等,不过大部分状况下返回一个 string 就够用了。
2,load
load 为 Async + First 类型,即异步优先的钩子,和 resolveId 相似。它的作用是通过 resolveId 解析后的门路来加载模块内容。这里,咱们以官网的 image 插件 为例来介绍一下 load 钩子的应用。源码简化后如下所示:
const mimeTypes = {
'.jpg': 'image/jpeg',
// 前面图片类型省略
};
export default function image(opts = {}) {const options = Object.assign({}, defaults, opts);
return {
name: 'image',
load(id) {const mime = mimeTypes[extname(id)];
if (!mime) {
// 如果不是图片类型,返回 null,交给下一个插件解决
return null;
}
// 加载图片具体内容
const isSvg = mime === mimeTypes['.svg'];
const format = isSvg ? 'utf-8' : 'base64';
const source = readFileSync(id, format).replace(/[\r\n]+/gm, '');
const dataUri = getDataUri({format, isSvg, mime, source});
const code = options.dom ? domTemplate({dataUri}) : constTemplate({dataUri});
return code.trim();}
};
}
从中能够看到,load 钩子的入参是模块 id,返回值个别是 null、string 或者一个对象:
- 如果返回值为 null,则交给下一个插件解决;
- 如果返回值为 string 或者对象,则终止后续插件的解决,如果是对象能够蕴含 SourceMap、AST 等。
3,代码转换: transform
transform 钩子也是十分常见的一个钩子函数,为 Async + Sequential 类型,也就是异步串行钩子,作用是对加载后的模块内容进行自定义的转换。咱们以官网的 replace 插件为例,这个插件的应用形式如下:
// rollup.config.js
import replace from '@rollup/plugin-replace';
module.exports = {
input: 'src/index.js',
output: {
dir: 'output',
format: 'cjs'
},
plugins: [
// 将会把代码中所有的 __TEST__ 替换为 1
replace({__TEST__: 1})
]
};
事实上,transform 的外部实现也并不是很简单,次要通过字符串替换来实现,外围逻辑简化如下:
import MagicString from 'magic-string';
export default function replace(options = {}) {
return {
name: 'replace',
transform(code, id) {
// 省略一些边界状况的解决
// 执行代码替换的逻辑,并生成最初的代码和 SourceMap
return executeReplacement(code, id);
}
}
}
function executeReplacement(code, id) {const magicString = new MagicString(code);
// 通过 magicString.overwrite 办法实现字符串替换
if (!codeHasReplacements(code, id, magicString)) {return null;}
const result = {code: magicString.toString() };
if (isSourceMapEnabled()) {result.map = magicString.generateMap({ hires: true});
}
// 返回一个带有 code 和 map 属性的对象
return result;
}
transform 钩子的入参别离为模块代码、模块 ID,返回一个蕴含 code(代码内容) 和 map(SourceMap 内容) 属性的对象,当然也能够返回 null 来跳过以后插件的 transform 解决。须要留神的是,以后插件返回的代码会作为下一个插件 transform 钩子的第一个入参,实现相似于瀑布流的解决。
4,Chunk 级代码批改: renderChunk
这里咱们持续以 replace 插件举例,在这个插件中,也同样实现了 renderChunk 钩子函数:
export default function replace(options = {}) {
return {
name: 'replace',
transform(code, id) {// transform 代码省略},
renderChunk(code, chunk) {
const id = chunk.fileName;
// 省略一些边界状况的解决
// 拿到 chunk 的代码及文件名,执行替换逻辑
return executeReplacement(code, id);
},
}
}
能够看到这里 replace 插件为了替换后果更加精确,在 renderChunk 钩子中又进行了一次替换,因为后续的插件依然可能在 transform 中进行模块内容转换,进而可能呈现合乎替换规定的字符串。
这里咱们把关注点放到 renderChunk 函数自身,能够看到有两个入参,别离为 chunk 代码内容、chunk 元信息,返回值跟 transform 钩子相似,既能够返回蕴含 code 和 map 属性的对象,也能够通过返回 null 来跳过以后钩子的解决。
5,generateBundle
generateBundle 也是异步串行的钩子,你能够在这个钩子外面自定义删除一些无用的 chunk 或者动态资源,或者本人增加一些文件。这里咱们以 Rollup 官网的 html 插件来具体阐明,这个插件的作用是通过拿到 Rollup 打包后的资源来生成蕴含这些资源的 HTML 文件,源码简化后如下所示:
export default function html(opts: RollupHtmlOptions = {}): Plugin {
// 初始化配置
return {
name: 'html',
async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) {
// 省略一些边界状况的解决
// 1. 获取打包后的文件
const files = getFiles(bundle);
// 2. 组装 HTML,插入相应 meta、link 和 script 标签
const source = await template({attributes, bundle, files, meta, publicPath, title});
// 3. 通过上下文对象的 emitFile 办法,输入 html 文件
const htmlFile: EmittedAsset = {
type: 'asset',
source,
name: 'Rollup HTML Asset',
fileName
};
this.emitFile(htmlFile);
}
}
}
置信从插件的具体实现中,你也能感触到这个钩子的弱小作用了。入参别离为 output 配置、所有打包产物的元信息对象,通过操作元信息对象你能够删除一些不须要的 chunk 或者动态资源,也能够通过 插件上下文对象的 emitFile 办法输入自定义文件。
好,罕用的 Rollup 钩子咱们就先介绍到这里,置信这些知识点曾经足够你应酬大多数的构建场景了。顺便说一句,大家在前面的章节能够理解到,Vite 的插件机制也是基于 Rollup 来实现的,像下面介绍的这些罕用钩子在 Vite 当中也随处可见,因而,把握了这些罕用钩子,也相当于给 Vite 插件的学习做下了很好的铺垫。