preface

Recently, the Ling team switched from the Vue technology stack to the React technology stack, and specially designated the Design component library to use Ant Design. For related ecological considerations, we decided to adopt the React solution of Ant Financial team. The reasons for the choice are as follows: First, the React original family barrel is relatively scattered, and the assembly of the primer package is troublesome; On the other hand, in terms of domestic React ecology, Ali’s open source and community are more outstanding, so we decided to use AliReact related technology stack. Based on the component library and related visual display and other factors, we chose the open source ecosystem of Ant Financial: umi + dva + antd + antv (PS: The best practice is Ant Desgin Pro), and of course Amoy’s Flying Ice React stack does a good job, but after some consideration, we chose Ant Financial’s React stack. As the core part of the whole ecosystem, UMI is the ruby on the crown. Therefore, I think that the learning and understanding of the core of the UMI architecture and the design philosophy may be more important than how to use it. As a user, I hope to learn some nutrients and get some inspiration from the source code of the big guys. I think: the expansion of the idea is far more important than the repeated work to come!

Umi is a based on micro kernel architecture, its core is the core of the only architecture function, will serve other needs in the form of a plug-in load to come in, “plug and play, don’t walk”, therefore, also known as “plug-in architecture”, for children’s shoes, want to know the specific micro kernel can see microkernel architecture, this article summarized: “if no necessary, not by entity”, Keep only the bare essentials.

For umi, its specific is through the “convention is greater than configuration” core concept, technology convergence, so that developers more focused on the development of the business, for more specific can SEE Yunqian (Chen Cheng) big man 2019 SEE Conf share Yunqian – Ant Financial front-end framework exploration road

The directory structure

Essentially, Umi ends up exporting a Service class based on EventEmitter. Users associate the plug-in with the core Service through Config. Umi emits the React -router and React -router-DOM into the framework. This allows “routers to be defined by convention”, which can be compared to the next-.js solution

  • packages

    • ast
    • babel-plugin-auto-css-modules
    • babel-plugin-import-to-await-require
    • babel-plugin-lock-core-js-3
    • babel-preset-umi
    • bundler-utils
    • bundler-webpack
    • core

      • src

        • Config
        • Html
        • Logger
        • Route
        • Service
    • create-umi-app
    • preset-built-in
    • renderer-mpa
    • renderer-react
    • runtime
    • server
    • test
    • test-utils
    • types
    • umi
    • utils

The source code parsing

The core source code is the Config, Route and Service in the core directory. The most core of the microkernel is this Service class, and the rest are based on its related expansion and fusion. The focus of the analysis is on the source code in the Service, Route and Config directories

Service

The file name role note
Service.ts Provide the entire core service class for exporting the service The core configuration
getPaths.ts The core method to get the absolute path to a file The file path
PluginAPI.ts Plug-in registration and access to the core classes The plug-in registry
types.ts A fixed value Interfaces and Types
enums.ts A fixed value The enumeration

Service.ts

export default class Service extends EventEmitter {
  // 项目根路径
  cwd: string;
  // package.json的绝对路径
  pkg: IPackage;
  // 跳过的插件
  skipPluginIds: Set<string> = new Set<string>();
  // 生命周期执行阶段
  stage: ServiceStage = ServiceStage.uninitialized;
  // 注册命令
  commands: {
    [name: string]: ICommand | string;
  } = {};
  // 解析完的插件
  plugins: {
    [id: string]: IPlugin;
  } = {};
  // 插件方法
  pluginMethods: {
    [name: string]: Function;
  } = {};
  // 初始化插件预设
  initialPresets: IPreset[];
  initialPlugins: IPlugin[];
  // 额外的插件预设
  _extraPresets: IPreset[] = [];
  _extraPlugins: IPlugin[] = [];
  // 用户配置
  userConfig: IConfig;
  configInstance: Config;
  config: IConfig | null = null;
  // babel处理
  babelRegister: BabelRegister;
  // 钩子函数处理
  hooksByPluginId: {
    [id: string]: IHook[];
  } = {};
  hooks: {
    [key: string]: IHook[];
  } = {};
  // 用户配置生成的路径信息
  paths: {
    // 项目根目录
    cwd?: string;
    // node_modules文件目录
    absNodeModulesPath?: string;
    // src目录
    absSrcPath?: string;
    // pages目录
    absPagesPath?: string;
    // dist导出目录
    absOutputPath?: string;
    // 生成的.umi目录
    absTmpPath?: string;
  } = {};
  env: string | undefined;
  ApplyPluginsType = ApplyPluginsType;
  EnableBy = EnableBy;
  ConfigChangeType = ConfigChangeType;
  ServiceStage = ServiceStage;
  args: any;

