Umi插件原理解析

Umi是一个企业级的前端开发框架,它本身是一个插件化的系统。插件化的优点是你可以方便的进行能力拓展。在Umi中,这代表着你可以通过插件来扩展项目的编译时与运行时能力,这包括修改代码打包配置,修改启动代码,约定目录结构,修改 HTML 等功能。

这篇文章主要探索Umi中的插件机制,并解答下面一些问题:

  • 如何编写Umi的插件?
  • Umi的插件化是如何实现的?
  • Umi中plugin与preset有什么区别?
  • 我们可以通过Umi插件实现哪些功能?

在这之前,你需要有Umi的使用经验,并阅读过Umi插件的相关文档。本篇文章会结合源码进行解析。

插件初始化

由于本文的目的是分析插件原理,所以一些不相关的流程就省略了。直接进入Service类(源码在core/src/service/service.ts 中)的run方法。首先看看插件是如何初始化的。

// resolve initial presets and plugins
const presetPlugins: Plugin[] = [];
// 遍历 preset 数组,调用 initPreset 方法进行初始化
while (presets.length) {
  await this.initPreset({
    preset: presets.shift()!,
    presets,
    plugins: presetPlugins,
  });
}
// 
plugins.unshift(...presetPlugins);

// 遍历 plugin 数组,调用 initPlugin 进行初始化
while (plugins.length) {
  await this.initPlugin({ plugin: plugins.shift()!, plugins });
}

在Umi中有插件(plugin)与插件集(presets)两个概念,插件集用来引入多个插件。

  1. presets会先于plugins做初始化。
  2. 一个preset中如果返回了presets或plugins,则返回的presets会接着被初始化,返回的plugins会被插入到插件数组中。
  3. preset与plugin的唯一区别是:preset中可以注册多个preset与plugin,即上面的第二点,而plugin中不行。

这个区别反映在initPreset()的实现中:

async initPreset(opts: {
  preset: Plugin;
  presets: Plugin[];
  plugins: Plugin[];
}) {
  // preset 也是调用 initPlugin 进行初始化
  const { presets, plugins } = await this.initPlugin({
    plugin: opts.preset,
    presets: opts.presets,
    plugins: opts.plugins,
  });
  opts.presets.unshift(...(presets || [])); // 向传入的presets数组首部插入返回的presets
  opts.plugins.push(...(plugins || []));  // 向传入的plugins数组中插入返回的plugins
}

initPlugin()主要做了下面一些事情:

  • 构造需要传入给插件方法的api对象,并将一些属性的访问代理到service对象上
  • 执行插件方法(Umi插件本质上就是一个方法,它接收一个api参数)
  • 如果方法的返回值包含presets或plugins,则将它们返回

总结重点:这部分了解了preset与plugin的概念,preset先于plugin初始化,preset中可以注册多个plugin。

插件方法的api参数

