umi3源码探究简析

前端 | umi3源码探究简析.png

前言

图片

近期,团(ling)队(dao)准(yao)备(qiu)从Vue技术栈转向React技术栈,而且特别指定了使用Ant Design的设计组件库,出于相关生态考虑,我们决定采用蚂蚁金服团队相关react方案。选择理由如下:一来是React原装全家桶比较散,引包组装比较麻烦;二来是国内React生态相关方面阿里开源及社区做的比较突出,因而我们决定使用阿里React相关技术栈。基于组件库及相关可视化展示等因素,我们选择了蚂蚁金服团队的开源生态圈:umi + dva + antd + antv ( ps:最佳实践案例是Ant Desgin Pro),当然淘系的飞冰相关React技术栈做的也很突出,但权衡之后,我们最终还是选择了蚂蚁金服团队的React技术栈。作为整个生态圈最为核心的部分,umi可谓是王冠上的红宝石,因而个人认为对于整个umi架构内核的学习及设计哲学的理解,可能比如何使用要来的更为重要;作为一个使用者,希望能从各位大佬的源码中汲取一些养分以及获得一些灵感,私以为:思想的拓展要远比不断地重复劳作要来的重要!

图片

umi基于的是一种微内核架构方式,其核心是只保留架构的核心功能,将其他需求服务以插件的形式载入进来,“即插即用,不用即走”,因而又称为“插件化架构”,对于想具体了解微内核的童鞋,可以看这篇文章微内核架构,总结来讲就是“如无必要,勿增实体”,只保留最精简、最核心的部分。

图片

对于umi来说,其具体是通过“约定大于配置”的核心理念,将技术收敛,让开发者更集中精力于业务的开发,对于更具体的可以看看云谦(陈成)的大佬2019 SEE Conf的分享 云谦 - 蚂蚁金服前端框架探索之路

目录结构

图片

本质上umi最后会导出一个基于EventEmitter的Service的类,用户通过Config中的配置将插件与核心Service关联,umi将react-router和react-router-dom内嵌进了框架中,从而可以“约定式定义路由”,此处可以对比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

源码解析

核心源码在于core目录下的Config、Route及Service,微内核中最最最核心的就是这个Service类,其他都是基于其进行的相关的扩展与融合,重点分析Service、Route及Config这三个目录中的源码

Service

文件名 作用 备注
Service.ts 提供整个核心服务类,用于导出服务 核心配置
getPaths.ts 获取文件绝对路径的核心方法 文件路径
PluginAPI.ts 插件的注册及接入核心类 插件注册
types.ts 固定值 接口及类型
enums.ts 固定值 枚举

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 {
  // 项目根目录
  let absSrcPath = cwd;

  // 如果存在src目录,将absSrcPath定位到src路径下
  if (isDirectoryAndExist(join(cwd, 'src'))) {
    absSrcPath = join(cwd, 'src');
  }

  // src下是page还是pages
  const absPagesPath = config.singular
    ? join(absSrcPath, 'page')
    : join(absSrcPath, 'pages');

  // 临时文件路径
  const tmpDir = ['.umi', env !== 'development' && env]
    .filter(Boolean)
    .join('-');

  // outputPath 指定输出路径
  return normalizeWithWinPath({
    cwd,
    absNodeModulesPath: join(cwd, 'node_modules'),
    absOutputPath: join(cwd, config.outputPath || './dist'),
    absSrcPath,
    absPagesPath,
    absTmpPath: join(absSrcPath, tmpDir),
  });
}

PluginAPI.ts

描述插件核心方法的类,插件的编写需借助这个api,扩展方法需要在preset-built-in的presets集合中进行扩展