  constructor(opts: IServiceOpts) {
    super();
    this.cwd = opts.cwd || process.cwd();
    // 仓库根目录,antd pro构建的时候需要一个新的空文件夹
    this.pkg = opts.pkg || this.resolvePackage();
    this.env = opts.env || process.env.NODE_ENV;

    // babel处理
    this.babelRegister = new BabelRegister();

    // 加载环境变量
    this.loadEnv();

    // 获取用户配置
    this.configInstance = new Config({
      cwd: this.cwd,
      service: this,
      localConfig: this.env === 'development',
    });

    // 从.umirc.ts中获取内容
    this.userConfig = this.configInstance.getUserConfig();

    // 获取导出的配置
    this.paths = getPaths({
      cwd: this.cwd,
      config: this.userConfig!,
      env: this.env,
    });

    // 初始化插件
    const baseOpts = {
      pkg: this.pkg,
      cwd: this.cwd,
    };

    // 初始化预设
    this.initialPresets = resolvePresets({
      ...baseOpts,
      presets: opts.presets || [],
      userConfigPresets: this.userConfig.presets || [],
    });

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

    // 初始化配置及插件放入babel注册中
    this.babelRegister.setOnlyMap({
      key: 'initialPlugins',
      value: lodash.uniq([
        ...this.initialPresets.map(({ path }) => path),
        ...this.initialPlugins.map(({ path }) => path),
      ]),
    });
  }
  // 设置生命周期
  setStage(stage: ServiceStage) {
    this.stage = stage;
  }
  // 解析package.json的文件
  resolvePackage() {
    try {
      return require(join(this.cwd, 'package.json'));
    } catch (e) {
      return {};
    }
  }
  // 加载环境
  loadEnv() {
    const basePath = join(this.cwd, '.env');
    const localPath = `${basePath}.local`;
    loadDotEnv(basePath);
    loadDotEnv(localPath);
  }

