找回密码
 会员注册
查看: 27|回复: 0

UMI3源码解析系列之插件化架构核心

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64080
发表于 2024-9-19 17:51:59 | 显示全部楼层 |阅读模式
插件化架构插件化架构(Plug-inArchitecture),也被称为微内核架构(MicrokernelArchitecture),是一种面向功能进行拆分的可扩展性架构,在如今的许多前端主流框架中都能看到它的身影。今天我们以umi框架为主,来看看插件化架构的实现思路,同时对比一下不同框架中插件化实现思路的异同。各个主流框架插件化异同二话不说先上结论。触发方式插件API插件功能umi基于tapable的发布订阅模式10种核心方法,50种扩展方法,9个核心属性在路由、生成文件、构建打包、HTML操作、命令等方面提供能力babel基于visitor的访问者模式基于@babel/types对于AST的操作等rollup基于hook的回调模式构建钩子、输出钩子、监听钩子定制构建和打包阶段的能力webpack基于tapable的发布订阅模式主要为compolier和compilation提供一系列的钩子loader不能实现的都靠它vue-cli基于hook的回调模式生成阶段为GeneratorAPI,运行阶段为chainWebpack等更改webpack配置为主的api在生成项目、项目运行和vueui阶段提供能力一个完整的插件系统应该包括三个部分:插件内核(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 line        content = `// @ts-nocheck${EOL}${content}`;      }      if (!existsSync(absPath) || readFileSync(absPath, 'utf-8') !== content) {        writeFileSync(absPath, content, 'utf-8');      }    },  });}当我们在控制台umi路径下键入命令npxumidev后,就启动了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 luginAPI(opts);    // 注册umi服务初始化过程中的hook节点    [      'onPluginReady', // 插件初始化完毕      'modifyPaths', // 修改路径      'onStart', // 启动umi      'modifyDefaultConfig', // 修改默认配置      'modifyConfig', // 修改配置    ].forEach((name) => {      pluginAPI.registerMethod({ name, exitsError: false });    });    return new roxy(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在实例化的过程中会在构造函数中初始化插件集和插件:    // 初始化 resets 和 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,插件唯一的key    path: winPath(path), // 路径    apply() {      // 延迟加载插件      try {        const ret = require(path);        // use the default member for es modules        return compatESModuleRequire(ret);      } catch (e) {        throw new Error(`Register ${type} ${path} failed, since ${e.message}`);      }    },    defaultConfig: null, // 默认配置  };2.初始化插件umi实例化Service对象后会调用Service对象的run方法。插件的初始化就是在run方法中完成的。初始化preset和plugin的过程大同小异,我们重点看初始化plugin的过程。  // 初始化插件  async initPlugin(plugin: IPlugin) {    // 在第一步初始化插件配置后,插件在umi系统中就变成了一个个的对象,这里导出了id, key和延迟加载函数apply    const { id, key, apply } = plugin;    // 获取插件系统的桥梁插件接口PluginApi    const 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-ignore        this.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-ignore              before: 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-ignore              before: 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-ignore              before: 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方法。插件通过注册Compiler和compilation的钩子函数,在整个编译生命周期都可以访问compiler对象,完成插件功能。同时整个事件驱动的功能都是基于webpack的核心工具Tapable。Tapable同样也是umi的事件驱动工具。可以看到umi和webpack的整体思路是很相似的。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插件,在运行vueui时会用到。vue-cli插件的包目录结构├── generator.js  # generator(可选)├── index.js      # service 插件├── package.json└── prompts.js    # prompt 文件(可选)└── ui.js    # ui 文件(可选)生成阶段其中generator.js和prompts.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是插件和系统之间的桥梁,使插件完成特定功能,再通过不同插件的组合形成了一套功能完整的前端框架系统。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 11:27 , Processed in 0.367451 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表