umi源码-07核心插件注册

695 阅读7分钟

第4篇 《04.run 方法-收集插件和插件集》 中我们介绍了umi内置的插件集和插件,这里再汇总一下,包括三个方面,这三个是有顺序的,因为umi中插件集合优先于插件注册。

  • 插件 servicePlugin 注册方法(最终被放在presets里面了,因此先执行)
  • 插件集 @umijs/preset-umi 多种插件的集合
  • 插件 generatePlugin 注册命令

下面会针对这些插件展开说明

servicePlugin

packages/core/src/service/servicePlugin.ts 代码和简单,初始化以下方法

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

这里再复习一下 registerMethod 其实核心逻辑就是在 this.service.pluginMethods 注册一个方法 类似这样 this.service.pluginMethods.modifyPaths = {fn: function(){}} 然后可以通过代理 proxyPluginAPIopts.service.pluginMethods 调用 fn,即 api.onCheck(() => {} )

if (opts.service.pluginMethods[prop]) {
  return opts.service.pluginMethods[prop].fn;
}

下面是 registerMethod 的核心代码

registerMethod(opts: { name: string; fn?: Function }) {
    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 }),
          });
        },
    };
  }

下面是代理调用的核心代码,调用api.onCheck 就是调用 opts.service.pluginMethods['onCheck'] = fn

static proxyPluginAPI(opts: {
    pluginAPI: PluginAPI;
    service: Service;
    serviceProps: string[];
    staticProps: Record<string, any>;
  }) {
    return new Proxy(opts.pluginAPI, {
      get: (target, prop: string) => {
        if (opts.service.pluginMethods[prop]) {
          return opts.service.pluginMethods[prop].fn;
        }
        // @ts-ignore
        return target[prop];
      },
    });
  }

@umijs/preset-umi

插件集中有几十个插件,我们这里挑其中几个来说明

registerMethods

主要分为三个部分

  • 注册大量的方法
  • 执行一个 onStart
  • 注册一个writeTmpFile方法,后面可以调用,源码中都是通过 api.writeTmpFile的方式调用的。

注册了 writeTmpFile之后怎么就能通过api.writeTmpFile 访问到对应注册的方法 实现原理上面已经复习过,此处不再赘述

writeTmpFile 简单来说就是把传进来的内容做一些处理,然后生成一个文件 writeFileSync(absPath, content, 'utf-8');当我们调用api.writeTmpFile()时,这个方法就会执行。

import { init } from '@umijs/bundler-utils/compiled/es-module-lexer';
import { fsExtra, lodash, Mustache } from '@umijs/utils';
import assert from 'assert';
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { IApi } from './types';
import { isTypeScriptFile } from './utils/isTypeScriptFile';
import transformIEAR from './utils/transformIEAR';


export default (api: IApi) => {
  [
    'onGenerateFiles',
    'onBeforeCompiler',
    'onBuildComplete',
    'onBuildHtmlComplete',
    'onPatchRoute',
    // 'onPatchRouteBefore',
    // 'onPatchRoutes',
    // 'onPatchRoutesBefore',
    'onPkgJSONChanged', // new
    'onDevCompileDone',
    'onCheckPkgJSON',
    'onCheckCode',
    'onCheckConfig',
    'onBeforeMiddleware',
    'addBeforeMiddlewares',
    'addLayouts',
    'addMiddlewares',
    'addApiMiddlewares',
    'addRuntimePlugin',
    'addRuntimePluginKey',
    // 'addUmiExports',
    'addPolyfillImports',
    'addEntryImportsAhead',
    'addEntryImports',
    'addEntryCodeAhead',
    'addEntryCode',
    'addExtraBabelPresets',
    'addExtraBabelPlugins',
    'addBeforeBabelPresets',
    'addBeforeBabelPlugins',
    'addHTMLMetas',
    'addHTMLLinks',
    'addHTMLStyles',
    'addHTMLHeadScripts',
    'addHTMLScripts',
    'addTmpGenerateWatcherPaths',
    'chainWebpack',
    'modifyEntry',
    'modifyHTMLFavicon',
    'modifyHTML',
    'modifyExportHTMLFiles',
    'modifyWebpackConfig',
    'modifyViteConfig',
    // 'modifyHTMLChunks',
    // 'modifyExportRouteMap',
    // 'modifyPublicPathStr',
    'modifyRendererPath',
    'modifyServerRendererPath',
    'modifyRoutes',
    'modifyBabelPresetOpts',
  ].forEach((name) => {
    api.registerMethod({ name });
  });


  api.onStart(async () => {
    await init;
  });


  api.registerMethod({
    name: 'writeTmpFile',
    fn(opts: {
      path: string;
      content?: string;
      tpl?: string;
      tplPath?: string;
      context?: Record<string, string>;
      noPluginDir?: boolean;
    }) {
      const absPath = join(
        api.paths.absTmpPath,
        // @ts-ignore
        this.plugin.key && !opts.noPluginDir ? `plugin-${this.plugin.key}` : '',
        opts.path,
      );
      fsExtra.mkdirpSync(dirname(absPath));
      let content = opts.content;
      if (!content) {
        const tpl = opts.tplPath
          ? readFileSync(opts.tplPath, 'utf-8')
          : opts.tpl;
        content = Mustache.render(tpl, opts.context);
      }


      // Only js files generate comments
      const isJsFile = /\.(t|j)sx?$/.test(absPath);


      content = [
        isTypeScriptFile(opts.path) && `// @ts-nocheck`,
        isJsFile && '// This file is generated by Umi automatically',
        isJsFile && '// DO NOT CHANGE IT MANUALLY!',
        content.trim(),
        '',
      ]
        .filter((text) => text !== false)
        .join('\n');


      // transform imports for all javascript-like files only vite mode enable
      if (api.appData.vite && isJsFile) {
        content = transformIEAR({ content, path: absPath }, api);
      }


      if (!existsSync(absPath)) {
        writeFileSync(absPath, content, 'utf-8');
      } else {
        const fileContent = readFileSync(absPath, 'utf-8');


        if (fileContent.startsWith('// debug') || fileContent === content) {
          return;
        } else {
          writeFileSync(absPath, content, 'utf-8');
        }
      }
    },
  });
};