export default class PluginAPI {
  // 插件的id,区别不同的插件
  id: string;
  // 插件内的不同内容,如方法及数据等
  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;
    }
  }

  // 注册预设
  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,
          });
    });
    // 插到最前面,下个 while 循环优先执行
    this.service._extraPresets.splice(0, 0, ...extraPresets);
  }

  // 在 preset 初始化阶段放后面,在插件注册阶段放前面
  registerPlugins(plugins: (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 ||
      // 这里不能用 arrow function,this 需指向执行此方法的 PluginAPI
      // 否则 pluginId 会不会,导致不能正确 skip plugin
      function (fn: Function) {
        const hook = {
          key: name,
          ...(utils.lodash.isPlainObject(fn) ? fn : { fn }),
        };
        // @ts-ignore
        this.register(hook);
      };
  }

  // 跳过插件,不执行的插件
  skipPlugins(pluginIds: string[]) {
    pluginIds.forEach((pluginId) => {
      this.service.skipPluginIds.add(pluginId);
    });
  }
}

Route

文件名 作用 备注
Route.ts 路由的核心类 封装了路由匹配等方法
routesToJSON.ts 路由转化为json的方法 用于前后端传递
getConventionalRoutes.ts 获取默认路由 前端开发时常用写的路由表

Route.ts

class Route {
  opts: IOpts;
  constructor(opts?: IOpts) {
    this.opts = opts || {};
  }

  async getRoutes(opts: IGetRoutesOpts) {
    // config 用户 + 插件配置
    // root 是 absPagesPath
    // componentPrefix是路径的分割符号,默认是 "/"
    const { config, root, componentPrefix } = opts;

    // 避免修改配置里的 routes,导致重复 patch
    let routes = lodash.cloneDeep(config.routes);
    let isConventional = false;

    // 如果用户没有自定义,则使用约定式路由;如果配置了则约定式路由无效
    if (!routes) {
      assert(root, `opts.root must be supplied for conventional routes.`);

      // 默认路由的拼接方式
      routes = this.getConventionRoutes({
        root: root!,
        config,
        componentPrefix,
      });
      isConventional = true;
    }

    // 生成的路由可以被插件新增,修改,删除
    await this.patchRoutes(routes, {
      ...opts,
      isConventional,
    });
    return routes;
  }

  // TODO:
  // 1. 移动 /404 到最后,并处理 component 和 redirect
  async patchRoutes(routes: IRoute[], opts: IGetRoutesOpts) {
    // 执行插件的 onPatchRoutesBefore 钩子函数对路由修改
    if (this.opts.onPatchRoutesBefore) {
      await this.opts.onPatchRoutesBefore({
        routes,
        parentRoute: opts.parentRoute,
      });
    }

    // routes中的route执行patrchRoute方法
    for (const route of routes) {
      await this.patchRoute(route, opts);
    }

    // onPatchRoutes进行最终的路由修改
    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 的修改需要在子路由 patch 之前做
    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 钩子函数
    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 里加无用的信息,所以必须 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,
      });
      // 解决 SSR 开启动态加载后,页面闪烁问题
      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

需要考虑多种情况,如:目录、文件、动态路由等

// 考虑多种情况:
// 可能是目录,没有后缀,比如 [post]/add.tsx
// 可能是文件,有后缀,比如 [id].tsx
// [id$] 是可选动态路由
const RE_DYNAMIC_ROUTE = /^\[(.+?)\]/;

// 获取文件,主要就是fs模块的读写问价等方法
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;
  });
}

// 文件路由的reducer方法
function fileToRouteReducer(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: 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;
}

Config

文件名 作用 备注
Config.ts 核心配置类 关联用户输入与脚手架输出的中介者
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();
    };
  }
}

总结

图片
图片

umi是蚂蚁金服前端架构的基石,其他的各种扩展应用,诸如:antd组件库、dva数据流等,都是基于umi来构建的,而Ant Design Pro算是蚂蚁金服中后台应用的一个最佳实践。umi对于自研前端生态的核心基础库有着重要的参考价值,对整个生态的支撑也起着“牵一发而动全身”的作用,如果用一句话来概括umi的核心设计理念,那就是“约定大于配置”,其他各种设计都是围绕着这一设计哲学展开的,因而对于生态的建设要想好我想给外界传递一种什么样的价值与理念,反复造轮子是没有意义的,只有真正能解决问题,好用的轮子才能走的更长远!

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容