preface

Recently, DAO (LING) yao (Qiu) switched from Vue technology stack to React technology stack, and the Design component library using Ant Design was specially specified. For relevant ecological consideration, we decided to adopt the React solution of Ant Financial team. The reasons for choosing are as follows: First, React original family bucket is scattered, leading package assembly is more troublesome; Secondly, the development of Ali open source and the community related to React ecology in China is quite prominent, so we decided to use Ali React related technology stack. Based on factors such as component library and relevant visual display, we chose the open source ecosystem of Ant Financial team: UMI + DVA + ANTD + ANTV (PS: The best practice case is Ant Desgin Pro), of course, Tao’s React technology stack related to flying ice is also outstanding, but after weighing up, we finally chose Ant Financial’s React technology stack. As the core part of the whole ecosystem, UMI is the ruby in the crown. Therefore, I think the understanding of the whole UMI architecture core and design philosophy may be more important than how to use it. As a user, I hope to draw some nutrients and get some inspiration from you big guy’s source code. I think: the expansion of thought is far more important than constantly repeating work!

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 leanest, most central parts.

For UMI, it is specifically through the core concept of “convention is greater than configuration”, the technology convergence, so that developers focus more on business development, for more specific can SEE the cloud Qian (Chen Cheng) 2019 SEE Conf share cloud Qian – Ant Financial front-end framework to explore the road

The directory structure

In essence, UMI will finally export a class based on EventEmitter Service. Users will associate the plug-in with the core Service through configuration in Config. Umi will embed the react-Router and react-router-DOM into the framework. In this way, you can “reduce the form to define the route”. Here you can compare the scheme of Next. Js

  • 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

Core source code lies in the core directory of Config, Route and Service, the most core of the microkernel is the Service class, the other are based on its related extension and fusion, focus on the analysis of Service, Route and Config in the three directories of the source

Service

The file name role note
Service.ts Provide an entire core service class for exporting services The core configuration
getPaths.ts Core method to get the absolute path to a file The file path
PluginAPI.ts Plug-in registration and access 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 });
  }
}
Copy the code

getPaths.ts

export default function getServicePaths({ cwd, config, env, }: { cwd: string; config: any; env? : string; }): IServicePaths {// Project root directory let absSrcPath = CWD; If (isDirectoryAndExist(join(CWD, 'SRC '))) {absSrcPath = join(CWD,' SRC '); if (isDirectoryAndExist(join(CWD, 'SRC ')) { } // SRC const absPagesPath = config.singular? join(absSrcPath, 'page') : join(absSrcPath, 'pages'); // Temporary 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), }); }Copy the code

PluginAPI.ts

Classes that describe the plug-in’s core methods. The plug-in is written using this API, and the extension method needs to be extended in the presets set of preset-built-in

Export default class PluginAPI {// PluginAPI id: string; // Different contents in the plug-in, such as methods and data etc. Key: string; 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(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; }} // Register the default registerPresets. (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, }); }); _extraPresets. Splice (0, 0,... extraPresets); } // Preset is placed behind the initialization stage and ahead of the plugin registration stage. (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, exitsError = 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, Skip plugin function (fn: function) {const hook = {key: name,... (utils.lodash.isPlainObject(fn) ? fn : { fn }), }; // @ts-ignore this.register(hook); }; } // skipPlugins, do not execute plugins skipPlugins(pluginIds: string[]) { pluginIds.forEach((pluginId) => { this.service.skipPluginIds.add(pluginId); }); }}Copy the code

Route

The file name role note
Route.ts Core classes for routing Encapsulate route matching methods
routesToJSON.ts Route to JSON method Used for back – to – back delivery
getConventionalRoutes.ts Obtaining 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) {// config user + plugin configuration // root is abespath // componentPrefix is a path partition symbol, Default is "/" const {config, root, componentPrefix} = opts; Patch let routes = lodash.clonedeep (config.routes); let isConventional = false; // If the user does not customize the route, the reduced-form route is used. If yes, the reduced-form route is invalid. routes) { assert(root, `opts.root must be supplied for conventional routes.`); // Routes = this.getConventionRoutes({root: root! , config, componentPrefix, }); isConventional = true; } // The generated route can be added, modified, removed await this.patchRoutes(routes, {... opts, isConventional, }); return routes; } // TODO: // 1. Move /404 to the end and handle Component and redirect async patchRoutes(routes: IRoute[], opts: 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 executes patrchRoute for (const route of routes) {await this. PatchRoute (route, opts); 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, }); } 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), ); } // 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; }, [])); }}Copy the code

routesToJSON.ts

Json.stringify () export default function ({routes, config, CWD}: IOpts) {// Deep clone 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? .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'); }Copy the code

getConventionalRoutes.ts

You need to consider multiple scenarios, such as directories, files, and dynamic routing

// Consider multiple cases: TSX // [id].tsx // [id$] is an optional dynamic route 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 fileToRouteReducer(opts: IOpts, Memo: IRoute[], file: reducer) 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: 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; Function 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; }Copy the code

Config

The file name role note
Config.ts Core configuration class The mediator 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();
    };
  }
}
Copy the code

conclusion

Umi is the cornerstone of Ant Financial’s front-end architecture. Other extended applications, such as ANTD component library and DVA data stream, are all built based on UMI. Ant Design Pro is a best practice of Ant Financial’s back-end applications. Umi has an important reference value for the core base base of self-developed front-end ecology, and also plays a role in supporting the whole ecology. If one sentence can be used to summarize umi’s core design concept, it is “convention is greater than configuration”. Other designs are carried out around this design philosophy. Therefore, for ecological construction, if I want to think about what kind of value and concept I want to convey to the outside world, it is meaningless to repeatedly build wheels. Only real problems can be solved, and good wheels can go further!

reference

  • Umi official Warehouse
  • Microkernel architecture
  • Umi based development scheme
  • What cheung: UMI source column