umi源码阅读5:插件机制

795 阅读6分钟

umi阅读分为5个部分,分别是:

以下代码以umi的3.5.20版本为例,主要内容以源码+个人解读为主

上一篇:umi源码阅读4:插件机制

插件机制

umi的另一个特点是一切皆插件,本部分内容主要研究插件机制,包括注册,初始化。

主要内容:研究插件如何注册和执行的过程,并了解插件实现的机制。

v3.umijs.org/zh-CN/guide… umi官网中关于插件开发的内容中简单描述了如何开发一个插件,下面是官网的简单示例:

export default function (api: IApi) {
  api.logger.info('use plugin');
  api.describe({
    key: 'mainPath',
    config: {
      schema(joi) {
        return joi.string();
      },
    },
  });

  api.modifyHTML(($) => {
    $('body').prepend(`<h1>hello umi plugin</h1>`);
    return $;
  });

  if (api.userConfig.mainPath) {
    api.modifyRoutes((routes: any[]) => {
      return resetMainPath(routes, api.config.mainPath);
    });
  }

}

显然这里的api对象是从外部注入的,那api对象是什么呢?

带着这个问题我们来看代码。首先是插件引用:命名规则使用 @umijs 或者 umi-plugin 开头;或在config中显示引用。接下来我们看umi是怎么处理这两种插件规则的。

也是dev执行过程去查找。

packages/core/src/Service/Service.ts中Service类中,实例化函数中

// 初始化插件集
this.initialPresets = resolvePresets({
      ...baseOpts,
      presets: opts.presets || [],
      userConfigPresets: this.userConfig.presets || [],
    });
// 初始化插件
this.initialPlugins = resolvePlugins({
      ...baseOpts,
      plugins: opts.plugins || [],
      userConfigPlugins: this.userConfig.plugins || [],
    });

继续看调用的函数

// 处理插件集
export function resolvePresets(opts: IResolvePresetsOpts) {
  const type = PluginType.preset;
  const presets = [...getPluginsOrPresets(type, opts)];
  return presets.map((path: string) => {
    return pathToObj({
      type,
      path,
      cwd: opts.cwd,
    });
  });
}

// 处理插件
export function resolvePlugins(opts: IResolvePluginsOpts) {
  const type = PluginType.plugin;
  const plugins = getPluginsOrPresets(type, opts);
  return plugins.map((path: string) => {
    return pathToObj({
      type,
      path,
      cwd: opts.cwd,
    });
  });
}

在resolvePresets和resolvePlugins中,都调用了getPluginsOrPresets:

function getPluginsOrPresets(type: PluginType, opts: IOpts): string[] {
  const upperCaseType = type.toUpperCase();
  return [
    // opts
    ...((opts[type === PluginType.preset ? 'presets' : 'plugins'] as any) ||
      []),
    // env
    ...(process.env[`UMI_${upperCaseType}S`] || '').split(',').filter(Boolean),
    // dependencies
    ...Object.keys(opts.pkg.dependencies || {})
      .concat(Object.keys(opts.pkg.dependencies || {}))
      .filter(isPluginOrPreset.bind(null, type)),
    // user config
    ...((opts[
      type === PluginType.preset ? 'userConfigPresets' : 'userConfigPlugins'
    ] as any) || []),
  ].map((path) => {
    
    return resolve.sync(path, {
      basedir: opts.cwd,
      extensions: ['.js', '.ts'],
    });
  });
}

getPluginsOrPresets中获取来着以下几个途径的插件:

(1)内置的,即opts

(2)环境变量中[UMI_${upperCaseType}S]注入的

(3)packages.json文件dependencies和dependencies中符合插件命名规则的插件

(4)用户配置中的显示引用插件

其中(3)(4)便是我们常用的外部插件引用的方式。这里的引用只是返回模块的路径。所有的路径还会进行依次转换,调用的是pathToObj函数

