umi3源码解析之核心Service类初始化
前言
umi 是一个插件化的企业级前端应用框架,在开发中后台项目中应用颇广,确实带来了许多便利。借着这个契机,便有了我们接下来的“umi3源码解析”系列的分享,初衷很简单就是从源码层面上帮助大家深入认知 umi 这个框架,能够更得心应手的使用它,学习源码中的设计思想提升自身。该系列的大纲如下:

开辟鸿蒙,今天要解析的就是第一part,内容包括以下两个部分:
- 邂逅 
umi命令,看看umi dev时都做了什么? - 初遇插件化,了解源码中核心的 
Service类初始化的过程。 
本次使用源码版本为  3.5.21 ,地址放在这里了
,接下来的每一块代码笔者都贴心的为大家注释了在源码中的位置,先 clone 再食用更香哟!
邂逅 umi 命令
该部分在源码中的路径为:packages/umi
首先是第一部分umi命令,umi脚手架为我们提供了 umi 这个命令,当我们创建完一个umi项目并安装完相关依赖之后,通过 yarn start 启动该项目时,执行的命令就是 umi dev
那么在 umi 命令运行期间都发生了什么呢,先让我们来看一下完整的流程,如下图:

接下来我们对其几个重点的步骤进行解析,首先就是对于我们在命令行输入的 umi 命令进行处理。
处理命令行参数
// packages/umi/src/cli.ts
const args = yParser(process.argv.slice(2), {
  alias: {
    version: ['v'],
    help: ['h'],
  },
  boolean: ['version'],
});
if (args.version && !args._[0]) {
  args._[0] = 'version';
  const local = existsSync(join(__dirname, '../.local'))
    ? chalk.cyan('@local')
    : '';
  console.log(`umi@${require('../package.json').version}${local}`);
} else if (!args._[0]) {
  args._[0] = 'help';
}
解析命令行参数所使用的 yParser方法 是基于 yargs-parser 封装, 该方法的两个入参分别是 进程的可执行文件的绝对路径 和 正在执行的 JS 文件的路径。解析结果如下:
// 输入 umi dev 经 yargs-parser 解析后为:
// args={
//   _: ["dev"],
// }
在解析命令行参数后,对 version 和 help 参数进行了特殊处理:
- 如果 
args中有version字段,并且 args._ 中没有值,将执行version命令,并从package.json中获得version的值并打印 - 如果没有 
version字段,args._中也没有值,将执行help命令 
总的来说就是,如果只输入 umi 实际会执行 umi help 展示 umi 命令的使用指南,如果输入 umi --version 会输出依赖的版本,如果执行 umi dev 那就是接下来的步骤了。
提问:您知道输入
umi --version dev会发什么吗?
运行 umi dev
// packages/umi/src/cli.ts
const child = fork({
  scriptPath: require.resolve('./forkedDev'),
});
process.on('SIGINT', () => {
  child.kill('SIGINT');
  process.exit(0);
});
// packages/umi/src/utils/fork.ts
if (CURRENT_PORT) {
  process.env.PORT = CURRENT_PORT;
}
const child = fork(scriptPath, process.argv.slice(2), { execArgv });
child.on('message', (data: any) => {
  const type = (data && data.type) || null;
  if (type === 'RESTART') {
    child.kill();
    start({ scriptPath });
  } else if (type === 'UPDATE_PORT') {
    // set current used port
    CURRENT_PORT = data.port as number;
  }
  process.send?.(data);
});
本地开发时,大部分脚手架都会采用开启一个新的线程来启动项目,umi脚手架也是如此。这里的 fork 方法是基于 node 中 child_process.fork() 方法的封装,主要做了以下三件事:
- 确定端口号,使用命令行指定的端口号或默认的8080,如果该端口号已被占用则 
prot += 1 - 开启子进程,该子进程独立于父进程,两者之间建立 IPC 通信通道进行消息传递
 - 处理通信,主要监听了 