  // 真正的初始化
  async init() {
    this.setStage(ServiceStage.init);
    await this.initPresetsAndPlugins();

    // 状态:初始
    this.setStage(ServiceStage.initHooks);

    // 注册了plugin要执行的钩子方法
    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);
      });
    });

    // 状态:插件已注册
    this.setStage(ServiceStage.pluginReady);
    // 执行插件
    await this.applyPlugins({
      key: 'onPluginReady',
      type: ApplyPluginsType.event,
    });

    // 状态:获取配置信息
    this.setStage(ServiceStage.getConfig);

    // 拿到对应插件的默认配置信息
    const defaultConfig = await this.applyPlugins({
      key: 'modifyDefaultConfig',
      type: this.ApplyPluginsType.modify,
      initialValue: await this.configInstance.getDefaultConfig(),
    });

    // 将实例中的配置信息对应修改的配置信息
    this.config = await this.applyPlugins({
      key: 'modifyConfig',
      type: this.ApplyPluginsType.modify,
      initialValue: this.configInstance.getConfig({
        defaultConfig,
      }) as any,
    });

    // 状态:合并路径
    this.setStage(ServiceStage.getPaths);
    
    if (this.config!.outputPath) {
      this.paths.absOutputPath = join(this.cwd, this.config!.outputPath);
    }

    // 修改路径对象
    const paths = (await this.applyPlugins({
      key: 'modifyPaths',
      type: ApplyPluginsType.modify,
      initialValue: this.paths,
    })) as object;
    Object.keys(paths).forEach((key) => {
      this.paths[key] = paths[key];
    });
  }

  
  async initPresetsAndPlugins() {
    this.setStage(ServiceStage.initPresets);
    this._extraPlugins = [];
    while (this.initialPresets.length) {
      await this.initPreset(this.initialPresets.shift()!);
    }

    this.setStage(ServiceStage.initPlugins);
    this._extraPlugins.push(...this.initialPlugins);
    while (this._extraPlugins.length) {
      await this.initPlugin(this._extraPlugins.shift()!);
    }
  }

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

    [
      '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];
      },
    });
  }

  async applyAPI(opts: { apply: Function; api: PluginAPI }) {
    let ret = opts.apply()(opts.api);
    if (isPromise(ret)) {
      ret = await ret;
    }
    return ret || {};
  }

  // 初始化配置
  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);
    // TODO: ...defaultConfigs 考虑要不要支持,可能这个需求可以通过其他渠道实现
    const { presets, plugins, ...defaultConfigs } = await this.applyAPI({
      api,
      apply,
    });

    // register extra presets and plugins
    if (presets) {
      assert(
        Array.isArray(presets),
        `presets returned from preset ${id} must be Array.`,
      );
      // 插到最前面,下个 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()!);
    }

    if (plugins) {
      assert(
        Array.isArray(plugins),
        `plugins returned from preset ${id} must be Array.`,
      );
      this._extraPlugins.push(
        ...plugins.map((path: string) => {
          return pathToObj({
            type: PluginType.plugin,
            path,
            cwd: this.cwd,
          });
        }),
      );
    }
  }

  // 初始化插件
  async initPlugin(plugin: IPlugin) {
    const { id, key, apply } = plugin;

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

    // register before apply
    this.registerPlugin(plugin);
    await this.applyAPI({ api, apply });
  }

  getPluginOptsWithKey(key: string) {
    return getUserConfigWithKey({
      key,
      userConfig: this.userConfig,
    });
  }

  // 注册插件
  registerPlugin(plugin: IPlugin) {
    // 考虑要不要去掉这里的校验逻辑
    // 理论上不会走到这里,因为在 describe 的时候已经做了冲突校验
    if (this.plugins[plugin.id]) {
      const name = plugin.isPreset ? 'preset' : 'plugin';
      throw new Error(`\
${name} ${plugin.id} is already registered by ${this.plugins[plugin.id].path}, \
${name} from ${plugin.path} register failed.`);
    }
    this.plugins[plugin.id] = plugin;
  }

  isPluginEnable(pluginId: string) {
    // api.skipPlugins() 的插件
    if (this.skipPluginIds.has(pluginId)) return false;

    const { key, enableBy } = this.plugins[pluginId];

    // 手动设置为 false
    if (this.userConfig[key] === false) return false;

    // 配置开启
    if (enableBy === this.EnableBy.config && !(key in this.userConfig)) {
      return false;
    }

    // 函数自定义开启
    if (typeof enableBy === 'function') {
      return enableBy();
    }

    // 注册开启
    return true;
  }

  // 判断函数:是否有插件
  hasPlugins(pluginIds: string[]) {
    return pluginIds.every((pluginId) => {
      const plugin = this.plugins[pluginId];
      return plugin && !plugin.isPreset && this.isPluginEnable(pluginId);
    });
  }

  // 判断函数:是否有预设
  hasPresets(presetIds: string[]) {
    return presetIds.every((presetId) => {
      const preset = this.plugins[presetId];
      return preset && preset.isPreset && this.isPluginEnable(presetId);
    });
  }

  // 真正的插件执行函数,基于promise实现
  async applyPlugins(opts: {
    key: string;
    type: ApplyPluginsType;
    initialValue?: any;
    args?: any;
  }) {
    const hooks = this.hooks[opts.key] || [];
    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.`,
          );
        }
        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}.`,
        );
    }
  }

  // 运行方法
  async run({ name, args = {} }: { name: string; args?: any }) {
    args._ = args._ || [];
    if (args._[0] === name) args._.shift();

    this.args = args;
    await this.init();

    this.setStage(ServiceStage.run);
    await this.applyPlugins({
      key: 'onStart',
      type: ApplyPluginsType.event,
      args: {
        args,
      },
    });
    return this.runCommand({ name, args });
  }
  
  // 运行命令
  async runCommand({ name, args = {} }: { name: string; args?: any }) {
    assert(this.stage >= ServiceStage.init, `service is not initialized.`);

    args._ = args._ || [];
    if (args._[0] === name) args._.shift();

    const command =
      typeof this.commands[name] === 'string'
        ? this.commands[this.commands[name] as string]
        : this.commands[name];
    assert(command, `run command failed, command ${name} does not exists.`);

    const { fn } = command as ICommand;
    return fn({ args });
  }
}

getPaths.ts

