【umi】 插件核心机制

143 阅读2分钟

image.png

核心概念

preset 和plugin

preset 的作用是预设一些插件,它通常用来注册一批 presets 和 plugins。在 preset 中,上述提到的接受 api 的方法可以有返回值,该返回值是一个包含 plugins 和 presets 属性的对象,其作用就是注册相应的插件或者插件集。

import { IApi } from 'umi';

export default (api: IApi) => {
  return {
    plugins: ['./plugin_foo','./plugin_bar'],
    presets: [
     // npm 依赖
     'umi-preset-hello',
     // 相对路径
    './preset',
    // 绝对路径
    `${__dirname}/preset.js`,
   ],
  }
};

它们的注册顺序是值得注意的:presets 始终先于 plugins 注册。Umi 维护了两个队列分别用来依次注册 presets 和 plugins,这个例子中的注册的 preset_foo 将被置于 presets 队列队首,而 plugin_foo 和 plugin_bar 将被依次置于 plugins 队列队尾。这里把 preset 放在队首的目的在于保证 presets 之间的顺序和关系是可控的。

packages/core/src/service/service.ts image.png

生命周期

`` export enum ServiceStage { uninitialized, init, initPresets, initPlugins, resolveConfig, collectAppData, onCheck, onStart, runCommand, }


### init阶段

####  init stage
>-   init stage: 该阶段 Umi 将加载各类配置信息。包括:加载 `.env` 文件; require `package.json` ;加载用户的配置信息; resolve 所有的插件(内置插件、环境变量、用户配置依次进行)