RESTART重启 和UPDATE_PORT更新端口号 事件 
接下来看一下在子进程中运行的 forkedDev.ts都做了什么。
// packages/umi/src/forkedDev.ts
(async () => {
  try {
    // 1、设置 NODE_ENV 为 development
    process.env.NODE_ENV = 'development';
    // 2、Init webpack version determination and require hook
    initWebpack();
    // 3、实例化 Service 类,执行 run 方法
    const service = new Service({
      cwd: getCwd(), // umi 项目的根路径
      pkg: getPkg(process.cwd()), // 项目的 package.json 文件的路径
    });
    await service.run({
      name: 'dev',
      args,
    });
    // 4、父子进程通信
    let closed = false;
    process.once('SIGINT', () => onSignal('SIGINT'));
    process.once('SIGQUIT', () => onSignal('SIGQUIT'));
    process.once('SIGTERM', () => onSignal('SIGTERM'));
    function onSignal(signal: string) {
      if (closed) return;
      closed = true;
      // 退出时 触发插件中的onExit事件
      service.applyPlugins({
        key: 'onExit',
        type: service.ApplyPluginsType.event,
        args: {
          signal,
        },
      });
      process.exit(0);
    }
  } catch (e: any) {
    process.exit(1);
  }
})();
- 设置 
process.env.NODE_ENV的值 initWebpack(接下来解析)- 实例化 
Service并run(第二 part 的内容) - 处理父子进程通信,当父进程监听到 
SIGINT、SIGTERM等终止进程的信号,也通知到子进程进行终止;子进程退出时触发插件中的onExit事件 
initWebpack
// packages/umi/src/initWebpack.ts
const haveWebpack5 =
      (configContent.includes('webpack5:') &&
       !configContent.includes('// webpack5:') &&
       !configContent.includes('//webpack5:')) ||
      (configContent.includes('mfsu:') &&
       !configContent.includes('// mfsu:') &&
       !configContent.includes('//mfsu:'));
if (haveWebpack5 || process.env.USE_WEBPACK_5) {
  process.env.USE_WEBPACK_5 = '1';
  init(true);
} else {
  init();
}
initRequreHook();
这一步功能是检查用户配置确定初始化 webpack 的版本。读取默认配置文件 .umirc 和 config/config 中的配置,如果其中有 webpack5 或  mfsu 等相关配置,umi 就会使用 webpack5 进行初始化,否则就使用 webpack4 进行初始化。这里的 mfsu 是 webpack5 的模块联邦相关配置,umi 在 3.5 版本时已经进行了支持。
初遇插件化
该部分在源码中的路径为:packages/core/src/Service
说起 umi 框架,最先让人想到的就是 插件化 ,这也是框架的核心,该部分实现的核心源码就是 Service 类,接下来我们就来看看 Service 类的实例化 和 init() 的过程中发生了什么,可以称之为插件化实现的开端,该部分的大致流程如下

