插件化架构

插件化架构(Plug-in Architecture),也被称为微内核架构(Microkernel Architecture),是一种面向功能进行拆分的可扩展性架构,在如今的许多前端主流框架中都能看到它的身影。今天我们以 umi 框架为主,来看看插件化架构的实现思路,同时对比一下不同框架中插件化实现思路的异同。

各个主流框架插件化异同

二话不说先上结论。

触发方式 插件 API 插件功能
umi 基于 tapable 的发布订阅模式 10 种核心方法,50 种扩展方法,9 个核心属性 在路由、生成文件、构建打包、HTML 操作、命令等方面提供能力
babel 基于 visitor 的访问者模式 基于@babel/types 对于 AST 的操作等
rollup 基于 hook 的回调模式 构建钩子、输出钩子、监听钩子 定制构建和打包阶段的能力
webpack 基于 tapable 的发布订阅模式 主要为 compolier 和 compilation 提供一系列的钩子 loader 不能实现的都靠它
vue-cli 基于 hook 的回调模式 生成阶段为 Generator API,运行阶段为 chainWebpack 等更改 webpack 配置为主的 api 在生成项目、项目运行和 vue ui 阶段提供能力

一个完整的插件系统应该包括三个部分:

插件内核(plugiCore):用于管理插件;

插件接口(pluginApi):用于提供 api 给插件使用;

插件(plugin):功能模块,不同的插件实现不同的功能。

因此我们也从这三部分入手去分析 umi 的插件化。

umi 插件(plugin)

我们先从最简单的开始,认识一个umi 插件长什么样。我们以插件集preset(@umijs/preset-built-in)中的一个内置插件umiInfo(packages/preset-built-in/src/plugins/features/umiInfo.ts)为例,来认识一下 umi 插件。

import { IApi } from '@umijs/types';export default (api: IApi) => {// 调用扩展方法addHTMLHeadScripts在 HTML 头部添加脚本api.addHTMLHeadScripts(() => [{content: `//! umi version: ${process.env.UMI_VERSION}`,},]);// 调用扩展方法addEntryCode在入口文件最后添加代码api.addEntryCode(() => `window.g_umi = {version: '${process.env.UMI_VERSION}',};`,);
};

可以看到 umi 插件导出了一个函数,函数内部为调用传参 api 上的两个方法属性,主要实现了两个功能,一个是在 html 文件头部添加脚本,另一个是在入口文件最后添加代码。其中,preset是一系列插件的合集。代码非常简单,就是 require 了一系列的plugin。插件集preset(packages/preset-built-in/src/index.ts)如下:

export default function () {return {plugins: [// 注册方法插件require.resolve('./plugins/registerMethods'),// 路由插件require.resolve('./plugins/routes'),// 生成文件相关插件require.resolve('./plugins/generateFiles/core/history'),……// 打包配置相关插件require.resolve('./plugins/features/404'),……// html操作相关插件require.resolve('./plugins/features/html/favicon'),……// 命令相关插件require.resolve('./plugins/commands/build/build'),……}

这些plugin主要包括一个注册方法插件(packages/preset-built-in/src/plugins/registerMethods.ts),一个路由插件(packages/preset-built-in/src/plugins/routes.ts),一些生成文件相关插件(packages/preset-built-in/src/plugins/generateFiles/*),一些打包配置相关插件(packages/preset-built-in/src/plugins/features/*),一些html 操作相关插件(packages/preset-built-in/src/plugins/features/html/*)以及一些命令相关插件(packages/preset-built-in/src/plugins/commands/*)。

在注册方法插件registerMethods(packages/preset-built-in/src/plugins/registerMethods.ts)中,umi集中注册了几十个方法,这些方法就是umi文档中插件 api 的扩展方法

export default function (api: IApi) {// 集中注册扩展方法['onGenerateFiles','onBuildComplete','onExit',……].forEach((name) => {api.registerMethod({ name });});// 单独注册writeTmpFile方法,并传参fn,方便其他扩展方法使用api.registerMethod({name: 'writeTmpFile',fn({path,content,skipTSCheck = true,}: {path: string;content: string;skipTSCheck?: boolean;}) {assert(api.stage >= api.ServiceStage.pluginReady,`api.writeTmpFile() should not execute in register stage.`,);const absPath = join(api.paths.absTmpPath!, path);api.utils.mkdirp.sync(dirname(absPath));if (isTSFile(path) && skipTSCheck) {// write @ts-nocheck into first linecontent = `// @ts-nocheck${EOL}${content}`;}if (!existsSync(absPath) || readFileSync(absPath, 'utf-8') !== content) {writeFileSync(absPath, content, 'utf-8');}},});
}

当我们在控制台umi路径下键入命令npx umi dev后,就启动了 umi 命令,附带 dev 参数,经过一系列的操作后实例化Service对象(路径:packages/umi/src/ServiceWithBuiltIn.ts),

import { IServiceOpts, Service as CoreService } from '@umijs/core';
import { dirname } from 'path';class Service extends CoreService {constructor(opts: IServiceOpts) {process.env.UMI_VERSION = require('../package').version;process.env.UMI_DIR = dirname(require.resolve('../package'));super({...opts,presets: [// 配置内置默认插件集require.resolve('@umijs/preset-built-in'),...(opts.presets || []),],plugins: [require.resolve('./plugins/umiAlias'), ...(opts.plugins || [])],});}
}export { Service };

Service的构造函数中就传入了上面提到的默认插件集preset(@umijs/preset-built-in),供umi使用。至此我们介绍了以默认插件集preset为代表的umi插件。

插件接口(pluginApi)

Service对象(packages/core/src/Service/Service.ts)中的getPluginAPI方法为插件提供了插件接口getPluginAPI接口就是整个插件系统的桥梁。它使用代理模式将umi插件核心方法、初始化过程hook 节点api、Service 对象方法属性和通过@umijs/preset-built-in 注册到 service 对象上的扩展方法组织在了一起,供插件调用。

getPluginAPI(opts: any) {//实例化PluginAPI对象,PluginAPI对象包含describe,register,registerCommand,registerPresets,registerPlugins,registerMethod,skipPlugins七个核心插件方法const pluginAPI = new PluginAPI(opts);// 注册umi服务初始化过程中的hook节点['onPluginReady', // 插件初始化完毕'modifyPaths', // 修改路径'onStart', // 启动umi'modifyDefaultConfig', // 修改默认配置'modifyConfig', // 修改配置].forEach((name) => {pluginAPI.registerMethod({ name, exitsError: false });});return new Proxy(pluginAPI, {get: (target, prop: string) => {// 由于 pluginMethods 需要在 register 阶段可用// 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果if (this.pluginMethods[prop]) return this.pluginMethods[prop];// 注册umi service对象上的属性和核心方法if (['applyPlugins','ApplyPluginsType','EnableBy','ConfigChangeType','babelRegister','stage',……].includes(prop)) {return typeof this[prop] === 'function'? this[prop].bind(this): this[prop];}return target[prop];},});}
插件内核(pluginore)
1.初始化配置

上面讲到启动umi后会实例化Service对象(路径:packages/umi/src/ServiceWithBuiltIn.ts),并传入preset插件集(@umijs/preset-built-in)。该对象继承自CoreServeice(packages/core/src/Service/Service.ts)。CoreServeice在实例化的过程中会在构造函数中初始化插件集和插件:

// 初始化 Presets 和 plugins, 来源于四处// 1. 构造 Service 传参// 2. process.env 中指定// 3. package.json 中 devDependencies 指定// 4. 用户在 .umirc.ts 文件中配置this.initialPresets = resolvePresets({...baseOpts,presets: opts.presets || [],userConfigPresets: this.userConfig.presets || [],});this.initialPlugins = resolvePlugins({...baseOpts,plugins: opts.plugins || [],userConfigPlugins: this.userConfig.plugins || [],});

经过转换处理,一个插件在umi系统中最终会表示为如下格式的一个对象:

{id, // @umijs/plugin-xxx,插件名称key, // xxx,插件唯一的keypath: winPath(path), // 路径apply() {// 延迟加载插件try {const ret = require(path);// use the default member for es modulesreturn compatESModuleRequire(ret);} catch (e) {throw new Error(`Register ${type} ${path} failed, since ${e.message}`);}},defaultConfig: null, // 默认配置};
2.初始化插件

umi实例化Service对象后会调用Service对象的run方法。插件的初始化就是在run方法中完成的。初始化presetplugin的过程大同小异,我们重点看初始化plugin的过程。

// 初始化插件async initPlugin(plugin: IPlugin) {// 在第一步初始化插件配置后,插件在umi系统中就变成了一个个的对象,这里导出了id, key和延迟加载函数applyconst { id, key, apply } = plugin;// 获取插件系统的桥梁插件接口PluginApiconst api = this.getPluginAPI({ id, key, service: this });// 注册插件this.registerPlugin(plugin);// 执行插件代码await this.applyAPI({ api, apply });}

这里我们要重点看一下在最开始preset集中第一个注册方法插件中注册扩展方法时曾提到的registerMethod方法。

registerMethod({name,fn,exitsError = true,}: {name: string;fn?: Function;exitsError?: boolean;}) {// 注册的方法已经存在的情况的处理if (this.service.pluginMethods[name]) {if (exitsError) {throw new Error(`api.registerMethod() failed, method ${name} is already exist.`,);} else {return;}}// 这里分为两种情况:第一种注册方法时传入了fn参数,则注册的方法就是fn方法;第二种情况未传入fn,则返回一个函数,函数会将传入的fn参数转换为hook钩子并注册,挂载到service的hooksByPluginId属性下this.service.pluginMethods[name] =fn || function (fn: Function | Object) {const hook = {key: name,...(utils.lodash.isPlainObject(fn) ? fn : { fn }),};// @ts-ignorethis.register(hook);};}

因此当执行插件代码时,如果是核心方法则直接执行,如果是扩展方法则除了writeTmpFile,其余都是在hooksByPluginId下注册了hook。到这里Service完成了插件的初始化,执行了插件调用的核心方法和扩展方法。

3.初始化 hooks

通过下述代码,Service将以插件名称为维度配置的hook,转换为以hook名称为维度配置的回调集。

Object.keys(this.hooksByPluginId).forEach((id) => {const hooks = this.hooksByPluginId[id];hooks.forEach((hook) => {const { key } = hook;hook.pluginId = id;this.hooks[key] = (this.hooks[key] || []).concat(hook);});});

addHTMLHeadScripts扩展方法为例 转换前:

'./node_modules/@@/features/devScripts': [{ key: 'addBeforeMiddlewares', fn: [Function (anonymous)] },{ key: 'addHTMLHeadScripts', fn: [Function (anonymous)] },
……],'./node_modules/@@/features/umiInfo': [{ key: 'addHTMLHeadScripts', fn: [Function (anonymous)] },{ key: 'addEntryCode', fn: [Function (anonymous)] }],'./node_modules/@@/features/html/headScripts': [ { key: 'addHTMLHeadScripts', fn: [Function (anonymous)] } ],

转换之后:

addHTMLHeadScripts: [{key: 'addHTMLHeadScripts',fn: [Function (anonymous)],pluginId: './node_modules/@@/features/devScripts'},{key: 'addHTMLHeadScripts',fn: [Function (anonymous)],pluginId: './node_modules/@@/features/umiInfo'},{key: 'addHTMLHeadScripts',fn: [Function (anonymous)],pluginId: './node_modules/@@/features/html/headScripts'}],

至此插件系统就绪达到pluginReady状态。

4.触发 hook

在程序达到 pluginReady 状态后,Service 立即执行了一次触发 hook 操作。

await this.applyPlugins({key: 'onPluginReady',type: ApplyPluginsType.event,});

那么是如何触发的呢?我们来详细看一下applyPlugins的代码实现:

async applyPlugins(opts: {key: string;type: ApplyPluginsType;initialValue?: any;args?: any;}) {// 找到对应需要触发的hook会调集,这里的hooks就是上面以插件名称为维度配置的hook转换为以hook名称为维度配置的回调集const hooks = this.hooks[opts.key] || [];// 判断事件类型,umi将回调事件分为add、modify和event三种switch (opts.type) {case ApplyPluginsType.add:if ('initialValue' in opts) {assert(Array.isArray(opts.initialValue),`applyPlugins failed, opts.initialValue must be Array if opts.type is add.`,);}// 事件管理基于webpack的Tapable库,只用到了AsyncSeriesWaterfallHook一种事件控制方式,既异步串行瀑布流回调方式:异步,所有的钩子都是异步处理;串行,依次执行;瀑布流,上一个钩子的结果是下一个钩子的参数。const tAdd = new AsyncSeriesWaterfallHook(['memo']);for (const hook of hooks) {if (!this.isPluginEnable(hook.pluginId!)) {continue;}tAdd.tapPromise({name: hook.pluginId!,stage: hook.stage || 0,// @ts-ignorebefore: hook.before,},//与其他两种事件类型不同,add类型会返回所有钩子的结果async (memo: any[]) => {const items = await hook.fn(opts.args);return memo.concat(items);},);}return await tAdd.promise(opts.initialValue || []);case ApplyPluginsType.modify:const tModify = new AsyncSeriesWaterfallHook(['memo']);for (const hook of hooks) {if (!this.isPluginEnable(hook.pluginId!)) {continue;}tModify.tapPromise({name: hook.pluginId!,stage: hook.stage || 0,// @ts-ignorebefore: hook.before,},// 与其他两种钩子不同,modify类型会返回最终的钩子结果async (memo: any) => {return await hook.fn(memo, opts.args);},);}return await tModify.promise(opts.initialValue);case ApplyPluginsType.event:const tEvent = new AsyncSeriesWaterfallHook(['_']);for (const hook of hooks) {if (!this.isPluginEnable(hook.pluginId!)) {continue;}tEvent.tapPromise({name: hook.pluginId!,stage: hook.stage || 0,// @ts-ignorebefore: hook.before,},// event类型,只执行钩子,不返回结果async () => {await hook.fn(opts.args);},);}return await tEvent.promise();default:throw new Error(`applyPlugin failed, type is not defined or is not matched, got ${opts.type}.`,);}}

至此,umi的整体插件工作流程介绍完毕,后续代码就是umi根据流程需要不断触发各类的hook从而完成整个umi的各项功能。除了umi,其他的一些框架也都应用了插件模式,下面做简单介绍对比。

babel 插件机制

babel主要的作用就是语法转换babel的整个过程分为三个部分:解析,将代码转换为抽象语法树(AST);转换,遍历 AST 中的节点进行语法转换操作;生成,根据最新的 AST 生成目标代码。其中在转换的过程中就是依据babel配置的各个插件去完成的。

babel 插件
const createPlugin = (name) => {return {name,visitor: {FunctionDeclaration(path, state) {},ReturnStatement(path, state) {},}};
};

可以看到babel的插件也是返回一个函数,和umi的很相似。但是babel插件的运行却并不是基于发布订阅的事件驱动模式,而是采用访问者模式babel会通过一个访问者visitor统一遍历节点,提供方法及维护节点关系,插件只需要在visitor中注册自己关心的节点类型,当visitor遍历到相关节点时就会调用插件在visitor上注册的方法并执行。

webpack 插件机制

webpack整体基于两大支柱功能:一个是loader,用于对模块的源码进行转换,基于管道模式;另一个就是plugin,用于解决 loader 无法解决的问题,顾名思义,plugin 就是基于插件机制的。来看一个典型的webpack插件:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';class ConsoleLogOnBuildWebpackPlugin {apply(compiler) {compiler.hooks.run.tap(pluginName, (compilation) => {console.log('webpack 构建正在启动!');});}
}module.exports = ConsoleLogOnBuildWebpackPlugin;

webpack在初始化时会统一执行插件的apply方法。插件通过注册Compilercompilation的钩子函数,在整个编译生命周期都可以访问compiler对象,完成插件功能。同时整个事件驱动的功能都是基于 webpack 的核心工具TapableTapable同样也是umi的事件驱动工具。可以看到umiwebpack的整体思路是很相似的。

rollup 插件机制

rollup也是模块打包工具,与 webpack 相比rollup更适合打包纯 js 的类库。同样rollup也具有插件机制。一个典型的rollup插件:

export default function myExample() {return {name: 'my-example',resolveId(source) {},load(id) {},};
}

rollup 插件维护了一套同步/异步、串行/并行、熔断/传参的事件回调机制,不过这部分并没有单独抽出类库,而是在 rollup 项目中维护的。通过插件控制器(src/utils/PluginDriver.ts)、插件上下文(src/utils/PluginContext.ts)、插件缓存(src/utils/PluginCache.ts),完成了提供插件 api 和插件内核的能力。

vue-cli 插件机制

vue-cli的插件与其他相比稍有特点,就是将插件分为几种情况,一种项目生成阶段,插件未安装需要安装插件;另一种是项目运行阶段,启动插件;还有一种是UI插件,在运行vue ui时会用到。

vue-cli插件的包目录结构

├── generator.js  # generator(可选)
├── index.js      # service 插件
├── package.json
└── prompts.js    # prompt 文件(可选)
└── ui.js    # ui 文件(可选)
生成阶段

其中generator.jsprompts.js在安装插件的情况下执行,index 则在运行阶段执行。generator 示例:

module.exports = (api, options) => {// 扩展package.json字段api.extendPackage({dependencies: {'vue-router-layout': '^0.1.2'}})// afterAnyInvoke钩子 函数会被反复执行api.afterAnyInvoke(() => {// 文件操作})// afterInvoke钩子,这个钩子将在文件被写入硬盘之后被调用api.afterInvoke(() => {})}

prompts 会在安装期间与用户交互,获取插件的选项配置并在 generator.js 调用时作为参数存入。

在项目生成阶段通过 packages/@vue/cli/lib/GeneratorAPI.js 提供插件 api;在 packages/@vue/cli/lib/Generator.js 中初始化插件,执行插件注册的 api,在 packages/@vue/cli/lib/Creator.js 中运行插件注册的钩子函数,最终完成插件功能的调用。

运行阶段

vue-cli运行阶段插件:

const VueAutoRoutingPlugin = require('vue-auto-routing/lib/webpack-plugin')module.exports = (api, options) => {api.chainWebpack(webpackConfig => {webpackConfig.plugin('vue-auto-routing').use(VueAutoRoutingPlugin, [{pages: 'src/pages',nested: true}])})
}

在项目运行阶段的插件主要用来修改webpack的配置,创建或者修改命令。由 packages/@vue/cli-service/lib/PluginAPI.js 提供pluginapi,packages/@vue/cli-service/lib/Service.js 完成插件的初始化和运行。而vue-cli插件的运行主要是基于回调函数的模式来管理的。

通过以上介绍,可以发现插件机制是现代前端项目工程化框架中必不可少的一部分,插件的实现形式多种多样,但总的结构是大体一致的,既由插件(plugin)插件 api(pluginApi)插件核心(pluginCore)三部分组成。其中通过插件核心去注册和管理插件,完成插件的初始化和运行工作,插件 api 是插件和系统之间的桥梁,使插件完成特定功能,再通过不同插件的组合形成了一套功能完整的前端框架系统。

【设计】1359- Umi3 如何实现插件化架构相关推荐

  1. 技术期刊 · 路尘终见泰山平 | 微前端及插件化架构在 Wix 的实践;编辑器架构的第二路径;业务中的前端组件化体系……

    蒲公英 · JELLY技术期刊 Vol.47 不想当架构师的程序员不是"合格"的程序员?这一类的言论在很多文章中应该很常见吧,我们需要架构思维,要有抽象能力,要学会分层--需要的太 ...

  2. 前端进阶:跟着开源项目学习插件化架构

    一.微内核架构简介 1. 1 微内核的概念 微内核架构(Microkernel Architecture),有时也被称为插件化架构(Plug-in Architecture),是一种面向功能进行拆分的 ...

  3. 大型 Web 应用插件化架构探索

    简介:随着 Web 技术的逐渐成熟,越来越多的应用架构趋向于复杂,例如阿里云等巨型控制台项目,每个产品下都有各自的团队来负责维护和迭代.不论是维护还是发布以及管控成本都随着业务体量的增长而逐渐不可控. ...

  4. iOS插件化架构探索

    +前言 WWDC2014苹果在iOS上开放了动态库,这给了我们一个很大的想象空间. 动态库即动态链接库,是Cocoa/Cocoa Touch程序中使用的一种资源打包方式,可以将代码文件.头文件.资源文 ...

  5. iOS之深入解析插件化架构

    一.前言 WWDC2014 苹果在 iOS 上开放了动态库,这给了我们一个很大的想象空间.动态库即动态链接库,是 Cocoa/Cocoa Touch 程序中使用的一种资源打包方式,可以将代码文件.头文 ...

  6. iOS 上的插件化设计

    ????????关注后回复 "进群" ,拉你进程序员交流群???????? 转自:掘金 ZenonHuang https://juejin.cn/post/697962703724 ...

  7. IOS-组件化架构漫谈

    2019独角兽企业重金招聘Python工程师标准>>> 组件化架构的由来 随着移动互联网的不断发展,很多程序代码量和业务越来越多,现有架构已经不适合公司业务的发展速度了,很多都面临着 ...

  8. .NET Conf 2020 - 基于ASP.NET Core构建可热插拔的插件化系统

    文章标题:.NET Conf 2020 - 基于ASP.NET Core构建可热插拔的插件化系统 作者:Lamond Lu 项目地址:https://github.com/lamondlu/CoolC ...

  9. 单文件组件的组件传值_移动端组件化架构(下)

    我的组件化方案 对于项目架构来说,一定要建立于业务之上来设计架构.不同的项目业务不同,组件化方案的设计也会不同,应该设计最适合公司业务的架构. 架构设计 以我之前公司项目为例,项目是一个地图导航应用, ...

最新文章

  1. jedis操作set_在Java中使用jedis操作Set类型
  2. FPGA之道(62)时空变换之空间换时间
  3. Java学习笔记(43)——Java泛型
  4. 完美解决Flask-Migrate使用SQLite生成自动迁移脚本的Bug
  5. Python学习笔记(六)if判断语句
  6. vmware, failed to lock the file 的解决
  7. 在html中加动画效果,html5中css3新添加的动画效果
  8. SqlCommandBuilder
  9. linux 指令tftp传输文件_tftp命令_Linux tftp 命令用法详解:在本机和tftp服务器之间使用TFTP协议传输文件...
  10. 关于高德地图Android开发时地图只显示一次、第二次打开不定位的解决办法
  11. SpringBoot开发常用技术整合 代码上传至github上面去
  12. MySQL笔记-InnoDB物理及逻辑存储结构
  13. 年薪20万软件测试工程师都具备的能力,你有吗?
  14. Redis数据库的初步认识(二)-C/C++连接redis数据库
  15. Saved Blogs
  16. 通过 blast 结果查看 测序数据fastq是否被污染,以及污染reads所属物种、所占比例
  17. 数字功放-耐福NTP功放芯片详细性能概述
  18. House of apple 一种新的glibc中IO攻击方法
  19. 虚拟机网络适配器下的3种网络模式(主机模式、桥接模式和NAT模式)
  20. G - Ugly Numbers

热门文章

  1. python数据分析与应用pdf_看了Python在金融行业中的应用,大数据分析实在太重要了!...
  2. 关于上楼梯问题(斐波那契数列应用)
  3. 微信生态圈盈利模式分析
  4. 给自己分一个 MAC地址--locally administered address
  5. Word 恢复默认样式
  6. darknet cpp weights模型转换成ONNX模型
  7. iOS 中设置下划线失效不显示
  8. 【NCC】NCC 附件管理按钮开发,从ftp下载附件到文件服务器开发笔记
  9. 假设用于通信的电文由字符集{a,b,c,d,e,f,g}中的字母构成。它们在电文中出现的频度分别为
  10. 漫画人脸检测 | 全局和局部信息融合的深度神经网络(文末源码)