export default function getServicePaths({ cwd, config, env, }: { cwd: string; config: any; env? : string; }): IServicepaths {// project root let absSrcPath = CWD; // if (isDirectoryAndExist(join(CWD, 'SRC ')) {absSrcPath = join(CWD,' SRC '); } // SRC is it page or pages const absPagesPath = config.singularity? join(absSrcPath, 'page') : join(absSrcPath, 'pages'); // temp file path const tmpDir = ['.umi', env!== 'development' &&env].filter(Boolean).join('-'); Return normalizeWithWinPath({CWD, absnodeModulesPath: join(CWD, 'node_modules'), absoutputPath: join(cwd, config.outputPath || './dist'), absSrcPath, absPagesPath, absTmpPath: join(absSrcPath, tmpDir), }); }

PluginAPI.ts

A class that describes the core methods of the plug-in that are written with this API, and the extension methods that are extended in the preset-built presets collection

Export Default class PluginAi {// Plugin ID: String; // Plug-ins with different contents, such as methods and data; service: Service; Html: typeof Html; utils: typeof utils; logger: Logger; constructor(opts: IOpts) { this.id = opts.id; this.key = opts.key; this.service = opts.service; this.utils = utils; this.Html = Html; this.logger = new Logger(`umi:plugin:${this.id || this.key}`); } // TODO: reversed keys describe({ id, key, config, enableBy, }: { id? : string; key? : string; config? : IPluginConfig; enableBy? : EnableBy | (() => boolean); } = {}) { const { plugins } = this.service; // this.id and this.key is generated automatically // so we need to diff first if (id && this.id ! == id) { if (plugins[id]) { const name = plugins[id].isPreset ? 'preset' : 'plugin'; throw new Error( `api.describe() failed, ${name} ${id} is already registered by ${plugins[id].path}.`, ); } plugins[id] = plugins[this.id]; plugins[id].id = id; delete plugins[this.id]; this.id = id; } if (key && this.key ! == key) { this.key = key; plugins[this.id].key = key; } if (config) { plugins[this.id].config = config; } plugins[this.id].enableBy = enableBy || EnableBy.register; } // Register the plugin (Hook: IHook) { assert( hook.key && typeof hook.key === 'string', `api.register() failed, hook.key must supplied and should be string, but got ${hook.key}.`, ); assert( hook.fn && typeof hook.fn === 'function', `api.register() failed, hook.fn must supplied and should be function, but got ${hook.fn}.`, ); this.service.hooksByPluginId[this.id] = ( this.service.hooksByPluginId[this.id] || [] ).concat(hook); } // RegisterCommand (command: ICOMmand) {const {name, alias} = command; assert( ! this.service.commands[name], `api.registerCommand() failed, the command ${name} is exists.`, ); this.service.commands[name] = command; if (alias) { this.service.commands[alias] = name; }} // RegisterPresets (presets: (IPreset | string)[]) { assert( this.service.stage === ServiceStage.initPresets, `api.registerPresets() failed, it should only used in presets.`, ); assert( Array.isArray(presets), `api.registerPresets() failed, presets must be Array.`, ); const extraPresets = presets.map((preset) => { return isValidPlugin(preset as any) ? (preset as IPreset) : pathToObj({ type: PluginType.preset, path: preset as string, cwd: this.service.cwd, }); }); Splice (0, 0, 0, 0, 0, 0, 0, 0, 0... extraPresets); } // RegisterPlugins are placed after the preset initialization phase and before the plugins registration phase. (IPlugin | string)[]) { assert( this.service.stage === ServiceStage.initPresets || this.service.stage === ServiceStage.initPlugins, `api.registerPlugins() failed, it should only be used in registering stage.`, ); assert( Array.isArray(plugins), `api.registerPlugins() failed, plugins must be Array.`, ); const extraPlugins = plugins.map((plugin) => { return isValidPlugin(plugin as any) ? (plugin as IPreset) : pathToObj({ type: PluginType.plugin, path: plugin as string, cwd: this.service.cwd, }); }); if (this.service.stage === ServiceStage.initPresets) { this.service._extraPlugins.push(... extraPlugins); } else { this.service._extraPlugins.splice(0, 0, ... extraPlugins); }} // RegisterMethod ({name, fn, ExitError = 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; }} this. Service. PluginMethods [name] = fn | | / / here can't use arrow function, this need to implement this method PluginAPI / / or pluginId will not, Plugin (fn: function) {const hook = {key: name,... (utils.lodash.isPlainObject(fn) ? fn : { fn }), }; // @ts-ignore this.register(hook); }; } // Skip the plugins and do not execute plugins (pluginIds: string[]) { pluginIds.forEach((pluginId) => { this.service.skipPluginIds.add(pluginId); }); }}

Route

The file name role note
Route.ts The core class of routing Methods such as route matching are encapsulated
routesToJSON.ts Routes to JSON methods Used for front and back end transfer
getConventionalRoutes.ts Get the default route Front-end development often uses written routing tables

Route.ts

class Route { opts: IOpts; constructor(opts? : IOpts) { this.opts = opts || {}; } async getRoutes(opts: IgetRoutesOpts) {// Configure user + plugin configuration // root is abspagesPath // ComponentPrefix is the path splitter symbol, Default is "/" const {config, root, componentPrefix} = opts; // Routes let routes = lodash.clonedeep (config.routes); Routes let routes = lodash.clonedeep (config.routes); let isConventional = false; // If the user does not customize the route, use the convention route; If configured, the contract route is invalid if (! routes) { assert(root, `opts.root must be supplied for conventional routes.`); Routes = this.getConventionRoutes({root: root! , config, componentPrefix, }); isConventional = true; } // Generated routes can be added, modified, deleted by plugin await this.patchRoutes(routes, {... opts, isConventional, }); return routes; } // Todo: // 1. Move /404 to last, and handle Component and Redirect async patchRoutes(Routes: Iroute [], opts: Routes: IGetRoutesOpts) {/ / plug-in onPatchRoutesBefore right hook function by modifying the if (this. Opts. OnPatchRoutesBefore) {await this.opts.onPatchRoutesBefore({ routes, parentRoute: opts.parentRoute, }); } // Route in Routes execute patrChroute for (const route of routes) {await this. patChroute (route, opts); } // OnpatChroutes to take final route change if (this.opts. OnpatChroutes) {await this.opts. OnpatChroutes ({Routes, parentRoute: opts.parentRoute, }); } } async patchRoute(route: IRoute, opts: IGetRoutesOpts) { if (this.opts.onPatchRouteBefore) { await this.opts.onPatchRouteBefore({ route, parentRoute: opts.parentRoute, }); } // route.path: if (route.path && route.path.charat (0)! = = '/' &&! /^https? :\/\//.test(route.path) ) { route.path = winPath(join(opts.parentRoute? .path || '/', route.path)); } if (route.redirect && route.redirect.charAt(0) ! == '/') { route.redirect = winPath( join(opts.parentRoute? .path || '/', route.redirect), ); } // Recursion patchRoutes if (route.routes) {await this.patchroutes (route.routes, {... opts, parentRoute: route, }); } else { if (! ('exact' in route)) { // exact by default route.exact = true; } } // resolve component path if ( route.component && ! opts.isConventional && typeof route.component === 'string' && ! route.component.startsWith('@/') && ! path.isAbsolute(route.component) ) { route.component = winPath(join(opts.root, route.component)); } // resolve wrappers path if (route.wrappers) { route.wrappers = route.wrappers.map((wrapper) => { if (wrapper.startsWith('@/') || path.isAbsolute(wrapper)) { return wrapper; } else { return winPath(join(opts.root, wrapper)); }}); } // OnpatChroute hook function if (this.opts. OnpatChroute) {await this.opts. OnpatChroute ({route, parentRoute: opts.parentRoute, }); }} // getConventionRoutes(Opts: Any): IROUTE [] {return getConventionAlRoutes (Opts); } getJSON(opts: { routes: IRoute[]; config: IConfig; cwd: string }) { return routesToJSON(opts); } getPaths({ routes }: { routes: IRoute[] }): string[] { return lodash.uniq( routes.reduce((memo: string[], route) => { if (route.path) memo.push(route.path); if (route.routes) memo = memo.concat(this.getPaths({ routes: route.routes })); return memo; }, [])); }}

routesToJSON.ts

Json.stringify () export default function ({routes, config, CWD}: IOpts) {// Routes are routes, so we have to deep clone them to avoid const clonedRoutes = lodash.clonedeep (routes); if (config.dynamicImport) { patchRoutes(clonedRoutes); } function patchRoutes(routes: IRoute[]) { routes.forEach(patchRoute); } function patchRoute(route: IRoute) { if (route.component && ! isFunctionComponent(route.component)) { const webpackChunkName = routeToChunkName({ route, cwd, }); If (config?) if (config?) if (config?) .ssr && config? .dynamicImport) { route._chunkName = webpackChunkName; } route.component = [ route.component, webpackChunkName, route.path || EMPTY_PATH, ].join(SEPARATOR); } if (route.routes) { patchRoutes(route.routes); } } function isFunctionComponent(component: string) { return ( /^\((.+)? \)(\s+)? =>/.test(component) || /^function([^\(]+)? ((\ [^ \] +)? \] ([^ {] +)? {/.test(component) ); } function replacer(key: string, value: any) { switch (key) { case 'component': if (isFunctionComponent(value)) return value; if (config.dynamicImport) { const [component, webpackChunkName] = value.split(SEPARATOR); let loading = ''; if (config.dynamicImport.loading) { loading = `, loading: LoadingComponent`; } return `dynamic({ loader: () => import(/* webpackChunkName: '${webpackChunkName}' */'${component}')${loading}})`; } else { return `require('${value}').default`; } case 'wrappers': const wrappers = value.map((wrapper: string) => { if (config.dynamicImport) { let loading = ''; if (config.dynamicImport.loading) { loading = `, loading: LoadingComponent`; } return `dynamic({ loader: () => import(/* webpackChunkName: 'wrappers' */'${wrapper}')${loading}})`; } else { return `require('${wrapper}').default`; }}); return `[${wrappers.join(', ')}]`; default: return value; } } return JSON.stringify(clonedRoutes, replacer, 2) .replace(/\"component\": (\"(.+?) \")/g, (global, m1, m2) => { return `"component": ${m2.replace(/\^/g, '"')}`; }) .replace(/\"wrappers\": (\"(.+?) \")/g, (global, m1, m2) => { return `"wrappers": ${m2.replace(/\^/g, '"')}`; }) .replace(/\\r\\n/g, '\r\n') .replace(/\\n/g, '\r\n'); }

getConventionalRoutes.ts

There are many cases to consider, such as directories, files, dynamic routing, and so on

// Consider multiple scenarios: [id].tsx // [id] $= const RE_DYNAMIC_ROUTE = /^\[(.+?)\]/; [id] $= const re_dynamic route = /^\[(.+?)\]/; Function getFiles(root: string) {if (! existsSync(root)) return []; return readdirSync(root).filter((file) => { const absFile = join(root, file); const fileStat = statSync(absFile); const isDirectory = fileStat.isDirectory(); const isFile = fileStat.isFile(); if ( isDirectory && ['components', 'component', 'utils', 'util'].includes(file) ) { return false; } if (file.charAt(0) === '.') return false; if (file.charAt(0) === '_') return false; // exclude test file if (/\.(test|spec|e2e)\.(j|t)sx? $/.test(file)) return false; // d.ts if (/\.d\.ts$/.test(file)) return false; if (isFile) { if (! /\.(j|t)sx? $/.test(file)) return false; const content = readFileSync(absFile, 'utf-8'); try { if (! isReactComponent(content)) return false; } catch (e) { throw new Error( `Parse conventional route component ${absFile} failed, ${e.message}`, ); } } return true; }); } function FileToruterEduReducer (opts: IOpts, memo: iRoute [], file: string) { const { root, relDir = '' } = opts; const absFile = join(root, relDir, file); const stats = statSync(absFile); const __isDynamic = RE_DYNAMIC_ROUTE.test(file); if (stats.isDirectory()) { const relFile = join(relDir, file); const layoutFile = getFile({ base: join(root, relFile), fileNameWithoutExt: '_layout', type: 'javascript', }); const route = { path: normalizePath(relFile, opts), routes: getRoutes({ ... opts, relDir: join(relFile), }), __isDynamic, ... (layoutFile ? { component: layoutFile.path, } : { exact: true, __toMerge: true, }), }; memo.push(normalizeRoute(route, opts)); } else { const bName = basename(file, extname(file)); memo.push( normalizeRoute( { path: normalizePath(join(relDir, bName), opts), exact: true, component: absFile, __isDynamic, }, opts, ), ); } return memo; } function normalizeRoute(route: Iroute, opts: Iopts) {let props: unknown = undefined; if (route.component) { try { props = getExportProps(readFileSync(route.component, 'utf-8')); } catch (e) { throw new Error( `Parse conventional route component ${route.component} failed, ${e.message}`, ); } route.component = winPath(relative(join(opts.root, '.. '), route.component)); route.component = `${opts.componentPrefix || '@/'}${route.component}`; } return { ... route, ... (typeof props === 'object' ? props : {}), }; } function normalizePath(path: string, opts: normalizePath) IOpts) { path = winPath(path) .split('/') .map((p) => { // dynamic route p = p.replace(RE_DYNAMIC_ROUTE, ':$1'); // :post$ => :post? if (p.endsWith('$')) { p = p.slice(0, -1) + '? '; } return p; }) .join('/'); path = `/${path}`; // /index/index -> / if (path === '/index/index') { path = '/'; } // /xxxx/index -> /xxxx/ path = path.replace(/\/index$/, '/'); // remove the last slash // e.g. /abc/ -> /abc if (path ! == '/' && path.slice(-1) === '/') { path = path.slice(0, -1); } return path; } // normalizerOutes (Routes: IROUTE []): IROUTE [] {const ParamsRoutes: IROUTE [] = []; const exactRoutes: IRoute[] = []; const layoutRoutes: IRoute[] = []; routes.forEach((route) => { const { __isDynamic, exact } = route; delete route.__isDynamic; if (__isDynamic) { paramsRoutes.push(route); } else if (exact) { exactRoutes.push(route); } else { layoutRoutes.push(route); }}); assert( paramsRoutes.length <= 1, `We should not have multiple dynamic routes under a directory.`, ); return [...exactRoutes, ...layoutRoutes, ...paramsRoutes].reduce( (memo, route) => { if (route.__toMerge && route.routes) { memo = memo.concat(route.routes); } else { memo.push(route); } return memo; }, [] as IRoute[], ); } // Export Default Function getRoutes(opts: IOpts) {const {root, relDir = ", config} = opts; const files = getFiles(join(root, relDir)); const routes = normalizeRoutes( files.reduce(fileToRouteReducer.bind(null, opts), []), ); if (! relDir) { const globalLayoutFile = getFile({ base: root, fileNameWithoutExt: `.. /${config.singular ? 'layout' : 'layouts'}/index`, type: 'javascript', }); if (globalLayoutFile) { return [ normalizeRoute( { path: '/', component: globalLayoutFile.path, routes, }, opts, ), ]; } } return routes; }

Config

The file name role note
Config.ts Core Configuration Classes An intermediary that associates user input with scaffold output
export default class Config {
  cwd: string;
  service: Service;
  config?: object;
  localConfig?: boolean;
  configFile?: string | null;

  constructor(opts: IOpts) {
    this.cwd = opts.cwd || process.cwd();
    this.service = opts.service;
    this.localConfig = opts.localConfig;
  }

  // 获取默认配置
  async getDefaultConfig() {
    const pluginIds = Object.keys(this.service.plugins);

    // collect default config
    let defaultConfig = pluginIds.reduce((memo, pluginId) => {
      const { key, config = {} } = this.service.plugins[pluginId];
      if ('default' in config) memo[key] = config.default;
      return memo;
    }, {});

    return defaultConfig;
  }

  // 获取配置的方法
  getConfig({ defaultConfig }: { defaultConfig: object }) {
    assert(
      this.service.stage >= ServiceStage.pluginReady,
      `Config.getConfig() failed, it should not be executed before plugin is ready.`,
    );

    const userConfig = this.getUserConfig();
    // 用于提示用户哪些 key 是未定义的
    // TODO: 考虑不排除 false 的 key
    const userConfigKeys = Object.keys(userConfig).filter((key) => {
      return userConfig[key] !== false;
    });

    // get config
    const pluginIds = Object.keys(this.service.plugins);
    pluginIds.forEach((pluginId) => {
      const { key, config = {} } = this.service.plugins[pluginId];
      // recognize as key if have schema config
      if (!config.schema) return;

      const value = getUserConfigWithKey({ key, userConfig });
      // 不校验 false 的值,此时已禁用插件
      if (value === false) return;

      // do validate
      const schema = config.schema(joi);
      assert(
        joi.isSchema(schema),
        `schema return from plugin ${pluginId} is not valid schema.`,
      );
      const { error } = schema.validate(value);
      if (error) {
        const e = new Error(
          `Validate config "${key}" failed, ${error.message}`,
        );
        e.stack = error.stack;
        throw e;
      }

      // remove key
      const index = userConfigKeys.indexOf(key.split('.')[0]);
      if (index !== -1) {
        userConfigKeys.splice(index, 1);
      }

      // update userConfig with defaultConfig
      if (key in defaultConfig) {
        const newValue = mergeDefault({
          defaultConfig: defaultConfig[key],
          config: value,
        });
        updateUserConfigWithKey({
          key,
          value: newValue,
          userConfig,
        });
      }
    });

    if (userConfigKeys.length) {
      const keys = userConfigKeys.length > 1 ? 'keys' : 'key';
      throw new Error(`Invalid config ${keys}: ${userConfigKeys.join(', ')}`);
    }

    return userConfig;
  }

  // 获取用户配置
  getUserConfig() {
    const configFile = this.getConfigFile();
    this.configFile = configFile;
    // 潜在问题:
    // .local 和 .env 的配置必须有 configFile 才有效
    if (configFile) {
      let envConfigFile;
      if (process.env.UMI_ENV) {
        const envConfigFileName = this.addAffix(
          configFile,
          process.env.UMI_ENV,
        );
        const fileNameWithoutExt = envConfigFileName.replace(
          extname(envConfigFileName),
          '',
        );
        envConfigFile = getFile({
          base: this.cwd,
          fileNameWithoutExt,
          type: 'javascript',
        })?.filename;
        if (!envConfigFile) {
          throw new Error(
            `get user config failed, ${envConfigFile} does not exist, but process.env.UMI_ENV is set to ${process.env.UMI_ENV}.`,
          );
        }
      }
      const files = [
        configFile,
        envConfigFile,
        this.localConfig && this.addAffix(configFile, 'local'),
      ]
        .filter((f): f is string => !!f)
        .map((f) => join(this.cwd, f))
        .filter((f) => existsSync(f));

      // clear require cache and set babel register
      const requireDeps = files.reduce((memo: string[], file) => {
        memo = memo.concat(parseRequireDeps(file));
        return memo;
      }, []);
      requireDeps.forEach(cleanRequireCache);
      this.service.babelRegister.setOnlyMap({
        key: 'config',
        value: requireDeps,
      });

      // require config and merge
      return this.mergeConfig(...this.requireConfigs(files));
    } else {
      return {};
    }
  }

  addAffix(file: string, affix: string) {
    const ext = extname(file);
    return file.replace(new RegExp(`${ext}$`), `.${affix}${ext}`);
  }

  requireConfigs(configFiles: string[]) {
    return configFiles.map((f) => compatESModuleRequire(require(f)));
  }

  mergeConfig(...configs: object[]) {
    let ret = {};
    for (const config of configs) {
      // TODO: 精细化处理,比如处理 dotted config key
      ret = deepmerge(ret, config);
    }
    return ret;
  }

  getConfigFile(): string | null {
    // TODO: support custom config file
    const configFile = CONFIG_FILES.find((f) => existsSync(join(this.cwd, f)));
    return configFile ? winPath(configFile) : null;
  }

  getWatchFilesAndDirectories() {
    const umiEnv = process.env.UMI_ENV;
    const configFiles = lodash.clone(CONFIG_FILES);
    CONFIG_FILES.forEach((f) => {
      if (this.localConfig) configFiles.push(this.addAffix(f, 'local'));
      if (umiEnv) configFiles.push(this.addAffix(f, umiEnv));
    });

    const configDir = winPath(join(this.cwd, 'config'));

    const files = configFiles
      .reduce<string[]>((memo, f) => {
        const file = winPath(join(this.cwd, f));
        if (existsSync(file)) {
          memo = memo.concat(parseRequireDeps(file));
        } else {
          memo.push(file);
        }
        return memo;
      }, [])
      .filter((f) => !f.startsWith(configDir));

    return [configDir].concat(files);
  }

  // 发布订阅,监听用户配置的修改
  watch(opts: {
    userConfig: object;
    onChange: (args: {
      userConfig: any;
      pluginChanged: IChanged[];
      valueChanged: IChanged[];
    }) => void;
  }) {
    let paths = this.getWatchFilesAndDirectories();
    let userConfig = opts.userConfig;
    const watcher = chokidar.watch(paths, {
      ignoreInitial: true,
      cwd: this.cwd,
    });
    watcher.on('all', (event, path) => {
      console.log(chalk.green(`[${event}] ${path}`));
      const newPaths = this.getWatchFilesAndDirectories();
      const diffs = lodash.difference(newPaths, paths);
      if (diffs.length) {
        watcher.add(diffs);
        paths = paths.concat(diffs);
      }

      const newUserConfig = this.getUserConfig();
      const pluginChanged: IChanged[] = [];
      const valueChanged: IChanged[] = [];
      Object.keys(this.service.plugins).forEach((pluginId) => {
        const { key, config = {} } = this.service.plugins[pluginId];
        // recognize as key if have schema config
        if (!config.schema) return;
        if (!isEqual(newUserConfig[key], userConfig[key])) {
          const changed = {
            key,
            pluginId: pluginId,
          };
          if (newUserConfig[key] === false || userConfig[key] === false) {
            pluginChanged.push(changed);
          } else {
            valueChanged.push(changed);
          }
        }
      });
      debug(`newUserConfig: ${JSON.stringify(newUserConfig)}`);
      debug(`oldUserConfig: ${JSON.stringify(userConfig)}`);
      debug(`pluginChanged: ${JSON.stringify(pluginChanged)}`);
      debug(`valueChanged: ${JSON.stringify(valueChanged)}`);

      if (pluginChanged.length || valueChanged.length) {
        opts.onChange({
          userConfig: newUserConfig,
          pluginChanged,
          valueChanged,
        });
      }
      userConfig = newUserConfig;
    });

    return () => {
      watcher.close();
    };
  }
}

conclusion

Umi is the cornerstone of Ant Financial’s front-end architecture. Extensible applications such as ANTD component libraries and DVA data streams are built on Umi. Ant Design Pro is a best practice for back-end applications in Ant Financial. UMI has an important reference value for the core basic library of self-research front-end ecology, and it also plays a role of “making a move and moving the whole body” for the support of the whole ecology. If I can summarize the core design philosophy of UMI in a sentence, it is “convention is greater than configuration”, and all other designs are carried out around this design philosophy. Therefore, for ecological construction, we should think well about what kind of values and concepts we want to convey to the outside world. It is meaningless to repeatedly build wheels. Only the wheels that can really solve the problem can go a long way!

reference

  • UMI Official Warehouse
  • Microkernel architecture
  • Development scheme based on Umi
  • What Xiang: umi source column