``` js
// packages/core/src/service/service.ts run()
 async run(opts: { name: string; args?: any }) {
  const { name, args = {} } = opts;
    args._ = args._ || [];
    // shift the command itself
    if (args._[0] === name) args._.shift();
    this.args = args;
    this.name = name;

    // loadEnv
    this.stage = ServiceStage.init;
    loadEnv({ cwd: this.cwd, envFile: '.env' });
    // get pkg from package.json
    let pkg: Record<string, string | Record<string, any>> = {};
    let pkgPath: string = '';
    try {
      pkg = require(join(this.cwd, 'package.json'));
      pkgPath = join(this.cwd, 'package.json');
    } catch (_e) {
      // APP_ROOT
      if (this.cwd !== process.cwd()) {
        try {
          pkg = require(join(process.cwd(), 'package.json'));
          pkgPath = join(process.cwd(), 'package.json');
        } catch (_e) {}
      }
    }
    this.pkg = pkg;
    this.pkgPath = pkgPath || join(this.cwd, 'package.json');

    const prefix = this.frameworkName;
    const specifiedEnv = process.env[`${prefix}_ENV`.toUpperCase()];
    // https://github.com/umijs/umi/pull/9105
    // assert(
    //   !specifiedEnv ||
    //     (specifiedEnv && !Object.values(SHORT_ENV).includes(specifiedEnv)),
    //   `${chalk.yellow(
    //     Object.values(SHORT_ENV).join(', '),
    //   )} config files will be auto loaded by env, Do not configure ${chalk.cyan(
    //     `process.env.${prefix.toUpperCase()}_ENV`,
    //   )} with these values`,
    // );
    // get user config
    const configManager = new Config({
      cwd: this.cwd,
      env: this.env,
      defaultConfigFiles: this.opts.defaultConfigFiles,
      specifiedEnv,
    });

    this.configManager = configManager;
    this.userConfig = configManager.getUserConfig().config;
    // get paths
    // temporary paths for use by function generateFinalConfig.
    // the value of paths may be updated by plugins later
    // 抽离成函数,方便后续继承覆盖
    this.paths = await this.getPaths();
   }

注册阶段

initPresets stage

  • initPresets stage: 该阶段 Umi 将注册 presets。presets 在注册的时候可以通过 return { presets, plugins } 来添加额外的插件。其中 presets 将添加到 presets 队列的队首,而 plugins 将被添加到 plugins 队列的队尾。

initPlugins stage

  • initPlugins stage: 该阶段 Umi 将注册 plugins。这里的 plugins 包括上个阶段由 presets 添加的额外的 plugins, 一个值得注意的点在于: 尽管 plugins 也可以 return { presets, plugins } ,但是 Umi 不会对其进行任何操作。插件的 init 其实就是执行插件的代码(但是插件的代码本质其实只是调用 api 进行各种 hook 的注册,而 hook 的执行并非在此阶段执行,因此这里叫插件的注册)。
 // packages/core/src/service/service.ts run()
 // resolve initial presets and plugins
    const { plugins, presets } = Plugin.getPluginsAndPresets({
      cwd: this.cwd,
      pkg,
      plugins: [require.resolve('./generatePlugin')].concat(
        this.opts.plugins || [],
      ),
      presets: [require.resolve('./servicePlugin')].concat(
        this.opts.presets || [],
      ),
      userConfig: this.userConfig,
      prefix,
    });
    // register presets and plugins
    this.stage = ServiceStage.initPresets;
    const presetPlugins: Plugin[] = [];
    while (presets.length) {
      await this.initPreset({
        preset: presets.shift()!,
        presets,
        plugins: presetPlugins,
      });
    }
    plugins.unshift(...presetPlugins);
    this.stage = ServiceStage.initPlugins;
    while (plugins.length) {
      await this.initPlugin({ plugin: plugins.shift()!, plugins });
    }
    const command = this.commands[name];
    if (!command) {
      this.commandGuessHelper(Object.keys(this.commands), name);
      throw Error(`Invalid command ${chalk.red(name)}, it's not registered.`);
    }
    // collect configSchemas and configDefaults
    for (const id of Object.keys(this.plugins)) {
      const { config, key } = this.plugins[id];
      if (config.schema) this.configSchemas[key] = config.schema;
      if (config.default !== undefined) {
        this.configDefaults[key] = config.default;
      }
      this.configOnChanges[key] = config.onChange || ConfigChangeType.reload;
    }
   

initPreset

async initPreset(opts: {
 preset: Plugin;
 presets: Plugin[];
 plugins: Plugin[];
}) {
 const { presets, plugins } = await this.initPlugin({
   plugin: opts.preset,
   presets: opts.presets,
   plugins: opts.plugins,
 });
 // presets 将添加到 presets 队列的队首
 opts.presets.unshift(...(presets || []));
 // plugins 将被添加到 plugins 队列的队尾
 opts.plugins.push(...(plugins || []));
}

initPlugins

通过pluginAPI.registerPresetspluginAPI.registerPlugins 进行注册

  async initPlugin(opts: {
  plugin: Plugin;
  presets?: Plugin[];
  plugins: Plugin[];
}) {
  // register to this.plugins
  assert(
    !this.plugins[opts.plugin.id],
    `${opts.plugin.type} ${opts.plugin.id} is already registered by ${
      this.plugins[opts.plugin.id]?.path
    }, ${opts.plugin.type} from ${opts.plugin.path} register failed.`,
  );
  this.plugins[opts.plugin.id] = opts.plugin;

  // apply with PluginAPI
  const pluginAPI = new PluginAPI({
    plugin: opts.plugin,
    service: this,
  });
  pluginAPI.registerPresets = pluginAPI.registerPresets.bind(
    pluginAPI,
    opts.presets || [],
  );
  pluginAPI.registerPlugins = pluginAPI.registerPlugins.bind(
    pluginAPI,
    opts.plugins,
  );
  const proxyPluginAPI = PluginAPI.proxyPluginAPI({
    service: this,
    pluginAPI,
    serviceProps: [
      'appData',
      'applyPlugins',
      'args',
      'config',
      'cwd',
      'pkg',
      'pkgPath',
      'name',
      'paths',
      'userConfig',
      'env',
      'isPluginEnable',
    ],
    staticProps: {
      ApplyPluginsType,
      ConfigChangeType,
      EnableBy,
      ServiceStage,
      service: this,
    },
  });
  let dateStart = new Date();
  let ret = await opts.plugin.apply()(proxyPluginAPI);
  opts.plugin.time.register = new Date().getTime() - dateStart.getTime();
  if (opts.plugin.type === 'plugin') {
    assert(!ret, `plugin should return nothing`);
  }
  // key should be unique
  assert(
    !this.keyToPluginMap[opts.plugin.key],
    `key ${opts.plugin.key} is already registered by ${
      this.keyToPluginMap[opts.plugin.key]?.path
    }, ${opts.plugin.type} from ${opts.plugin.path} register failed.`,
  );
  this.keyToPluginMap[opts.plugin.key] = opts.plugin;
  if (ret?.presets) {
    ret.presets = ret.presets.map(
      (preset: string) =>
        new Plugin({
          path: preset,
          type: PluginType.preset,
          cwd: this.cwd,
        }),
    );
  }
  if (ret?.plugins) {
    ret.plugins = ret.plugins.map(
      (plugin: string) =>
        new Plugin({
          path: plugin,
          type: PluginType.plugin,
          cwd: this.cwd,
        }),
    );
  }
  return ret || {};
}

packages/core/src/service/pluginAPI.ts

registerPresets
export class PluginAPI {
  registerPresets(source: Plugin[], presets: any[]) {
    assert(
      this.service.stage === ServiceStage.initPresets,
      `api.registerPresets() failed, it should only used in presets.`,
    );
    source.splice(
      0,
      0,
      ...presets.map((preset) => {
        return new Plugin({
          path: preset,
          cwd: this.service.cwd,
          type: PluginType.preset,
        });
      }),
    );
  }
}
registerPlugins
export class PluginAPI {
  registerPlugins(source: Plugin[], plugins: any[]) {
    assert(
      this.service.stage === ServiceStage.initPresets ||
        this.service.stage === ServiceStage.initPlugins,
      `api.registerPlugins() failed, it should only be used in registering stage.`,
    );
    const mappedPlugins = plugins.map((plugin) => {
      if (lodash.isPlainObject(plugin)) {
        assert(
          plugin.id && plugin.key,
          `Invalid plugin object, id and key must supplied.`,
        );
        plugin.type = PluginType.plugin;
        plugin.enableBy = plugin.enableBy || EnableBy.register;
        plugin.apply = plugin.apply || (() => () => {});
        plugin.config = plugin.config || {};
        plugin.time = { hooks: {} };
        return plugin;
      } else {
        return new Plugin({
          path: plugin,
          cwd: this.service.cwd,
          type: PluginType.plugin,
        });
      }
    });
    if (this.service.stage === ServiceStage.initPresets) {
      source.push(...mappedPlugins);
    } else {
      source.splice(0, 0, ...mappedPlugins);
    }
  }
 }

执行阶段

主要利用applyPlugins来完成

resolveConfig stage

  • resolveConfig stage: 该阶段 Umi 将整理各个插件中对于 config schema 的定义,然后执行插件的 modifyConfig 、modifyDefaultConfig、 modifyPaths 等 hook,进行配置的收集。
   // setup api.config from modifyConfig and modifyDefaultConfig
    this.stage = ServiceStage.resolveConfig;
    const { defaultConfig } = await this.resolveConfig();
    if (this.config.outputPath) {
      this.paths.absOutputPath = isAbsolute(this.config.outputPath)
        ? this.config.outputPath
        : join(this.cwd, this.config.outputPath);
    }
    this.paths = await this.applyPlugins({
      key: 'modifyPaths',
      initialValue: this.paths,
    });

    const storage = await this.applyPlugins({
      key: 'modifyTelemetryStorage',
      initialValue: noopStorage,
    });

    this.telemetry.useStorage(storage);
async resolveConfig() {
   // configManager and paths are not available until the init stage
   assert(
     this.stage > ServiceStage.init,
     `Can't generate final config before init stage`,
   );

   const resolveMode = this.commands[this.name].configResolveMode;
   const config = await this.applyPlugins({
     key: 'modifyConfig',
     // why clone deep?
     // user may change the config in modifyConfig
     // e.g. memo.alias = xxx
     initialValue: lodash.cloneDeep(
       resolveMode === 'strict'
         ? this.configManager!.getConfig({
             schemas: this.configSchemas,
           }).config
         : this.configManager!.getUserConfig().config,
     ),
     args: { paths: this.paths },
   });
   const defaultConfig = await this.applyPlugins({
     key: 'modifyDefaultConfig',
     // 避免 modifyDefaultConfig 时修改 this.configDefaults
     initialValue: lodash.cloneDeep(this.configDefaults),
   });
   this.config = lodash.merge(defaultConfig, config) as Record<string, any>;

   return { config, defaultConfig };
 }

collectionAppData stage

  • collectionAppData stage: 该阶段 Umi 执行 modifyAppData hook,来维护 App 的元数据。( AppData 是 umi@4 新增的 api )
// applyPlugin collect app data
    // TODO: some data is mutable
    this.stage = ServiceStage.collectAppData;
    this.appData = await this.applyPlugins({
      key: 'modifyAppData',
      initialValue: {
        // base
        cwd: this.cwd,
        pkg,
        pkgPath,
        plugins: this.plugins,
        presets,
        name,
        args,
        // config
        userConfig: this.userConfig,
        mainConfigFile: configManager.mainConfigFile,
        config: this.config,
        defaultConfig: defaultConfig,
        // TODO
        // moduleGraph,
        // routes,
        // npmClient,
        // nodeVersion,
        // gitInfo,
        // gitBranch,
        // debugger info,
        // devPort,
        // devHost,
        // env
      },
    });
    ```
    
    #### onCheck stage
> -   onCheck stage: 该阶段 Umi 执行 `onCheck` hook。

// applyPlugin onCheck
this.stage = ServiceStage.onCheck;
await this.applyPlugins({
  key: 'onCheck',
});
```

#### onStart stage
  • onStart stage: 该阶段 Umi 执行 onStart hook。
    // applyPlugin onStart
    this.stage = ServiceStage.onStart;
    await this.applyPlugins({
      key: 'onStart',
    });

run command 阶段

  • runCommand stage: 该阶段 Umi 运行当前 cli 要执行的 command,(例如 umi dev, 这里就会执行 dev command)Umi 的各种核心功能都在 command 中实现。包括我们的插件调用 api 注册的绝大多数 hook。
    // run command
    this.stage = ServiceStage.runCommand;
    let ret = await command.fn({ args });
    this._profilePlugins();

以上就是 Umi 的插件机制的整体流程。

core API

packages/core/src/service/pluginAPI.ts

register

api.register({ key: string, fn, before?: string, stage?: number})

为 api.applyPlugins 注册可供其使用的 hook。

register(opts: Omit<IHookOpts, 'plugin'>) {
    assert(
      this.service.stage <= ServiceStage.initPlugins,
      'api.register() should not be called after plugin register stage.',
    );
    this.service.hooks[opts.key] ||= [];
    this.service.hooks[opts.key].push(
      new Hook({ ...opts, plugin: this.plugin }),
    );
  }

值得注意的是, fn的写法要结合即将使用的applyPlugin的type参数来确定

type

export enum ApplyPluginsType {
  add = 'add',
  modify = 'modify',
  event = 'event',
}

type等于add, 当keyadd开头且没有显示声明type时, applyPlugins 会默认按此类型执行, fn 需有返回值, applyPlugins 的 initialValue 必须是一个数组

await api.register({
  key: 'addFoo',
fn: async (args) => args * 2
 })
 
api.applyPlugins({
    key: 'addFoo',
    initialValue: [],
    args: 1
}).then(data=>{
// [2]
})

type等于modify, 当keymodify开头且没有显示声明type时, applyPlugins 会默认按此类型执行, 需要initialValue,fn 需要返回修改后的memo

await api.register({
  key: 'modifyMasterHTML',
  fn: (memo, args) => ({ ...memo, a: args})
 })
 
api.applyPlugins({
    key: 'modifyMasterHTML',
    type: api.ApplyPluginsType.modify, // 可以不写
    initialValue: $.html(),
});

type等于event,当keyon开头且没有显示声明type时, applyPlugins 会默认按此类型执行,不需要fninitvalues


await api.register({
 key: 'onCheckCode'
})

api.applyPlugins({
 key: 'onCheckCode',
 args: {
 ...args,
   CodeFrameError,
  },
 sync: true,
});

applyPlugins

api.applyPlugins({ key: string, type?: api.ApplyPluginsType, initialValue?: any, args?: any })

取得 register() 注册的 hooks 执行后的数据

核心本质: 插件的代码本质其实只是调用 api 进行各种 hook 的注册,而 hook 的执行并非在此阶段执行、执行 功能: 修改代码打包配置、修改启动代码、约定目录结构、修改 HTML等等

packages/core/src/service/pluginAPI.ts

registerMethod

api.registerMethod({ name: string, fn? })

往 api 上注册一个名为 'name' 的方法。

  • 当传入了 fn 时,执行 fn
  • 当没有传入 fn 时,registerMethod 会将 name 作为 api.register 的 key 并且将其柯里化后作为 fn。这种情况下相当于注册了一个 register 的快捷调用方式,便于注册 hook。
  // 这里的函数柯里话化表现在哪里?
  registerMethod(opts: { name: string; fn?: Function }) {
    assert(
      !this.service.pluginMethods[opts.name],
      `api.registerMethod() failed, method ${opts.name} is already exist.`,
    );
    this.service.pluginMethods[opts.name] = {
      plugin: this.plugin,
      fn:
        opts.fn ||
        // 这里不能用 arrow function,this 需指向执行此方法的 PluginAPI
        // 否则 pluginId 会不会,导致不能正确 skip plugin
        function (fn: Function | Object) {
          // @ts-ignore
          this.register({
            key: opts.name,
            ...(lodash.isPlainObject(fn) ? (fn as any) : { fn }),
          });
        },
    };
  }

preset-umi

@umijs/core 提供了一套插件的注册和管理机制, 核心的功能都依靠preset-umi来实现

packages/preset-umi/src/index.ts

  • registerMethods 这类插件注册了一些上述提到的“注册器”,以供开发者快速地注册 hook,这类方法也占据了 PluginAPI 中的大多数。
// packages/preset-umi/src/registerMethods.ts
export default (api: IApi) => {
  [
    'onGenerateFiles',
    'onBeforeCompiler',
    'onBuildComplete',
    'onBuildHtmlComplete',
    'onPatchRoute',
    'onPkgJSONChanged',
    'onPrepareBuildSuccess',
    'onDevCompileDone',
    'onCheckPkgJSON',
    'onCheckCode',
    'onCheckConfig',
    'onBeforeMiddleware',
    'addBeforeMiddlewares',
    'addLayouts',
    'addMiddlewares',
    'addApiMiddlewares',
    'addRuntimePlugin',
    'addRuntimePluginKey',
    'addPolyfillImports',
    'addPrepareBuildPlugins',
    'addEntryImportsAhead',
    'addEntryImports',
    'addEntryCodeAhead',
    'addEntryCode',
    'addExtraBabelPresets',
    'addExtraBabelPlugins',
    'addBeforeBabelPresets',
    'addBeforeBabelPlugins',
    'addHTMLMetas',
    'addHTMLLinks',
    'addHTMLStyles',
    'addHTMLHeadScripts',
    ...
  ].forEach((name) => {
    api.registerMethod({ name });
  });
  • features 这类插件为 Umi 提供了一些特性,例如 appData、lowImport、mock 等。

packages/preset-umi/src/features

image.png

  • commands 这类插件注册了各类 command, 提供了 Umi CLI 的各种功能。Umi 能够在终端中正常运行,依靠的就是 command 提供的功能。

packages/preset-umi/src/commands

image.png