export function pathToObj({
  type,
  path,
  cwd,
}: {
  type: PluginType;
  path: string;
  cwd: string;
}) {
  let pkg = null;
  let isPkgPlugin = false;

  const pkgJSONPath = pkgUp.sync({ cwd: path });
  if (pkgJSONPath) {
    pkg = require(pkgJSONPath);
    isPkgPlugin =
      winPath(join(dirname(pkgJSONPath), pkg.main || 'index.js')) ===
      winPath(path);
  }

  // 生成规范id
  let id;
  if (isPkgPlugin) {
    id = pkg!.name;
  } else if (winPath(path).startsWith(winPath(cwd))) {
    id = `./${winPath(relative(cwd, path))}`;
  } else if (pkgJSONPath) {
    id = winPath(join(pkg!.name, relative(dirname(pkgJSONPath), path)));
  } else {
    id = winPath(path);
  }
  id = id.replace('@umijs/preset-built-in/lib/plugins', '@@');
  id = id.replace(/.js$/, '');

  // 生成规范key
  const key = isPkgPlugin
    ? pkgNameToKey(pkg!.name, type)
    : nameToKey(basename(path, extname(path)));

  // 返回规范化对象
  return {
    id,
    key,
    path: winPath(path),
    apply() {
      // 使用插件的时候则是调用apply函数获取,延迟导入函数对象
      try {
        const ret = require(path);
        // 导出es模块
        return compatESModuleRequire(ret);
      } catch (e) {
        throw new Error(`Register ${type} ${path} failed, since ${e.message}`);
      }
    },
    defaultConfig: null,
  };
}

获得规范化的插件对象之后,我们继续研究怎么注册到umi的Service对象中,前面说到service实例化之后,运行了service.run()的方法,我们也确实在该方法中找到了初始化插件的函数

在service中init函数调用了initPresetsAndPlugins函数初始化插件:

async initPresetsAndPlugins() {
    // 遍历初始化preset
    this.setStage(ServiceStage.initPresets);
    this._extraPlugins = [];
    while (this.initialPresets.length) {
      await this.initPreset(this.initialPresets.shift()!);
    }
  	// 遍历初始化plugin
    this.setStage(ServiceStage.initPlugins);
    this._extraPlugins.push(...this.initialPlugins);
    while (this._extraPlugins.length) {
      await this.initPlugin(this._extraPlugins.shift()!);
    }
}

遍历调用initPreset来初始化preset,遍历调用initPlugin来初始化plugin,先看initPlugin

async initPlugin(plugin: IPlugin) {
    const { id, key, apply } = plugin;

  	// 获取独立的api实例
    const api = this.getPluginAPI({ id, key, service: this });

    // 注册插件
    this.registerPlugin(plugin);
  
  	// api实例注入
    await this.applyAPI({ api, apply });
}

initPlugin执行了3个操作,依次来看。获取api实例,注册插件,和api注入。

理解这里的操作的设计,先区分两个概念,plugin和pluginAPI,前者是插件,而pluginAPI是注册管理插件的类,用来代理注册插件到service中,提供插件基本使用的api。简单来说,pluginAPI承担plugin和service的中间角色。

getPluginAPI

getPluginAPI(opts: any) {
    const pluginAPI = new PluginAPI(opts);

    // register built-in methods
    [
      'onPluginReady',
      'modifyPaths',
      'onStart',
      '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];
        if (
          [
            'applyPlugins',
            'ApplyPluginsType',
            'EnableBy',
            'ConfigChangeType',
            'babelRegister',
            'stage',
            'ServiceStage',
            'paths',
            'cwd',
            'pkg',
            'userConfig',
            'config',
            'env',
            'args',
            'hasPlugins',
            'hasPresets',
          ].includes(prop)
        ) {
          return typeof this[prop] === 'function'
            ? this[prop].bind(this)
            : this[prop];
        }
        return target[prop];
      },
    });
  }

pluginAPI.registerMethod的实际操作,name存在则不会重复注册

pluginAPI.registerMethod({name,fn}) === this.service.pluginMethods[name] = fn

getPluginAPI返回了一个代理对象Proxy,从代码逻辑来看,是通过pluginAPI代理了servece部分操作,api.prop实际调用的是service.prop

registerPlugin

 registerPlugin(plugin: IPlugin) {
    if (this.plugins[plugin.id]) {
      // 校验
    }
    // 根据id挂接插件对象
    this.plugins[plugin.id] = plugin;
  }