commands/dev/dev

我们再来看一下dev ,看看一个命令是怎么把项目跑起来的。 执行命令前我们自然是要先注册这个命令,注册过程也是通过插件注册的。 这里看一下核心代码, 去除函数体 fn 的内容,具体看命令是怎么注册进来的。

export default (api: IApi) => {
  api.describe({
    enableBy() {
      return api.name === 'dev';
    },
  });


  api.registerCommand({
    name: 'dev',
    description: 'dev server for development',
    details: `
umi dev


# dev with specified port
PORT=8888 umi dev
`,
    async fn() {},
  });
};

稍微详细点的代码如下

registerCommand(
  opts: Omit<ICommandOpts, 'plugin'> & { alias?: string | string[] },
) {
  const { alias } = opts;
  delete opts.alias;
  const registerCommand = (commandOpts: Omit<typeof opts, 'alias'>) => {
    const { name } = commandOpts;
    this.service.commands[name] = new Command({
      ...commandOpts,
      plugin: this.plugin,
    });
  };
  registerCommand(opts);
  if (alias) {
    const aliases = makeArray(alias);
    aliases.forEach((alias) => {
      registerCommand({ ...opts, name: alias });
    });
  }
}

export class Command {
  name: string;
  description?: string;
  options?: string;
  details?: string;
  configResolveMode: ResolveConfigMode;
  fn: {
    ({ args }: { args: yParser.Arguments }): void;
  };
  plugin: Plugin;
  constructor(opts: IOpts) {
    this.name = opts.name;
    this.description = opts.description;
    this.options = opts.options;
    this.details = opts.details;
    this.fn = opts.fn;
    this.plugin = opts.plugin;
    this.configResolveMode = opts.configResolveMode || 'strict';
  }
}

其实核心逻辑很简单,只是在this.service.commands 对象下新增了一个命令,然后实例化new Command

this.service.commands[name] = new Command({
  ...commandOpts,
  plugin: this.plugin,
});
};

当我们执行 umi dev 时,umi的 umi/packages/core/src/service/service.ts 会做两步

  • 第一步从 commands 中找到命令 const command = this.commands[name];
  • 第二步 执行registerCommand注册的命令对应的fn函数体 await command.fn({ args });

通过这两步骤我们就把整个项目关联起来了

generatePlugin

注册一个 generate 命令,用于生成命令的命令,可以以插件的形式调用。 generate命令的注册方法跟上面 注册 dev 命令类似,这里不再详细介绍,主要说一下 generate对应的 fn 做了什么事。

