[陈同学i前端] 一起学Rollup|构建工作流与插件机制
前言
大家好,我是陈同学,一枚野生前端开发者,感谢各位的点赞、收藏、评论
Rollup一个用于JavaScript
的模块打包器,与webpack齐名并依靠自身差异化的体系设计赢得了许多业界工作者的青睐,同时也在Vite
的架构体系中发挥着重要作用
上一节文章中我们已经掌握Rollup打包的基础操作,但随着我们的业务发展,项目会出现各种各样的构建需求,比如:全局变量构建时替换、代码压缩、路径解析与别名,像这种业务场景相关的需求逻辑如果与核心打包逻辑放在一块,会对打包器源码造成侵入性,不利于后期的维护,故Rollup作者另外设计出插件机制以将关键打包节点的流程控制权交给开发者,开发者根据自身业务需要编写对应的钩子函数最终实现目标打包效果
本文阅读成本与收益如下:
阅读耗时:10mins
全文字数:9k+
预期效益
-
学习
Rollup
两大构建工作流 -
学习
Rollup
插件机制 -
了解两大构建阶段中的插件
Build-Hook
Output-Hook
-
了解插件的类型
钩子函数
钩子函数:
-
定义了插件的执行逻辑
-
声明了插件的作用阶段
两大构建工作流
Rollup打包构建流程主要包括两大步骤:
-
Build:主要负责创建模块依赖图,初始化各个模块的
AST
以及模块之间的依赖关系(解析各模块的内容及依赖关系) -
Output:构建打包并输出最终产物
在不同的构建阶段,Rollup
插件会有不同的插件工作流程
// src/index.js
import { add } from './tool';
console.log(add(1, 2));
// src/tool.js
export const add = (a, b) => a + b;
export const multi = (a, b) => a * b;
现有如上两个JS文件
Build
下面将通过JS-API执行第一步构建逻辑:解析文件后获得各个模块的内容及依赖关系
// build.js
const rollup = require("rollup");
const inputOptions = {
input: "./src/index.js",
external: [],
plugins:[]
};
async function build() {
const bundle = await rollup.rollup(inputOptions);
// 打印各个模块的内容及依赖关系
console.log(bundle); // 打印_1
console.log(bundle.cache.modules); // 打印_2
}
build();
打印_1:
{
cache: {
modules: [ [Object], [Object] ],
plugins: [Object: null prototype] {}
},
close: [AsyncFunction: close],
closed: false,
generate: [AsyncFunction: generate],
watchFiles: [
'/Users/frontend-starter/src/index.js',
'/Users/frontend-starter/src/tool.js'
],
write: [AsyncFunction: write]
}
打印_2:
可见经过Build
阶段的bundle
对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,并提供generate
(不写磁盘)、write
(写入磁盘)方法以便后续output
阶段输出产物
Output
通过JS-API实现构建流程中的打包生成
逻辑
// build.js
const rollup = require('rollup');
const inputOptions = {
input: './src/index.js',
external: [],
plugins: [],
};
const outputOptions = {
// dir: 'dist/es', // 调用write时输出产物到该目录
entryFileNames: `[name].[hash].js`,
chunkFileNames: 'chunk-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
format: 'es',
sourcemap: true,
};
async function build() {
const bundle = await rollup.rollup(inputOptions);
const resp = await bundle.generate(outputOptions); // 打包生成产物(generate不写入磁盘)
console.log(resp);
}
build();
执行后结果
{
output: [
{
exports: [],
facadeModuleId: '/Users/frontend-starter/src/index.js',
isDynamicEntry: false,
isEntry: true,
isImplicitEntry: false,
modules: [Object: null prototype],
name: [Getter],
type: 'chunk',
code: '// src/tool.js\n' +
'\n' +
'const add = (a, b) => a + b;\n' +
'\n' +
'// src/index.js\n' +
'\n' +
'console.log(add(1, 2));\n',
dynamicImports: [],
fileName: 'index.7bc8c2f6.js',
implicitlyLoadedBefore: [],
importedBindings: {},
imports: [],
map: [SourceMap],
referencedFiles: []
}
]
}
插件机制
插件HOOK类型
Rollup插件机制中有不同的Hook
类型,这些类型代表了不同插件的执行特点
根据构建阶段分类
插件的各种Hook
可以根据这两个构建阶段分为两类:Build-Hook
与Output-Hook
-
Build-Hook:在Build阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别
-
Ouput-Hook(Output Generation Hook):主要进行代码的打包,对于代码而言,操作粒度一般为
chunk
级别(一个chunk
通常指很多文件打包到一起的产物)
根据执行方式分类
- Async & Sync
Async:异步钩子函数
Sync:同步钩子函数
最大的区别在于同步钩子里面不能有异步逻辑
- Parallel
并行钩子函数,如果有多个插件实现了这个钩子的逻辑,若某一个插件实现的钩子函数是异步逻辑,则并发执行钩子函数,不会等待当前钩子完成(与Sequential有区别,底层Promise.all)
说明:插件之间的操作并不是相互依赖的,也就是可以并发执行,从而提升构建性能
- Sequential
串行的钩子函数,适用于插件间处理结果相互依赖的情况,前一个插件Hook
的返回值作为后续插件的入参
如果其中某一个插件注册的钩子是异步的,后续的这类钩子将等待,直到当前钩子被解析完成
- First
如果有多个插件实现了这个Hook
,那么Hook
将依次运行,直到返回一个非null
或非undefined
的值为止
补充
-
Async/Sync可以搭配后面三种类型中的任意一种
-
钩子也可以是对象,而不是函数
return {
name: 'resolve-first',
resolveId: {
order: 'pre', // "pre" | "post" | null
handler(source) {
if (source === 'external') {
return { id: source, external: true };
}
return null;
}
}
};
-
如果多个插件指定order为
pre
或post
,Rollup将按照用户提供的顺序运行它们,该选项可用于所有插件钩子(对于并行钩子,则改变运行钩子的同步部分顺序) -
钩子对象中的
sequential
属性
当插件A, B, C, D, E
都实现了相同的并行钩子,并且中间的插件C具有sequential: true
,那么Rollup将首先并行运行A + B
,然后是C单独运行,然后是D + E
并行运行
return {
name: 'getFilesOnDisk',
writeBundle: {
sequential: true,
order: 'post',
async handler({ dir }) {
const topLevelFiles = await readdir(resolve(dir));
console.log(topLevelFiles);
}
}
};
Build阶段工作流
- 通过
options
钩子进行配置的转换,得到处理后的配置对象
钩子函数类型: (options: InputOptions) => InputOptions | null
钩子执行方式: async
, sequential
前置钩子: 无,这是build阶段第一个钩子
后置钩子: buildStart
- 调用
buildStart
钩子,正式开始构建流程
钩子函数类型: (options: InputOptions) => void
钩子执行方式: async
, parallel
前置钩子: options
后置钩子: resolveId
平行解析每一个入口模块
- 调用
resolveId
钩子解析模块文件路径
从inputOption的input配置指定的入口文件开始,每当匹配到引入外部模块的语句(如:import a from 'a'
)便依次执行注册插件中的每一个 resolveId
钩子,直到某一个插件中的 resolveId
执行完后返回非 null
或非 undefined
的值,将停止执行后续插件的 resolveId
逻辑并进入下一个钩子
return {
name: 'pluginName',
resolveId(importee, importer, resolveOptions) {
// 每个插件执行时都会绑定一个上下文对象作为 this
// this.resolve 会执行所有插件(除当前插件外)的 resolveId 钩子
return this.resolve(
importee,
importer,
Object.assign({ skipSelf: true }, resolveOptions)
).then((resolved) => {
// 替换后的路径会经过别的插件进行处理
let finalResult: PartialResolvedId | null = resolved;
if (!finalResult) {
// 如果其它插件没有处理这个路径,则直接返回
finalResult = { id: importee };
}
return finalResult;
});
}
}
- 调用
load
钩子加载模块内容
前提:经过resolveId
处理后的模块路径不在external
列表当中
return {
name: 'pluginName',
load(id) {
// code
return code;
}
}
- 接着判断当前解析的模块是否存在缓存,若不存在则执行所有的
transform
钩子来对模块内容进行进行自定义的转换;若存在则判断shouldTransformCachedModule
属性,true则执行所有的transform
钩子,false则进入moduleParsed
钩子逻辑
return {
name: 'pluginName',
transform(code, id) {
// 执行代码转化的逻辑,并生成最后的代码和SourceMap
return codeTransform(code, id);
}
}
- 这一步拿到最后的模块内容,进行
AST
分析,得到所有的import
内容,调用moduleParsed
钩子:
- 如果是普通的
import
,则执行resolveId
钩子,继续回到步骤3 - 如果是动态
import
,则执行resolveDynamicImport
钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过resolveId
解析路径
钩子函数类型: (moduleInfo: ModuleInfo) => void
钩子执行方式: async
,parallel
前置钩子: transform
后置钩子: resolveId
,resolveDynamicImport
平行地解析所有静态或动态引入的模块
- 直到所有的
import
都解析完毕,Rollup
执行buildEnd
钩子,Build阶段结束
钩子函数类型: (error?: Error) => void
钩子执行方式: async
,parallel
前置钩子: moduleParsed
,resolveId
,resolveDynamicImport
后置钩子: outputOptions
为build阶段的最后一个钩子逻辑
Output阶段工作流
- 执行所有插件的
outputOptions
钩子函数,对output
配置进行转换
钩子函数类型: (outputOptions: OutputOptions) => OutputOptions | null
钩子执行方式: sync
, sequential
前置钩子: buildEnd
(第一次输入生成),generateBundle
, writeBundle
, renderError
(其它情况)
后置钩子: renderStart
- 执行
renderStart
,并发执行renderStart
钩子,正式开始打包
该钩子可以读取所有outputOptions钩子的转换之后,可以访问的输出选项
钩子函数类型: (outputOptions: OutputOptions, inputOptions: InputOptions) => void
钩子执行方式: async
, parallel
前置钩子: outputOptions
后置钩子: renderDynamicImport
(chunk中存在未处理的动态引入表达式), resolveFileUrl
(chunk中存在未处理的import.meta.url), resolveImportMeta
(chunk中存在未处理的import.meta.*)
-
并发执行所有插件的
banner、footer、intro、outro
钩子(底层用Promise.all
包裹所有的这四种钩子函数),这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等 -
从入口模块开始扫描,扫描
动态import
语句执行renderDynamicImport
钩子,让开发者能自定义动态import
的内容与行为
钩子函数类型: ({format: string, moduleId: string, targetModuleId: string | null, customResolution: string | null}) => {left: string, right: string} | null
钩子执行方式: sync
, first
前置钩子: renderStart
(若为第一个chunk), banner, footer, intro, outro
(其它情况)
后置钩子: resolveFileUrl
(处理每一个import.meta.ROLLUP_FILE_URL_referenceId
表达式) and resolveImportMeta
(处理每一个import.meta
).
- 对每个即将生成的
chunk
,执行augmentChunkHash
钩子,来决定是否更改chunk
的哈希值
钩子函数类型: (chunkInfo: ChunkInfo) => string
钩子执行方式: sync
, sequential
前置钩子: renderChunk
.
后置钩子: renderChunk
(若还有其它chunk待处理), generateBundle
(所有chunk处理完成)
return {
name: 'pluginName',
augmentChunkHash(chunkInfo) {
if (chunkInfo.name === 'foo') {
return Date.now().toString(); // 返回的值为false则不会改变哈希值,若不为false则传递给hash.update
}
}
};
TIP:在 watch
模式下即可能会多次打包的场景下,这个钩子会比较适用
- 若无
import.meta
语句,则进入下一步,否则:
- 对于
import.meta.url
调用resolveFileUrl
来自定义 url 解析逻辑 - 对于
import.meta
调用resolveImportMeta
来进行自定义元信息解析
-
接着生成所有
chunk
的内容,针对每个chunk
会依次调用插件的renderChunk
方法进行自定义操作,在这里可以直接操作打包产物 -
调用
generateBundle
钩子,这个钩子的入参里面会包含所有的打包产物信息,包括chunk
(打包后的代码)、asset
(最终的静态资源文件)。可以在这里删除一些chunk
或者asset
,最终被删除的内容将不会作为产物输出
-
rollup.rollup
方法会返回一个bundle
对象,这个对象是包含generate和write两个方法,两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发writeBundle
钩子,传入所有的打包产物信息,包括chunk
和asset
,与generateBundle
钩子非常相似 -
writeBundle
钩子执行的时候,产物已经输出了 -
generateBundle
执行的时候产物还并没有输出
顺序:generateBundle
->输出并保存产物到磁盘
->writeBundle
- 当上述的
bundle
的close
方法被调用时,会触发closeBundle
钩子(Output
阶段正式结束)
钩子函数类型: closeBundle: () => Promise<void> | void
钩子执行方式: async
, parallel
前置钩子: buildEnd
讲到最后
Rollup插件机制将Rollup打包器的构建打包流程当中各个关键的节点通过钩子函数的方式提供给开发者,解决了各种各样的特殊开发场景下的构建流程自定义的问题,进一步提高了Rollup的灵活性、可扩展性
本节文章我们一开始通过简单的例子初步了解Rollup构建的两个阶段,并依据这两个阶段工作流展开了对Rollup插件体系的学习,通过流程图以及对每一个节点的关键描述,我们浅浅地了解了工作流中每一步钩子函数的作用以及部分应用场景,从中我们发现:
-
插件的编写简单易上手,只需要编写一个
对象A
,对象当中包含一个name
属性以及各个钩子函数对象,使用时将对象A
作为RollupinputOption
的plugins
属性数组元素即可 -
单个插件当中可以编写各个构建阶段的Hook,使得单个插件的潜在能力变得空前强大,在webpack当中分为
loader
、plugin
的功能,一个Rollup插件就可以实现 -
通过插件上下文对象
this
,我们可以在一个插件的钩子函数当中通过this.resolve()
调用其它插件的对应钩子函数,增加了插件的灵活性 -
在特定的应用场景下找到合适的钩子函数能够快速解决业务上的问题
大家通过本篇文章能够对Rollup
插件体系形成一个初步的了解,之后文章我们再通过简单的案例来巩固本节的内容
谢谢大家,我们下节再见!!!
感谢各位看到这里,如果你觉得本节内容还不错的话,欢迎各位的点赞、收藏、评论,大家的支持是我做内容的最大动力
本文为作者原创,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利
补充
转载自:https://juejin.cn/post/7155702261724184589