前言
在前面的5篇文章中,我们已经大致分析清楚了Rollup构建和生成的总体流程,在通过学习源码的过程中,我们已经对Rollup的几个核心类有了一定的认知,如果说您阅读之前的文章是为了应付面试的话,这篇文章将会更多的提供开发实践中的指导意义,必定值得您细细品读。
在这篇文章中,我会向大家阐述Rollup中重要的生命周期的原理,并且会结合一些Vite插件的例子向大家介绍这些生命周期的实际用途,升华大家对Rollup生命周期的认知。
Rollup的在打包过程中有两个阶段:构建和生成。由于篇幅的关系,我们在本文就暂时先选取构建过程中较为重要的生命周期阐述并对其中的一些举例实际应用。
好了,废话不多说,我们就开始吧。
生命周期钩子的执行顺序
Rollup的生命周期的钩子图例中标注了几种类型,分别是parallel
,sequential
,async
,sync
,first
。
它的官方文档写的真的挺让人费解的,我们先直接看源码,来看一下最终到底运行的情况是什么。
first
resolveId
这个生命周期在图例上被标记为first
类型的钩子,我们看一下源码实现。
resolveId
这个生命周期是在ModuleLoader执行的时候触发的。
好了,当我们找到了这个生命周期之后,我们就可以看看它的实现方式了。 Rollup会根据生命周期的配置方式来分配插件执行的顺序,但也会根据类别保持同类插件的相对顺序。
搞清楚插件顺序之后,我们就可以看runHook
的具体实现了。
ts
class PluginDriver {
private runHook<H extends AsyncPluginHooks | AddonHooks>(
hookName: H,
parameters: unknown[],
plugin: Plugin,
replaceContext?: ReplaceContext | null
): Promise<unknown> {
// We always filter for plugins that support the hook before running it
const hook = plugin[hookName];
const handler = typeof hook === 'object' ? hook.handler : hook;
let context = this.pluginContexts.get(plugin)!;
if (replaceContext) {
context = replaceContext(context, plugin);
}
let action: [string, string, Parameters<any>] | null = null;
return Promise.resolve()
.then(() => {
if (typeof handler !== 'function') {
return handler;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const hookResult = (handler as Function).apply(context, parameters);
if (!hookResult?.then) {
// short circuit for non-thenables and non-Promises
return hookResult;
}
// Track pending hook actions to properly error out when
// unfulfilled promises cause rollup to abruptly and confusingly
// exit with a successful 0 return code but without producing any
// output, errors or warnings.
action = [plugin.name, hookName, parameters];
this.unfulfilledActions.add(action);
// Although it would be more elegant to just return hookResult here
// and put the .then() handler just above the .catch() handler below,
// doing so would subtly change the defacto async event dispatch order
// which at least one test and some plugins in the wild may depend on.
return Promise.resolve(hookResult).then(result => {
// action was fulfilled
this.unfulfilledActions.delete(action!);
return result;
});
})
.catch(error_ => {
if (action !== null) {
// action considered to be fulfilled since error being handled
this.unfulfilledActions.delete(action);
}
return error(logPluginError(error_, plugin.name, { hook: hookName }));
});
}
}
这个runHook
函数有点儿复杂,因为它同时包含了异步和同步的处理,我们逐步拆分开来看。
对于这个context,我们可以不用那么关注,反正它是用来绑定插件handler
的执行上下文的,就把它看成this就好了,大事化小小事化了嘛。
如果当前插件返回的值不是一个Promise
,那么就什么都不用管,当前Promise
的状态立即就改变了。但是如果是Promise
的话,这儿就会等待这个Promise
的状态改变。
对于unfulfilledActions
的处理,我们实际上也不用管,这是Rollup是用来在构建出错的时候打印未完成任务提示信息的。
以上代码的逻辑其实就是一个Promise
的链式调用,即上一个Promise
的状态没发生变化的时候,会一直等待,直到它的状态发生变化,然后会处理一下一个节点。就像过年放鞭炮一样,上面一个鞭炮炸掉之后,下一个鞭炮才会爆炸。
如果说插件返回值都是同步内容的话,实际上跟同步任务差不多,只不过这些同步任务都被推到了微任务队列里面去执行了。
所以,对于first
类型的插件,我们可以简化成下面的写法来理解它即可。
js
function task1() {
return Promise.resolve(1);
}
function task2() {
return Promise.resolve(2);
}
function task3() {
return Promise.resolve(3);
}
async function taskRunner(taskList) {
for(task of taskList) {
const result = await task();
if(result !== null) {
return result;
}
console.log(result);
}
return null;
}
taskRunner([task1, task2, task3])
first
类型的钩子有一个重要的特点,当其中一个插件返回值不为null
时,后续的插件将不再执行。
sequential
options
这个生命周期在图例上被标记为sequential
类型的钩子,我们看一下源码实现。 之前我们已经看过getSortedValidatedPlugins
函数的实现了,这儿就不赘述了,不清楚可以回到上小节查看。
所以,对于sequential
类型的插件,我们可以简化成下面的写法来理解它即可。
js
function task1() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(1)
}, 1000)
})
}
function task2() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(2)
}, 1000)
})
}
function task3() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(3)
}, 1000)
})
}
async function taskRunner(taskList) {
for(task of taskList) {
const result = await task();
console.log(result);
}
}
taskRunner([task1, task2, task3])
sequential
和first
类型几乎没有什么区别,唯一的区别就是sequential
不在乎返回值,不会因为其中一个返回值提前终止,无论怎么样都会执行完所有插件的钩子函数。
parallel
buildStart
这个生命周期在图例上被标记为parallel
类型的钩子,我们看一下源码实现。 这个代码有点儿费解,我们先假设插件没有一个配置了sequential
这个参数,那么if
总是不能命中,那么,其实就相对于是一堆异步任务同时触发,但是最终执行的时间取这些异步任务里面耗时最长的那个时间。
好,明白了这个之后,剩下的就比较简单了,现在假设遇到了一个配置了sequential
的插件,也就是之前那一堆的并发异步任务都要处理,等处理完了,把之前累积的可并发的异步任务清一下,然后接着处理当前sequential
类型的异步任务。
所以,对于parallel
类型的插件,我们可以简化成下面的写法来理解它即可。
js
function task1() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(1)
}, 1000)
})
}
function task2() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(2)
}, 1000)
})
}
function task3() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(3)
}, 1000)
})
}
function task4() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(4)
}, 1000)
})
}
function task5() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(5)
}, 1000)
})
}
function task6() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(6)
}, 1000)
})
}
function task7() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(7)
}, 1000)
})
}
function task8() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(8)
}, 1000)
})
}
async function taskRunner(taskList) {
for(task of taskList) {
// 对于数组,并发执行
if(Array.isArray(task) {
const result = await Promise.all(task);
console.log(result);
} else {
const result = await task();
console.log(result);
}
}
}
taskRunner([[task1, task2, task3], task4, [task5, task6], task7, task8)
sync
outputOptions
这个生命周期在图例上被标记为sync
类型的钩子,我们看一下源码实现。 对于这个类型的钩子,这是真正在一个同步任务里面完成的,也就是说,sync类型的钩子甚至返回值都不能是Promise。
生命周期钩子的执行顺序小结
-
对于Rollup的插件系统来说,如果一个钩子是
sync
类型的钩子,那么该钩子的返回值不能是Promise
,除此之外的钩子都默认是async
钩子 ,Rollup将会尝试以Promise.resolve
解析包裹的值,哪怕你返回的值并不是一个Promise
。 -
first
类型的钩子和sequential
钩子类似,只不过first
类型的钩子只有第一个返回值会影响Rollup的行为,,而sequential
钩子的任意返回值都将影响Rollup的行为。 -
parallel
类型的钩子采用Promise.all
类型的方式执行钩子,各个钩子之间没有前后的依赖关系。
构建生命周期
options
这是一个sequential
类型的钩子,这个钩子不怎么常用。
替换或操作传递给
rollup.rollup
的选项对象。返回null
不会替换任何内容。如果只需要读取选项,则建议使用buildStart
钩子,因为该钩子可以访问所有options
钩子的转换考虑后的选项。
Rollup的源码实现:
buildStart
在每个
rollup.rollup
构建上调用。当你需要访问传递给rollup.rollup()
的选项时,建议使用此钩子,因为它考虑了所有options
钩子的转换,并且还包含未设置选项的正确默认值。
这是一个parallel
类型的钩子,这个钩子不怎么常用。
这个钩子的触发时机非常靠前,此刻Rollup都还没有开始解析文件,我们可以使用这个钩子在处理过程中进行一些初始化的操作。
Rollup的源码实现: 这个钩子也不需要任何返回值。
demo之加载自定义编译器
在@vitejs/plugin-vue
这个插件中,使用了buildStart
生命周期用来初始化Vue的编译器。
resolveId
定义一个自定义解析器。
这是一个first
类型的钩子,这个钩子是用来处理加载资源的标识的。
这是一个非常常用的钩子,使用这个钩子可以用来实现路径别名,可以用来实现虚拟模块。
resolveId
的源码相对来说比较简单,但是要向大家解释清楚它是干什么的,需要关联的知识点就比较多了,我们接下来一起看看。
之前我们已经看过first
类型插件的实现了,我们就不再赘述了,现在假设我们通过插件系统拿到或没有拿到预期的内容,我们一起来看看这个id最终是如何使用的。 在解析到结果的时候,把解析到的内容返回: 经历了以上历程以后,当前Module依赖资源的ID就已经确定好了,然后,就可以拿着这些ID去加载对应的资源了。 最终,通过ID获取到预期的资源,资源的来源可以是插件的返回值,否则则尝试从文件系统中读取,若读取不到,则报错。
demo1之路径别名
js
function customAlias() {
return {
name: "custom-resolver",
resolveId(source) {
if (source.startsWith("@")) {
const resolvedPath = path.resolve(__dirname, "src", source.slice(1));
return resolvedPath;
}
return null;
},
};
}
在Vite中,我们可以配置路径别名,其底层的实现就是这样的。
demo2之虚拟模块
js
export default function myPlugin() {
const virtualModuleId = 'virtual:my-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'my-plugin',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const msg = "from virtual module"`
}
}
}
}
所以我们需要在resolveId
生命周期将其处理逻辑接管,避免别的插件处理到了从而造成非预期的错误。
在虚拟模块的这个插件中,我们在load
生命周期接管到了之前带有特征含义开头的\0
的id,然后处理。
load
定义一个自定义加载器。
在之前我们已经处理好了文件ID,此刻就可以使用这个ID进行文件的加载了。load
也是一个async
、first
类型的生命周期。
这个生命周期的意义就是把读取ID映射文件内容的过程可以自定义,比如在之前我们所提到的虚拟模块,其核心就是要在load的时候返回一个现成的内容,这样就避免了从磁盘中读取。
另外,我们还可以在这个生命周期中从磁盘中加载内容,然后根据自己的业务逻辑,在读取到的内容的前面或后面拼接一些内容,可以达到在非生产模式注入一些工具或者变量的目的。
Rollup的源码实现如下:
demo之自定义文件加载内容
在@vitejs/plugin-vue
中,处理到了.vue
文件之后,需要分别将.vue
文件中的script
,template
,style
分别再次加载,此刻就需要分辨出什么加载某个vue文件上的哪个部分,因此,就会附加上querystring的方式来进行分辨。
transform
可以被用来转换单个模块。
这是一个async
,sequential
类型的生命周期。
这是Rollup整个构建阶段的生命周期中最最最常见且重要的一个生命周期,在这个生命周期中,我们可以对源代码进行转化。
为什么这个生命周期会被设计成sequential
类型的呢,这样的话,每个插件都有资格对某个内容进行修改,但是每个插件也必须要等待前面一个插件处理完成,才会轮到自己处理。
我们来看一下Rollup的源码实现:
transform最终得到的结果是交给对应的Module进行绑定,然后通过这个内容分析出依赖,递归加载依赖直到加载完成所有的内容;通过这份内容生成AST(如果transform直接就返回的是AST,Rollup就不再转AST)在未来TreeShaking阶段标记AST中不需要的节点,在生成Chunk的时候读取Module上的AST,并剔除不可达的code完成树摇,合并输出成最终的Chunk输出。 Rollup是采用reduce
的方式来进行叠加处理的。
如果某个插件返回null
,Rollup会跨过这个值,否则,就使用当前这个值,然后接着开启下一轮迭代。 最后,把这个迭代函数传递给PluginDriver,然后遍历所有处理transform的钩子,以async
+sequential
的方式,直到处理完所有的代码转换。
demo之DEV环境插件注入
就以我之前编写的环境变量控制的例子向大家举例子吧,嘿嘿。
ts
interface FunnyControlCenterPluginOptions {
disabled?: boolean
entry?: string | string[]
}
export function funnyControlCenterPlugin(options: FunnyControlCenterPluginOptions) {
return {
name: 'vite-plugin-funny-control-center',
enforce: 'pre',
transformIndexHtml(html: string) {
// 生产环境不做任何转换
if (process.env.NODE_ENV === 'production' || options.disabled) {
return html
}
// 非生产环境插入funny control center的渲染DOM节点
const customDom = `<div id="funny-control-center"></div>`
return html.replace('<body>', `<body>${customDom}`)
},
// 处理每个文件,特别是 src/main.js
transform(sourceCode: string, id: string) {
// 生产环境
if (process.env.NODE_ENV === 'production' || options.disabled) {
return null
}
const { entry = 'src/main.js' } = options
// Compatible to solve the windows path problem
let entryPath = Array.isArray(entry) ? entry : [entry]
if (process.platform === 'win32') {
entryPath = entryPath.map((item) => item.replace(/\\/g, '/'))
}
if (
entryPath.some((v) => {
return id.indexOf(v) >= 0
})
) {
const insertCode = `
// eslint-disable-next-line
import '@funny/control-center';`
// 将插入的代码放到文件的最顶部
return {
code: `${insertCode}\n${sourceCode}`,
map: null,
}
}
return null
},
}
}
在这个插件中,我注入了一个自己编写的插件UI,用来修改环境变量,因为有可能不符合项目本来的ESLint
配置,所以得对注入的内容关闭ESLint校验,防止影响使用者正常的开发。
moduleParsed
每次 Rollup 完全解析一个模块时,都会调用此钩子。
这个生命周期就是某个文件的内容处理完成了,在这个钩子里面可以获取到当前加载完成的Module的信息,在之前的文章中我们分析过,Module类上绑定了很多内容,有源代码,有当前文件依赖的内容,有源代码解析得到的AST内容等等。
Rollup的处理逻辑很简单,就是Module上的关键信息暴露出来了:
resolveDynamicImport
与resolveId
类似,区别是resolveDynamicImport
处理的是动态导入。
并且源码所处的位置也是相似的,都是在fetchModule
里面。
buildEnd
在 Rollup 完成产物但尚未调用
generate
或write
之前调用
这是一个async
、parallel
类型的生命周期。
假设在之前的处理过程中没有任何错误的话,调用这个生命周期时,Rollup构建过程已经完成了,也就是说磁盘中的内容已经全部加载至内存了,但是此刻还没有优化和打包合成。
但是如果之前的产生了错误的话,也是直接触发这个生命周期。
分辨是在什么情况下触发的这个生命周期的话,可以通过判断有无错误信息来进行判断。
以下是Rollup的源码实现:
在这个生命周期中,我们可以用来处理一些收尾的工作,不过具体demo就不给出了,大家只要明白它的意义即可。
总结
本文从源码的角度分析了Rollup不同类型的生命周期的实现,并以浅显易懂的示例Demo方式向大家阐述清楚了async
钩子和sync
钩子的区别,也阐述清楚了parallel
、sensequential
、first
类型钩子的区别。
本文对构建阶段生命周期的钩子的讲解是假定您已经完全掌握了Rollup构建阶段的处理逻辑,如果您觉得文章内容看起来尚且吃力,可以先阅读我本系列文章的第二篇和第三篇讲解Rollup文件加载逻辑方面的文章,有任何问题可以和我联系。
Rollup的插件系统因为这些不同类型的钩子函数,使得我们可以处理的时机或内容变的广泛而精彩,这是非常值得我们学习的。
在Rollup的插件系统的设计实现中可以看的到目前前端笔试题中一些常见的异步任务处理场景题的影子,大家需要引起一定的重视,如果你完全掌握这些编程技法,将会对你的编程能力有一个较大的提升。
我通过实际项目总结经验得出大家最需要掌握的钩子是buildStart
、resolveId
、load
、transform
钩子。
在下一篇中,我们将开始对Rollup输出阶段生命周期钩子的讲解,未完待续......