applyAPI

async applyAPI(opts: { apply: Function; api: PluginAPI }) {
    // 调用插件apply,并注入api
    let ret = opts.apply()(opts.api);
    if (isPromise(ret)) {
      ret = await ret;
    }
    return ret || {};
}

apply上面说明了是延迟导入插件对象的操作,apply()返回的标识插件对象,然后再注入api,api是PluginAPI的实例化对象,该对象用于管理注册plugin,并提供一些service的api能力,包括modifyHTML之类的。

再看一下initPreset,对preset的不同特性进行了处理。

  async initPreset(preset: IPreset) {
    const { id, key, apply } = preset;
    preset.isPreset = true;

    const api = this.getPluginAPI({ id, key, service: this });

    // register before apply
    this.registerPlugin(preset);
    
    const { presets, plugins, ...defaultConfigs } = await this.applyAPI({
      api,
      apply,
    });

    // 提供了preset和plugin可以注册preset和plugin的操作
    if (presets) {
      // 插到最前面,下个 while 循环优先执行
      this._extraPresets.splice(
        0,
        0,
        ...presets.map((path: string) => {
          return pathToObj({
            type: PluginType.preset,
            path,
            cwd: this.cwd,
          });
        }),
      );
    }

    // 深度优先
    const extraPresets = lodash.clone(this._extraPresets);
    this._extraPresets = [];
    while (extraPresets.length) {
      await this.initPreset(extraPresets.shift()!);
    }

    // plugin交给下一步初始化plugin来操作
    if (plugins) {
      this._extraPlugins.push(
        ...plugins.map((path: string) => {
          return pathToObj({
            type: PluginType.plugin,
            path,
            cwd: this.cwd,
          });
        }),
      );
    }
  }

插件的调用applyPlugins:

  async applyPlugins(opts: {
    key: string;
    type: ApplyPluginsType;
    initialValue?: any;
    args?: any;
  }) {
    const hooks = this.hooks[opts.key] || [];
    switch (opts.type) {
      case ApplyPluginsType.add:
       
        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,
            },
            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,
            },
            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,
            },
            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}.`,
        );
    }

AsyncSeriesWaterfallHook时一个异步顺序执行的函数,上一个异步函数的返回值会传给下一个函数,因此可以执行一些同名钩子函数。

PluginAPI的其他api在官网中都有描述v3.umijs.org/zh-CN/plugi…,包括描述,调用等。实现也不复杂,这里不再赘述。

以上就是钩子机制的研究解读。

说在后面:

umi的插件时较为复杂和庞大,理清逻辑需要耗费很大的功夫,也借鉴了不少大佬的解读文档和思路。umi提供了很多的预置插件来完善插件的功能,如果去除大量的插件外,umi的核心框架代码还是比较少的。

umi作为可插拔的企业级框架,通过约定式,插件机制等设计理念,提供了一个大而全且可拓展的前端开发框架。

有些人会觉得umi过于臃肿,当然对于小型项目来说,umi很多非必要的东西确实会影响性能。但是笔者觉得作为大而全的框架,必然会牺牲掉一些东西,umi的后续版本也在优化开发体验上不断努力。

当然如果我们不需要参与维护umi代码,可以不用把umi的实现过程搞得非常清楚,通过研究umi的主要源码,笔者总结框架可学习的内容。

设计理念:

(1)约定大于配置:比如按照他说明的格式创建文件即可开通和使用功能,比如创建mock文件夹则开启mock数据功能。 好处有:项目代码结构清晰,需要什么知道找到文件夹/文件即可;减少配置的负担。

(2)一切皆插件:umi通过将各种操作进行插件抽象化,在此基础上不但实现了框架原有的功能,还提供了外部可插拔的插件机制。这种设计我们可以应用在一些组件框架的设计之中。当然这种插件化的设计会在一定程度上破坏流程的连续性,但是提高了灵活性。

(完)

参考资料:

UMI3源码解析系列之构建原理_大转转FE的博客-CSDN博客

umi源码解析

if 我是前端Leader,谈谈前端框架体系建设 - 掘金