该流程图中前四步,都是在 Service 类实例化的过程中完成的,接下来让我们走进 Service 类。
Service类的实例化
// packages/core/src/Service/Service.ts
export default class Service extends EventEmitter {
  constructor(opts: IServiceOpts) {
    super();
    this.cwd = opts.cwd || process.cwd(); // 当前工作目录
    // repoDir should be the root dir of repo
    this.pkg = opts.pkg || this.resolvePackage(); // package.json
    this.env = opts.env || process.env.NODE_ENV; // 环境变量
    // 在解析config之前注册babel
    this.babelRegister = new BabelRegister();
    // 通过dotenv将环境变量中的变量从 .env 或 .env.local 文件加载到 process.env 中
    this.loadEnv(); 
    // 1、get user config
    const configFiles = opts.configFiles;
    this.configInstance = new Config({
      cwd: this.cwd,
      service: this,
      localConfig: this.env === 'development',
      configFiles
    });
    this.userConfig = this.configInstance.getUserConfig();
    // 2、get paths
    this.paths = getPaths({
      cwd: this.cwd,
      config: this.userConfig!,
      env: this.env,
    });
    // 3、get presets and plugins
    this.initialPresets = resolvePresets({
      ...baseOpts,
      presets: opts.presets || [],
      userConfigPresets: this.userConfig.presets || [],
    });
    this.initialPlugins = resolvePlugins({
      ...baseOpts,
      plugins: opts.plugins || [],
      userConfigPlugins: this.userConfig.plugins || [],
    });
  }
}
Service 类继承自 EventEmitter 用于实现自定义事件。在 Service 类实例化的过程中除了 初始化成员变量 外主要做了以下三件事:
1、解析配置文件
// packages/core/src/Config/Config.ts
const DEFAULT_CONFIG_FILES = [ // 默认配置文件
  '.umirc.ts',
  '.umirc.js',
  'config/config.ts',
  'config/config.js',
];
// ...
if (Array.isArray(opts.configFiles)) {
  // 配置的优先读取
  this.configFiles = lodash.uniq(opts.configFiles.concat(this.configFiles));
}
//...
getUserConfig() {
  // 1、找到 configFiles 中的第一个文件
  const configFile = this.getConfigFile();
  this.configFile = configFile;
  // 潜在问题:.local 和 .env 的配置必须有 configFile 才有效
  if (configFile) {
    let envConfigFile;
    if (process.env.UMI_ENV) {
      // 1.根据 UMI_ENV 添加后缀 eg: .umirc.ts --> .umirc.cloud.ts
      const envConfigFileName = this.addAffix(
        configFile,
        process.env.UMI_ENV,
      );
      // 2.去掉后缀 eg: .umirc.cloud.ts --> .umirc.cloud
      const fileNameWithoutExt = envConfigFileName.replace(
        extname(envConfigFileName),
        '',
      );
      // 3.找到该环境下对应的配置文件 eg: .umirc.cloud.[ts|tsx|js|jsx]
      envConfigFile = getFile({
        base: this.cwd,
        fileNameWithoutExt,
        type: 'javascript',
      })?.filename;
    }
    const files = [
      configFile, // eg: .umirc.ts
      envConfigFile, // eg: .umirc.cloud.ts
      this.localConfig && this.addAffix(configFile, 'local'), // eg: .umirc.local.ts
    ]
    .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;
    }, []);
    // 删除对象中的键值 require.cache[cachePath],下一次 require 将重新加载模块
    requireDeps.forEach(cleanRequireCache); 
    this.service.babelRegister.setOnlyMap({
      key: 'config',
      value: requireDeps,
    });
    // require config and merge
    return this.mergeConfig(...this.requireConfigs(files));
  } else {
    return {};
  }
}
细品源码,可以看出 umi 读取配置文件的优先级:自定义配置文件  > .umirc > config/config,后续根据 UMI_ENV 尝试获取对应的配置文件,development 模式下还会使用 local 配置,不同环境下的配置文件也是有优先级的
例如:.umirc.local.ts > .umirc.cloud.ts > .umirc.ts
由于配置文件中可能 require 其他配置,这里通过 parseRequireDeps方法进行递归处理。在解析出所有的配置文件后,会通过 cleanRequireCache 方法清除 requeire 缓存,这样可以保证在接下来合并配置时的引入是实时的。
2、获取相关绝对路径
// packages/core/src/Service/getPaths.ts
export default function getServicePaths({
  cwd,
  config,
  env,
}: {
  cwd: string;
  config: any;
  env?: string;
}): IServicePaths {
  let absSrcPath = cwd;
  if (isDirectoryAndExist(join(cwd, 'src'))) {
    absSrcPath = join(cwd, 'src');
  }
  const absPagesPath = config.singular
    ? join(absSrcPath, 'page')
    : join(absSrcPath, 'pages');
  const tmpDir = ['.umi', env !== 'development' && env]
    .filter(Boolean)
    .join('-');
  return normalizeWithWinPath({
    cwd,
    absNodeModulesPath: join(cwd, 'node_modules'),
    absOutputPath: join(cwd, config.outputPath || './dist'),
    absSrcPath, // src
    absPagesPath, // pages
    absTmpPath: join(absSrcPath, tmpDir),
  });
}
这一步主要获取项目目录结构中 node_modules、dist、src、pages 等文件夹的绝对路径。如果用户在配置文件中配置了 singular 为 true,那么页面文件夹路径就是 src/page,默认是 src/pages
3、收集 preset 和 plugin 以对象形式描述
在 umi 中“万物皆插件”,preset 是对于插件的描述,可以理解为“插件集”,是为了方便对插件的管理。例如:@umijs/preset-react 就是一个针对 react 应用的插件集,其中包括了 plugin-access 权限管理、plugin-antd antdUI组件 等。
// packages/core/src/Service/Service.ts
this.initialPresets = resolvePresets({
  ...baseOpts,
  presets: opts.presets || [],
  userConfigPresets: this.userConfig.presets || [],
});
this.initialPlugins = resolvePlugins({
  ...baseOpts,
  plugins: opts.plugins || [],
  userConfigPlugins: this.userConfig.plugins || [],
});
在收集 preset 和 plugin 时,首先调用了 resolvePresets 方法,其中做了以下处理:
3.1、调用 getPluginsOrPresets 方法,进一步收集 preset 和 plugin 并合并
// packages/core/src/Service/utils/pluginUtils.ts
getPluginsOrPresets(type: PluginType, opts: IOpts): string[] {
  const upperCaseType = type.toUpperCase();
  return [
    // opts
    ...((opts[type === PluginType.preset ? 'presets' : 'plugins'] as any) ||
      []),
    // env
    ...(process.env[`UMI_${upperCaseType}S`] || '').split(',').filter(Boolean),
    // dependencies
    ...Object.keys(opts.pkg.devDependencies || {})
      .concat(Object.keys(opts.pkg.dependencies || {}))
      .filter(isPluginOrPreset.bind(null, type)),
    // user config
    ...((opts[
      type === PluginType.preset ? 'userConfigPresets' : 'userConfigPlugins'
    ] as any) || []),
  ].map((path) => {
    return resolve.sync(path, {
      basedir: opts.cwd,
      extensions: ['.js', '.ts'],
    });
  });
}
这里可以看出 收集 preset 和 plugin 的来源主要有四个:
- 实例化 
Service时的入参 process.env中指定的UMI_PRESETS或UMI_PLUGINSpackage.json中dependencies和devDependencies配置的,需要命名规则符合/^(@umijs\/|umi-)preset-/这个正则- 解析配置文件中的,即入参中的 
userConfigPresets或userConfigPresets 
3.2、调用 pathToObj 方法:将收集的 plugin 或 preset 以对象的形式输出
// packages/core/src/Service/utils/pluginUtils.ts
// pathToObj 的返回结果
return {
  id,
  key,
  path: winPath(path),
  apply() {
    // use function to delay require
    try {
      const ret = require(path);
      // use the default member for es modules
      return compatESModuleRequire(ret);
    } catch (e) {
      throw new Error(`Register ${type} ${path} failed, since ${e.message}`);
    }
  },
  defaultConfig: null,
};
umi 官网中提到过:每个插件都会对应一个 id 和一个 key,id 是路径的简写,key 是进一步简化后用于配置的唯一值。便是在这一步进行的处理
形式如下:
{
  id: './node_modules/umi/lib/plugins/umiAlias',
  key: 'umiAlias',
  path: '项目地址/node_modules/umi/lib/plugins/umiAlias.js',
  apply: ...,
  defaultConfig: null
}
思考:为什么要将插件以对象的形式进行描述?有什么好处?
执行 run 方法,初始化插件
在 Service 类实例化完毕后,会立马调用 run 方法,run()执行的第一步就是执行 init 方法,init() 方法的功能就是完成插件的初始化,主要操作如下:
- 遍历 
initialPresets并init - 合并 
init presets过程中得到的plugin和initialPlugins - 遍历合并后的 
plugins并init 
这里的 initialPresets 和 initialPlugins 就是上一步 收集 preset 和 plugin 得到的结果,在这一步要对其逐一的 init,接下来我们看一下 init 的过程中做了什么。
Init plugin
// packages/core/src/Service/Service.ts
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);
  const { presets, plugins, ...defaultConfigs } = await this.applyAPI({
    api,
    apply,
  });
  // register extra presets and plugins
  if (presets) {
    // 插到最前面,下个 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) {
    this._extraPlugins.push(
      ...plugins.map((path: string) => {
        return pathToObj({
          type: PluginType.plugin,
          path,
          cwd: this.cwd,
        });
      }),
    );
  }
}
// initPlugin
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 });
}
这段代码主要做了以下几件事情:
getPluginAPI方法 :new PluginAPI时传入了Service实例,通过pluginAPI实例中的registerMethod方法将register方法添加到Service实例的pluginMethods中,后续返回pluginAPI的代理,以动态获取最新的register方法,以实现边注册边使用。
// packages/core/src/Service/Service.ts
getPluginAPI(opts: any) {
  const pluginAPI = new PluginAPI(opts);
  // register built-in methods
  [
    '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];
    },
  });
}
在 register 方法执行时会将该插件的 hooks 加入到 Service 实例的 hooksByPluginId 中
// hooks
hooksByPluginId: {
  [id: string]: IHook[];
} = {};
export interface IHook {
  key: string; // eg: onPluginReady
  fn: Function;
  pluginId?: string; // plugin 描述对象中的 id
  before?: string;
  stage?: number;
}
- 
registerPlugin是以plugin的id为键值将其添加到this.plugins中 - 
applyAPI就是调用preset对象中的apply方法获得preset文件的返回值,该返回值是一个方法,执行该方法,入参是api。如果applyAPI返回了presets会将该presets插入到_extraPresets遍历队列的最前方,保证在下一次优先执行 
// packages/core/src/Service/Service.ts
async applyAPI(opts: { apply: Function; api: PluginAPI }) {
  let ret = opts.apply()(opts.api);
  if (isPromise(ret)) {
    ret = await ret;
  }
  return ret || {};
}
- 由于 
presets支持嵌套,所以对_extraPresets进行了递归处理 
对于插件 hooks 的进一步处理
// packages/core/src/Service/Service.ts
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);
  });
});
//
hooks: {
  [key: string]: IHook[];
} = {};
经过上一步 init plugin 得到的 hooksByPluginId 中是 plugin id -- 该插件各个阶段的hook这样的键值对
在这里进行转换,结果为 hooks 中的 hook name -- 该阶段所有插件的hook,比如:键是 onPluginReady 值对应的就是各个插件的 onPluginReady 钩子,这样在后续就可以一次性执行所有插件的钩子。
从上边
init Plugin和 处理hooks的过程中不难看出,以对象的形式描述插件,让umi对于插件的处理变得非常灵活。
总结
经过以上部分的源码解析,大家肯定发现了。不管是提供 umi 命令,支持多样化的配置(比如:收集 package.json 中的依赖进行插件注册,是 pages 还是 page ?),还是为我们提供 preset + plugin 的插件配置模式,umi 为了节省使用者的开发效率真的做了很多事情,同时还非常注重性能(对插件 hooks 的处理中可见一斑)。umi 的理念之一就是 “约定大于配置”,按部就班的搭建项目引用插件,使用者只需要把重心放在业务逻辑上即可,多是一件美事啊。
OK,今天的源码解析就到这里为止喽,不知各位感觉如何呢?如果还是感到疑惑可以留言讨论,若是让您有所收获的话那就欢迎大家点赞并关注后续该系列的更新哟!
转载自:https://juejin.cn/post/7096740749621854215