从开源项目中学习插件架构设计

前言

随着前端基建的不断发展,涌现出了很多前端工具链,例如WebpackRollupEslint。 再或者,一些日常应用工具,例如: utoolsPicGo等等。我们可以发现他们都有个共同点:都有完善的插件机制

如下图,在utools中,想要某个功能,不需要我们去更新软件,而是直接下载插件,不同的插件是相互隔离的:

为什么这些工具都要集成插件机制呢?

对于一个系统或软件而言,如果有一套插件系统,就能够很大程度上解决后续功能的扩展,并且可以最小化影响现有的功能。

下面我画了一张图,来粗略的描述,一个系统中插件的执行流程:

不难看出插件的几个特征:

  1. 不管是任何系统和软件,都存在 生命周期,例如:初始化、启动、执行、结束等。
  2. 插件的目标就是在这个生命周期执行过程中,对过程中的数据、执行逻辑进行修改, 有很强的 扩展性和灵活性
  3. 插件是独立于系统流程之外的代码,这也就意味着,插件有极强的 独立性,它出现故障并不会对系统和软件造成很大影响。

我们可以联想到类似 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的插件系统主要包含两个重要部分:

  1. PluginDriver 插件执行器,大家可以类比为tapable
  2. PluginContext 插件上下文,它贯穿了整个插件的执行流程

Rollup插件是如何执行的

我们知道wepback执行插件是借助了tapable用于同步串行、并行,异步串行并行等方式来执行插件,那rollup其实自己实现一套简易的类似tapable的功能。

js 复制代码
class PluginDriver {
    hookFirst() {
        // ...
    }
    hookFirstSync() {
        // ...
    }
    hookParallel() {
        // ...
    }
    hookReduceArg0() {
        // ...
    }
    hookReduceArg0Sync() {
        // ...
    }
    hookReduceValue() {
        // ...
    }
    hookReduceValueSync() {
        // ...
    }
    hookSeqSync() {
        // ...
    }
}
  • hookFirst : 异步串行,出现第一个返回值不为空的插件,就停止执行,类似tapableAsyncSeriesBailHook

  • hookFirstSync : 同步串行,出现第一个返回值不为空的插件,就停止执行,类似tapableSyncBailHook

  • hookParallel : 异步并行 Promise.all,类似tapableAsyncParallelHook

  • hookReduceArg0 : 异步串行,把上一个hook的返回值作为下一个hook的参数,如果返回为空就停止执行,并返回最后的值, 类似tapableAsyncSeriesWaterfallHook

  • hookReduceArg0Sync :同步串行,把上一个hook的返回值作为下一个hook的参数,如果返回为空就停止执行,并返回最后的值, 类似tapableSyncWaterfallHook

  • hookReduceValue: 异步串行,传入一个初始值value,上一个hook处理好value后的返回值作为下一个hook的参数

  • hookReduceValueSync: 同步串行,传入一个初始值value,上一个hook处理好value后的返回值作为下一个hook的参数

  • hookSeq : 异步串行,忽略返回值,类似tapableSyncHook

  • hookSeqSync : 同步串行,忽略返回值类似tapableAsyncSeriesHook

我们可以从这段代码中,对rollup的插件的执行窥探一二:

通过PluginDriver 来执行每个插件中的钩子。

当然,rollup也是在构建的各个流程中,插入插件的代码逻辑,并执行

Rollup插件构建流程

rollup的钩子主要分为两个大的阶段: 构建阶段输出阶段。每个阶段下都包含各种钩子,可以在各个环节做一些处理。

上面是rollup的构建阶段的流程。有些钩子的返回值会影响后续的构建结果。

Rollup的插件如何通信?

其实不难想到,这么多钩子,如果其中两个钩子需要共享数据,或者许多钩子需要做相同的操作暴露给用户,光靠参数传递会非常麻烦。

所以rollup提供了插件上下文, 可以通过 this 从大多数钩子中访问一些帮助函数和信息。

插件上下文

例如:

  • addWatchFile 动态添加要watch的函数
  • emitFile 输出文件
  • parse 解析文件为ast
  • ...

如果我们想共享数据,可以直接在this上挂载数据,然后在其他钩子中访问即可。

如何开发一个插件系统

上面介绍完rollupwebpack插件机制,那我们是时候思考一下什么情况下,我们才需要插件系统。