async fn({ args }) {
  const [type] = args._;
  const runGenerator = async (generator: Generator) => {
    await generator?.fn({
      args,
      generateFile,
      installDeps,
      updatePackageJSON,
    });
  };


  if (type) {
    const generator = api.service.generators[type];
    if (!generator) {
      throw new Error(`Generator ${type} not found.`);
    }
    if (generator.type === GeneratorType.enable) {
      const enable = await generator.checkEnable?.({
        args,
      });
      if (!enable) {
        if (typeof generator.disabledDescription === 'function') {
          logger.warn(generator.disabledDescription());
        } else {
          logger.warn(generator.disabledDescription);
        }
        return;
      }
    }
    await runGenerator(generator);
  } else {
    const getEnableGenerators = async (
      generators: typeof api.service.generators,
    ) => {
      const questions = [] as { title: string; value: string }[];
      for (const key of Object.keys(generators)) {
        const g = generators[key];
        if (g.type === GeneratorType.generate) {
          questions.push({
            title: `${g.name} -- ${g.description}` || '',
            value: g.key,
          });
        } else {
          const enable = await g?.checkEnable?.({
            args,
          });
          if (enable) {
            questions.push({
              title: `${g.name} -- ${g.description}` || '',
              value: g.key,
            });
          }
        }
      }
      return questions;
    };
    const questions = await getEnableGenerators(api.service.generators);
    const { gType } = await prompts({
      type: 'select',
      name: 'gType',
      message: 'Pick generator type',
      choices: questions,
    });
    await runGenerator(api.service.generators[gType]);
  }
},

其实核心核心代码就三部分

  1. 第一部分根据不同的type生成 questions
  2. 第二部分通过 prompts 注册 node 的 命令行工具,使用户具备和 node 命令窗口交互的能力。具体看文档 prompts 链接
  3. 第三部分是通过找到 api.service.generators对象中的对应type,然后执行这个type下的fn
const questions = await getEnableGenerators(api.service.generators);
const { gType } = await prompts({
  type: 'select',
  name: 'gType',
  message: 'Pick generator type',
  choices: questions,
});
await runGenerator(api.service.generators[gType]);

const runGenerator = async (generator: Generator) => {
  await generator?.fn({
    args,
    generateFile,
    installDeps,
    updatePackageJSON,
  });
};

要说 api.service.generators,就不得不说 registerGenerator下面我们就具体看下 registerGenerator 是怎么为 api.service.generators 添加不同的type和fn的。

registerGenerator

我们来看 /packages/core/src/service/pluginAPI.ts,找到 registerGenerator

registerGenerator(opts: DistributiveOmit<Generator, 'plugin'>) {
  const { key } = opts;
  this.service.generators[key] = makeGenerator({
    ...opts,
    plugin: this.plugin,
  });
}
export function makeGenerator<T>(opts: T): T {
  return {
    ...opts,
  };
}

