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

前言

随着前端基建的不断发展,涌现出了很多前端工具链,例如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就可以控制最后的输出了。

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

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

总结

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

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

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

参考

相关推荐
恋猫de小郭4 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅10 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606111 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了11 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅11 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅12 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment12 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅12 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊12 小时前
jwt介绍
前端