一个软件若要接入插件系统,我个人认为需要具备以下条件:

  • 软件存在完善的生命周期,以便介入各个阶段
  • 软件的迭代存在重复工作,后续需要不断扩展,支持新的功能
  • 软件需要存在默认的执行流程,而插件可以介入修改流程

在满足这些条件后,我们就可以来思考如何开发插件系统,主要在几个方面:

  • 插件应该怎么写,长什么样?
  • 插件应该怎么执行?
  • 插件执行时机?输入输出如何确定?

插件的形态

就目前而言,通过了解上面两个开源项目的插件机制,我们可以知道插件其实可以分为两种写法:

  1. 对象配置写法
js 复制代码
function plugin() {
	return {
            name: 'plugin1',
            buildStart() {
                this.info({ message: 'Hey', pluginCode: 'SPECIAL_CODE' });
            },
            transform() {
              // ...
            }
	};
}
  1. 注册回调的写法
js 复制代码
hooks.xxx.tap('xx', () => {
})

或者像umi那样, 通过对应的hooks介入不同阶段

js 复制代码
// 修改配置
api.modifyConfig((memo)=>{
  memo.favicons = api.userConfig.changeFavicon;
  return memo;
});

其实插件的写法,可以借鉴的有很多,找到适合自己的就行,这一点并不局限。不过我个人比较喜欢平铺的对象写法,比较清晰易懂。

插件的执行

插件的执行是插件系统最为核心的逻辑。我们可以大概梳理出几种执行类型:

  1. 覆盖式

只使用最后一个插件的输出,作为结果:

场景:

  • rollupload钩子,要读取文件,直到最后有一个插件的load 存在就输出。
  1. 管道式

输入输出相互衔接,一般输入输出是同一个数据类型。

场景:

  • rollupoptions 钩子
  1. 洋葱圈式

在管道式的基础上,如果系统核心逻辑处于中间,插件同时关注进与出的逻辑,则可以使用洋葱圈模型。

场景:

  • redux的相关插件,如redux-thunk
  1. 集散式 集散式就是每一个插件都会执行,如果有输出则最终将结果进行合并。这里的前提是存在方案,可以对执行结果进行 merge。

当然,上面列出的执行类型可能并不止这些,还需要根据不同场景去使用。

插件的执行时机和输入输出

插件执行时机和输入输出,其实很大程度上取决于软件系统的生命周期。

rollup中,如果希望能够控制代码转换,很简单,只需要在代码读取完成后,执行transform钩子,告诉开发者文件的路径。如果开发者没有返回转换代码,就使用默认的转换逻辑。

想要删除某个最后输出的文件,也只需要在文件输出之前,执行renderChunks, 开发者在钩子中,删除chunks就可以控制最后的输出了。

所以,软件系统需要存在一套默认的执行逻辑,而插件可以改变默认的行为逻辑。

但是具体如何去改变,还需要进一步衡量,哪些地方有必要放插件,哪些地方不需要。

总结

插件架构并不是一个设计模式,而是一个设计思路。不同系统的插件架构其实很难复用,还是要根据具体的场景制定执行方式执行时机输入输出

相信到了这里你已经对插件架构有了大致的了解,要实现一个并不难,本文主要是介绍大致的思路。

有不对的地方,欢迎指正。

参考

相关推荐
沛沛老爹36 分钟前
服务监控插件全览:提升微服务可观测性的利器
微服务·云原生·架构·datadog·influx·graphite
J不A秃V头A1 小时前
Vue3:编写一个插件(进阶)
前端·vue.js
司篂篂1 小时前
axios二次封装
前端·javascript·vue.js
姚*鸿的博客2 小时前
pinia在vue3中的使用
前端·javascript·vue.js
huaqianzkh2 小时前
了解华为云容器引擎(Cloud Container Engine)
云原生·架构·华为云
宇文仲竹2 小时前
edge 插件 iframe 读取
前端·edge
Kika写代码2 小时前
【基于轻量型架构的WEB开发】【章节作业】
前端·oracle·架构
刘某某.2 小时前
使用OpenFeign在不同微服务之间传递用户信息时失败
java·微服务·架构
迪捷软件2 小时前
知识|智能网联汽车多域电子电气架构会如何发展?
架构·汽车
zyhJhon2 小时前
软考架构-层次架构风格
架构