代码很简单,把 key 和对应参数放在 this.service.generators对象里面就结束了。 接下来看看具体使用方法,我们找一个简单的注册方法看一下代码怎么写。 从下面代码可以注册命令也是插件,registerGenerator需要传进来两个核心参数

  1. 第一是 key,比如这个插件 key:prettier,执行命令就是 umi generator prettier
  2. 第二是 fn,执行 命令时具体要干的事, 会在上文提到的 generator?.fn执行。这里的fn是配置prettier的,具体如下
    1. 找到项目的根目录,在package.json中添加prettier的依赖项
    2. 找到项目的根目录,添加文件 .prettierrc 和 .prettierignore,并把内容写入
    3. 执行 npm install ,下载依赖项 。内部用到了 [cross-spawn](https://www.npmjs.com/package/cross-spawn),具体看注释。
import { GeneratorType } from '@umijs/core';
import { logger } from '@umijs/utils';
import { existsSync, writeFileSync } from 'fs';
import { join } from 'path';
import { IApi } from '../../types';
import { GeneratorHelper } from './utils';


export default (api: IApi) => {
  api.describe({
    key: 'generator:prettier',
  });


  api.registerGenerator({
    key: 'prettier',
    name: 'Enable Prettier',
    description: 'Setup Prettier Configurations',
    type: GeneratorType.enable,
    checkEnable: () => {
      // 存在 .prettierrc,不开启
      return !existsSync(join(api.cwd, '.prettierrc'));
    },
    disabledDescription:
      'prettier has been enabled; You can remove `.prettierrc` to run this again to re-setup.',
    fn: async () => {
      const h = new GeneratorHelper(api);


      // 在package.json中添加prettier的依赖项
      h.addDevDeps({
        prettier: '^2',
        'prettier-plugin-organize-imports': '^2',
        'prettier-plugin-packagejson': '^2',
      });


      // 2、添加 .prettierrc 和 .prettierignore
      writeFileSync(
        join(api.cwd, '.prettierrc'),
        `
{
  "printWidth": 80,
  "singleQuote": true,
  "trailingComma": "all",
  "proseWrap": "never",
  "overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
  "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}
`.trimLeft(),
      );
      logger.info('Write .prettierrc');
      writeFileSync(
        join(api.cwd, '.prettierignore'),
        `
node_modules
.umi
.umi-production
`.trimLeft(),
      );
      logger.info('Write .prettierignore');


      // 执行 npm install ,下载依赖项 
      // const spawn = require('cross-spawn');
		// spawn('npm', ['install'], { stdio: 'inherit', cwd: 'xxx' });
      h.installDeps();
    },
  });
};

还有大量命令都是在这个阶段注册进来的,正是因为注册了大量命令,我们可以直接使用

export type { UmiApiRequest, UmiApiResponse } from './features/apiRoute';
export type { IApi, IConfig, IRoute, webpack } from './types';
export default () => {
  return {
    plugins: [
      // registerMethods
      require.resolve('./registerMethods'),

      // features
      process.env.DID_YOU_KNOW !== 'none' &&
        require.resolve('@umijs/did-you-know/dist/plugin'),
      require.resolve('./features/404/404'),
      require.resolve('./features/appData/appData'),
      require.resolve('./features/check/check'),
      require.resolve('./features/clientLoader/clientLoader'),
      require.resolve('./features/codeSplitting/codeSplitting'),
      require.resolve('./features/configPlugins/configPlugins'),
      require.resolve('./features/crossorigin/crossorigin'),
      require.resolve('./features/depsOnDemand/depsOnDemand'),
      require.resolve('./features/devTool/devTool'),
      require.resolve('./features/esmi/esmi'),
      require.resolve('./features/exportStatic/exportStatic'),
      require.resolve('./features/favicons/favicons'),
      require.resolve('./features/mock/mock'),
      require.resolve('./features/mpa/mpa'),
      require.resolve('./features/overrides/overrides'),
      require.resolve('./features/polyfill/polyfill'),
      require.resolve('./features/polyfill/publicPathPolyfill'),
      require.resolve('./features/routePrefetch/routePrefetch'),
      require.resolve('./features/ssr/ssr'),
      require.resolve('./features/terminal/terminal'),
      require.resolve('./features/tmpFiles/tmpFiles'),
      require.resolve('./features/tmpFiles/configTypes'),
      require.resolve('./features/transform/transform'),
      require.resolve('./features/lowImport/lowImport'),
      require.resolve('./features/vite/vite'),
      require.resolve('./features/apiRoute/apiRoute'),
      require.resolve('./features/monorepo/redirect'),
      require.resolve('./features/test/test'),
      require.resolve('./features/clickToComponent/clickToComponent'),
      require.resolve('./features/legacy/legacy'),
      require.resolve('./features/classPropertiesLoose/classPropertiesLoose'),

      // commands
      require.resolve('./commands/build'),
      require.resolve('./commands/config/config'),
      require.resolve('./commands/dev/dev'),
      require.resolve('./commands/help'),
      require.resolve('./commands/lint'),
      require.resolve('./commands/setup'),
      require.resolve('./commands/version'),
      require.resolve('./commands/generators/page'),
      require.resolve('./commands/generators/prettier'),
      require.resolve('./commands/generators/tsconfig'),
      require.resolve('./commands/generators/jest'),
      require.resolve('./commands/generators/tailwindcss'),
      require.resolve('./commands/generators/dva'),
      require.resolve('./commands/generators/component'),
      require.resolve('./commands/generators/mock'),
      require.resolve('./commands/generators/cypress'),
      require.resolve('./commands/generators/api'),
      require.resolve('./commands/plugin'),
      require.resolve('./commands/verify-commit'),
      require.resolve('./commands/preview'),
      require.resolve('@umijs/plugin-run'),
    ].filter(Boolean),
  };
};

总结

通过前面几个章节的学习,我们发现很多事情都是围绕着packages/core/src/service/pluginAPI.ts中的方法展开的。 具体方法如下

  • describe 注册插件时的描述信息
  • registerGenerator 命令生成器,扩展命令用的,需要fn和key可以通过 umi generator key 执行注册的命令 fn
  • registerCommand 注册命令用的, umi devumi build 都是通过这个命令注册的
  • register 注册方法用的, 传入函数名和对应的fn,注册到 this.service.hooks 中, 可以通过 api.applyPlugins 调用
  • registerMethod 注册方法用,需要key,可以有函数fn可以没有fn
    • 有fn时,注册到 this.service.pluginMethods 中,可以通过 api.xxx
    • 没有fn时,会注册到 this.service.pluginMethods 中,也会注册到 this.service.hooks 中。因此可以通过 api.xxx 调用,也可以通过api.applyPlugins调用。

至此项目组架构性质的代码已经梳理的差不多了,通过这几篇文档的总结,大致能看出 umi 整体架构的实现思路以及各种方法实现思路。整个系统通过 核心的 pluginAPI 和 service 很好的关联在了一起。