前言
随着前端基建的不断发展,涌现出了很多前端工具链,例如Webpack
、Rollup
、Eslint
。 再或者,一些日常应用工具,例如: utools
、PicGo
等等。我们可以发现他们都有个共同点:都有完善的插件机制。
如下图,在utools中,想要某个功能,不需要我们去更新软件,而是直接下载插件,不同的插件是相互隔离的:
为什么这些工具都要集成插件机制呢?
对于一个系统或软件而言,如果有一套插件系统,就能够很大程度上解决后续功能的扩展,并且可以最小化影响现有的功能。
下面我画了一张图,来粗略的描述,一个系统中插件的执行流程:
不难看出插件的几个特征:
- 不管是任何系统和软件,都存在 生命周期,例如:初始化、启动、执行、结束等。
- 插件的目标就是在这个生命周期执行过程中,对过程中的数据、执行逻辑进行修改, 有很强的 扩展性和灵活性。
- 插件是独立于系统流程之外的代码,这也就意味着,插件有极强的 独立性,它出现故障并不会对系统和软件造成很大影响。
我们可以联想到类似 usb
, 硬盘
等,其实都可以随时插拔,随时作用于系统,这样看是不是很形象~
下面,跟着我在开源项目中,学习插件机制~
Webpack的插件设计
说到插件,就不得不提到前端领域对插件使用的集大成者,当你翻看Webpack源码时,你只会看到各种插件~。
如果了解过webpack
插件机制,应该知道它使用tapable
来实现对webpack构建流程的侵入。
Webpack 的插件体系是一种基于 Tapable 实现的强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。
Tapable 是 Webpack 插件架构的核心支架,但它的代码量其实很少,本质上就是围绕着 订阅/发布
模式叠加各种特化逻辑,适配 Webpack 体系下复杂的事件源-处理器之间交互需求,相关的用法就不多做介绍,推荐这篇文章:Tapable,看这一篇就够了
下面我们简单看一下ProgressPlugin
插件的实现:
看似很少的代码,其实插件已经横跨了构建的完整流程,包含构建、生成。
用这张图再合适不过了:
webpack整个执行流程其实包含三个阶段:
- initialize 初始化
- make 构建
- seal 封装生成
整个流程中大大小小的hooks有上百个,这也就代表webpack插件可以对打包流程的任何阶段做处理。
在webpack源码中,编译的一开始就在执行 hooks:
js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// compile 编译
this.hooks.compile.call(params);
this.hooks.make.callAsync(compilation, err => {
// make 构建完成
if (err) return callback(err);
this.hooks.finishMake.callAsync(compilation, err => {
// finishMake 构建完成
process.nextTick(() => {
compilation.finish(err => {
if (err) return callback(err);
compilation.seal(err => {
// seal阶段完成
this.hooks.afterCompile.callAsync(compilation, err => {
// afterCompile 编译完成后
});
});
});
});
});
});
});
}
可以说webpack
是跑在各种插件之上的,如果你刚开始研究它的源码,大概率会被绕晕!即使使用断点调试,也可能随时从一个文件跳到另一个文件。所以如果你想学习webpack
原理,tapable
就是你的起点。
Rollup的插件设计
Rollup的插件设计相比Webpack更为简洁,插件API并没有那么多,也是个人比较喜欢的插件系统,写起来非常方便.
Rollup的插件系统主要包含两个重要部分:
- PluginDriver 插件执行器,大家可以类比为
tapable
- PluginContext 插件上下文,它贯穿了整个插件的执行流程
Rollup插件是如何执行的
我们知道wepback
执行插件是借助了tapable
用于同步串行、并行,异步串行并行等方式来执行插件,那rollup其实自己实现一套简易的类似tapable
的功能。
js
class PluginDriver {
hookFirst() {
// ...
}
hookFirstSync() {
// ...
}
hookParallel() {
// ...
}
hookReduceArg0() {
// ...
}
hookReduceArg0Sync() {
// ...
}
hookReduceValue() {
// ...
}
hookReduceValueSync() {
// ...
}
hookSeqSync() {
// ...
}
}
-
hookFirst : 异步串行,出现第一个返回值不为空的插件,就停止执行,类似
tapable
的AsyncSeriesBailHook
-
hookFirstSync : 同步串行,出现第一个返回值不为空的插件,就停止执行,类似
tapable
的SyncBailHook
-
hookParallel : 异步并行 Promise.all,类似
tapable
的AsyncParallelHook
-
hookReduceArg0 : 异步串行,把上一个hook的返回值作为下一个hook的参数,如果返回为空就停止执行,并返回最后的值, 类似
tapable
的AsyncSeriesWaterfallHook
-
hookReduceArg0Sync :同步串行,把上一个hook的返回值作为下一个hook的参数,如果返回为空就停止执行,并返回最后的值, 类似
tapable
的SyncWaterfallHook
-
hookReduceValue: 异步串行,传入一个初始值value,上一个hook处理好value后的返回值作为下一个hook的参数
-
hookReduceValueSync: 同步串行,传入一个初始值value,上一个hook处理好value后的返回值作为下一个hook的参数
-
hookSeq : 异步串行,忽略返回值,类似
tapable
的SyncHook
-
hookSeqSync : 同步串行,忽略返回值类似
tapable
的AsyncSeriesHook
我们可以从这段代码中,对rollup的插件的执行窥探一二:
通过PluginDriver
来执行每个插件中的钩子。
当然,rollup也是在构建的各个流程中,插入插件的代码逻辑,并执行。
Rollup插件构建流程
rollup
的钩子主要分为两个大的阶段: 构建阶段
,输出阶段
。每个阶段下都包含各种钩子,可以在各个环节做一些处理。
上面是rollup的构建阶段的流程
。有些钩子的返回值会影响后续的构建结果。
Rollup的插件如何通信?
其实不难想到,这么多钩子,如果其中两个钩子需要共享数据,或者许多钩子需要做相同的操作暴露给用户,光靠参数传递会非常麻烦。
所以rollup
提供了插件上下文
, 可以通过 this
从大多数钩子中访问一些帮助函数和信息。
例如:
- addWatchFile 动态添加要watch的函数
- emitFile 输出文件
- parse 解析文件为ast
- ...
如果我们想共享数据,可以直接在this
上挂载数据,然后在其他钩子中访问即可。
如何开发一个插件系统
上面介绍完rollup
和webpack
插件机制,那我们是时候思考一下什么情况下,我们才需要插件系统。
一个软件若要接入插件系统,我个人认为需要具备以下条件:
- 软件存在完善的生命周期,以便介入各个阶段
- 软件的迭代存在重复工作,后续需要不断扩展,支持新的功能
- 软件需要存在默认的执行流程,而插件可以介入修改流程
在满足这些条件后,我们就可以来思考如何开发插件系统,主要在几个方面:
- 插件应该怎么写,长什么样?
- 插件应该怎么执行?
- 插件执行时机?输入输出如何确定?
插件的形态
就目前而言,通过了解上面两个开源项目的插件机制,我们可以知道插件其实可以分为两种写法:
- 对象配置写法
js
function plugin() {
return {
name: 'plugin1',
buildStart() {
this.info({ message: 'Hey', pluginCode: 'SPECIAL_CODE' });
},
transform() {
// ...
}
};
}
- 注册回调的写法
js
hooks.xxx.tap('xx', () => {
})
或者像umi
那样, 通过对应的hooks
介入不同阶段
js
// 修改配置
api.modifyConfig((memo)=>{
memo.favicons = api.userConfig.changeFavicon;
return memo;
});
其实插件的写法,可以借鉴的有很多,找到适合自己的就行,这一点并不局限。不过我个人比较喜欢平铺的对象写法,比较清晰易懂。
插件的执行
插件的执行是插件系统最为核心的逻辑。我们可以大概梳理出几种执行类型:
- 覆盖式
只使用最后一个插件的输出,作为结果:
场景:
rollup
的load
钩子,要读取文件,直到最后有一个插件的load
存在就输出。
- 管道式
输入输出相互衔接,一般输入输出是同一个数据类型。
场景:
rollup
的options
钩子
- 洋葱圈式
在管道式的基础上,如果系统核心逻辑处于中间,插件同时关注进与出的逻辑,则可以使用洋葱圈模型。
场景:
redux
的相关插件,如redux-thunk
- 集散式 集散式就是每一个插件都会执行,如果有输出则最终将结果进行合并。这里的前提是存在方案,可以对执行结果进行 merge。
当然,上面列出的执行类型可能并不止这些,还需要根据不同场景去使用。
插件的执行时机和输入输出
插件执行时机和输入输出,其实很大程度上取决于软件系统的生命周期。
在rollup
中,如果希望能够控制代码转换,很简单,只需要在代码读取完成后,执行transform
钩子,告诉开发者文件的路径。如果开发者没有返回转换代码,就使用默认的转换逻辑。
想要删除某个最后输出的文件,也只需要在文件输出之前,执行renderChunks
, 开发者在钩子中,删除chunks
就可以控制最后的输出了。
所以,软件系统需要存在一套默认的执行逻辑,而插件可以改变默认的行为逻辑。
但是具体如何去改变,还需要进一步衡量,哪些地方有必要放插件,哪些地方不需要。
总结
插件架构并不是一个设计模式,而是一个设计思路。不同系统的插件架构其实很难复用,还是要根据具体的场景制定执行方式
、执行时机
、输入输出
。
相信到了这里你已经对插件架构有了大致的了解,要实现一个并不难,本文主要是介绍大致的思路。
有不对的地方,欢迎指正。