Umi插件只有一个参数:一个api对象。对于实现插件功能来说,它无疑是非常重要的。api中的核心内容来自三部分:

  1. service(core/src/service/service.ts

    • 包括appDatauserConfigapplyPlugins
  2. pluginAPI(core/src/service/pluginAPI.ts

    • 包括describeregisterCommand
  3. @umijs/preset-umi或其他插件

    • @umijs/preset-umi会在其他自定义插件之前被初始化。其内部通过api.registerMethod注入了许多方法,如addBeforeBabelPluginsmodifyHTMLonBuildComplete等。这些方法都可以在api对象中访问。

大家可能会发现Umi插件API中有分为核心API与拓展方法。里面的核心API就来自servicepluginAPI对象,而拓展方法都是在插件中通过registerMethod核心API注册的。也就是说,我们自定义的插件中也是可以注册拓展方法的,并且这个拓展方法还可以在后面注册的其他插件中使用。

下面是代理到service对象的实现。拓展方法就是注册在这个 service.pluginMethods 中的,这样插件中可以通过api.someMethod方法直接访问到拓展方法了。

  static proxyPluginAPI(opts: {
    pluginAPI: PluginAPI;
    service: Service;
    serviceProps: string[];
    staticProps: Record<string, any>;
  }) {
    return new Proxy(opts.pluginAPI, {
      get: (target, prop: string) => {
        // 如果存在 service.pluginMethods[prop],则返回其中的 fn 方法
        if (opts.service.pluginMethods[prop]) {
          return opts.service.pluginMethods[prop].fn;
        }
        if (opts.serviceProps.includes(prop)) {
          const serviceProp = opts.service[prop];
          return typeof serviceProp === 'function'
            ? serviceProp.bind(opts.service)
            : serviceProp;
        }
        if (prop in opts.staticProps) {
          return opts.staticProps[prop];
        }
        return target[prop];
      },
    });
  }

总结重点:插件的本质是一个方法,这个方法有一个唯一参数:apiapi的核心方法来自service与pluginAPI对象,而它的拓展方法可以通过核心方法registerMethod()注册。如果一个插件中注册了拓展方法,那么之后注册的所有插件中都可以使用它。拓展方法给Umi带来了极大的拓展性。

拓展方法的注册与执行

那么一个拓展方法是怎么注册?又是如何工作的呢?这里以 modifyConfig 为例。使用示例如下:

import { IApi } from 'umi';
 
export default (api: IApi) => {
  api.describe({
    key: 'changeFavicon',
    config: {
      schema(joi) {
        return joi.string();
      },
    },
    enableBy: api.EnableBy.config
  });
  
  // 注册 modifyConfig 的钩子(hook)
  api.modifyConfig((memo)=>{
    memo.favicon = api.userConfig.changeFavicon;
    return memo;
  });
};

modifyConfig用来修改Umi配置,上面的示例中修改了默认的favicon属性,从而达到自定义favicon的效果。这里在modifyConfig里传入了一个函数,我们先简单的理解为它把这个函数插入了一个队列里存了起来,以便后面取出调用。

modifyConfig是在 core/src/service/servicePlugin.ts 这个插件中定义的,这个插件同样会在其他自定义插件之前注册。内容如下,即调用api.registerMethod核心方法来注册一些方法。

// service/servicePlugin
export default (api: PluginAPI) => {
  [
    'onCheck',
    'onStart',
    'modifyAppData',
    'modifyConfig',
    'modifyDefaultConfig',
    'modifyPaths',
    'modifyTelemetryStorage',
  ].forEach((name) => {
    api.registerMethod({ name });
  });
};

那何时执行前面插入到队列里的函数呢?在run方法中需要获取umi配置时就会通过applyPlugins方法执行modifyConfig这个API队列里注册的函数。它会把初始值传入到队列里的第一个函数中,函数执行后返回修改后的值,这个值又会接着传递给下一个注册函数,直到所有注册函数执行完,再将最后结果返回。

async resolveConfig() {
  const config = await this.applyPlugins({
    key: 'modifyConfig',
    initialValue,
    args: { paths: this.paths },
  });
  // ...
  return { config, defaultConfig };
}

总结重点:一个拓展方法可以通过api.registerMethod()方法进行注册。在注册后,其他插件中可以通过api.method(fn)向这个拓展方法中传入回调函数。这些回调函数会在一个特定的时期被取出执行。Umi中的编译时与运行时能力的拓展都是通过拓展方法的方式实现的。

Hook的执行

终于到这一步了。前面向拓展方法中传入的回调函数我们暂且称它为钩子(hook)函数。对于前面的内容,大家可能会疑问,传入的钩子函数具体是怎么执行的呢?是按注册的时间顺序执行吗?入参与返回值又有什么要求呢?

在看具体执行逻辑之前,我们先看看两个与注册相关的核心方法:regiterregisterMethod

register用来注册一个Hook。它定义了一个数组service.hooks[key],每次执行都会向数组中插入一条信息。信息中包括一个函数fnbeforestate可以先记在脑中,它们与fn的执行时机相关。

register(opts: { key: string, fn, before?: string, stage?: number}) {
  this.service.hooks[opts.key] ||= [];
  this.service.hooks[opts.key].push(
    new Hook({ ...opts, plugin: this.plugin }),
  );
}

registerMethod用来注册一个拓展方法,这个方法名是参数namename是唯一的)。其内容存储在
service.pluginMethods[name]中(🌟前面讲属性代理时有提到)。在不传入fn参数时,它默认会通过register方法注册一个Hook。

registerMethod(opts: { name: string; fn?: Function }) {
  this.service.pluginMethods[opts.name] = {
    plugin: this.plugin,
    fn:
      opts.fn ||
      function (fn) {
        this.register({
          key: opts.name,
          fn
        });
      },
  };
}

所以,当你使用下面的方式注册一个addFoo方法时,调用api.addFoo(fn)会发生什么呢?

api.registerMethod({
  name: 'addFoo'
})

它会向service.hooks.addFoo中插入一条数据:

{
    key: 'addFoo',
    fn: fn
}

这样,我们注册在拓展方法(Hook)中的回调就存在了service.hooks[methodName]里,后面可以方便的访问。

接下来就是执行Hook了!applyPlugins()定义在Service类中。applyPlugins()中会将指定Hook的回调一次性取出执行。执行这些钩子函数使用了Tapable这个库,Tapable支持指定执行顺序、同步异步以及返回值传递等特性(钩子队列里的函数如何执行就靠它了)。

applyPlugins中有三种不同类型的钩子,通过传入的key以什么开头来区分

  • add(”add”开头,如addBeforeBabelPlugins
  • modify(”modify”开头,如modifyConfig
  • event(”on”开头,如onBeforeCompile

每种钩子都可以绑定多个回调,不同类别的钩子回调的执行方式有以下区别:

  • add:按照hook函数的顺序依次执行。它们的返回值会按执行顺序拼接成一个数组。最后将这个数组返回。
  • modify:按照hook函数顺序依次执行。与add不同的是,每一个钩子处理完传入的初始值后,会将这个值传递给下一个钩子继续处理。最后一个钩子的返回值即是最终返回值。这样即达到了modify的效果。
  • event:按照hook函数顺序依次执行。没有返回值。

applyPlugins()源码如下,前面提到的register方法的beforestage参数会被透传给Tapable的tabPromise方法。具体使用方法可以查看官方文档

applyPlugins(opts){
  const hooks = this.hooks[opts.key] || [];
  let type = opts.type;
  // ... type分为3种类型: add,modify,event
  switch (type) {
    case ApplyPluginsType.add:
      const tAdd = new AsyncSeriesWaterfallHook(['memo']);
      for (const hook of hooks) {
        tAdd.tapPromise(
          {
            name: hook.plugin.key,
            stage: hook.stage || 0,
            before: hook.before,
          },
          async (memo: any) => {
            const items = await hook.fn(opts.args);
            return memo.concat(items);
          },
        );
      }
      return tAdd.promise(opts.initialValue || []);
    case ApplyPluginsType.modify:
      const tModify = new AsyncSeriesWaterfallHook(['memo']);
      for (const hook of hooks) {
        tModify.tapPromise(
          {
            name: hook.plugin.key,
            stage: hook.stage || 0,
            before: hook.before,
          },
          async (memo: any) => {
            const ret = await hook.fn(memo, opts.args);
            return ret;
          },
        );
      }
      return tModify.promise(opts.initialValue);
    case ApplyPluginsType.event:
      if (opts.sync) {
        const tEvent = new SyncWaterfallHook(['_']);
        hooks.forEach((hook) => {
          if (this.isPluginEnable(hook)) {
            tEvent.tap(
              {...},
              () => {
                hook.fn(opts.args);
              },
            );
          }
        });

        return tEvent.call(1) as T;
      }

      const tEvent = new AsyncSeriesWaterfallHook(['_']);
      for (const hook of hooks) {
        tEvent.tapPromise(
          {...},
          async () => {
            await hook.fn(opts.args);
          },
        );
      }
      return tEvent.promise(1);
  }
}

总结重点:一个拓展方法就是一个Hook,里面可以注册多个回调。Hook有几种类型:addmodifyevent,它们各自的作用不同。Hook的执行依赖了Tapable这个库。

问题回顾

  • 如何编写Umi的插件?Umi插件的本质就是一个函数,它接收一个api参数,里面包含了核心API,拓展方法,以及一些属性。你可以使用拓展方法注册自定义处理逻辑。
  • Umi的插件化是如何实现的?可以从拓展方法的实现去理解:首先注册一个拓展方法(registerMethod),之后可以向这个Hook中插入回调(api.someMethod)。在特定时期,这个拓展方法的回调会被取出执行(applyPlugins)。值得注意的是,Hook有三种不同类型,它们的作用有所区别。
  • Umi中plugin与preset有什么区别?preset可以用来注册一组plugin,仅此区别。
  • 我们可以通过Umi插件实现哪些功能?不仅能通过已有的Umi拓展方法来自定义逻辑,还能自己实现其他的拓展方法,并且可以在另外的插件中使用。

原文链接:https://juejin.cn/post/7214400156593324091 作者:Knockkk

(0)
上一篇 2023年3月26日 上午10:41
下一篇 2023年3月26日 上午10:52

相关推荐

发表回复

登录后才能评论