UMI3源码解析系列之构建原理

261次阅读  |  发布于2年以前

基于前面umi插件机制的原理可以了解到,umi是一个插件化的企业级前端框架,它配备了完善的插件体系,这也使得umi具有很好的可扩展性。umi的全部功能都是由插件完成的,构建功能同样是以插件的形式完成的。下面将从以下两个方面来了解umi的构建原理。

UMI命令注册

想了解umi命令的注册流程,咱们就从umi生成的项目入手。

umi初始化的项目package.json文件看,umi执行dev命令,实际执行的是start:dev,而start:dev最终执行的是umi dev

"scripts": {
  "dev": "npm run start:dev",
  "start:dev": "cross-env REACT_APP_ENV=dev MOCK=none UMI_ENV=dev umi dev"
}

根据这里的umi命令,我们找到node_modules里的umi文件夹,看下umi文件夹下的package.json文件:

"name": "umi",
"bin": {
  "umi": "bin/umi.js"
}

可以看到,这里就是定义umi命令的地方,而umi命令执行的脚本就在bin/umi.js里。接下来咱们看看bin/umi.js都做了什么。

#!/usr/bin/env node

require('v8-compile-cache');

const resolveCwd = require('@umijs/deps/compiled/resolve-cwd');

const { name, bin } = require('../package.json');
const localCLI = resolveCwd.silent(`${name}/${bin['umi']}`);
if (!process.env.USE_GLOBAL_UMI && localCLI && localCLI !== __filename) {
  const debug = require('@umijs/utils').createDebug('umi:cli');
  debug('Using local install of umi');
  require(localCLI);
} else {
  require('../lib/cli');
}

判断当前是否执行的是本地脚手架,若是,则引入本地脚手架文件,否则引入 lib/cli 。在这里,我们未开启本地脚手架指令,所以是引用的lib/cli

// 获取进程的版本号
const v = process.version;
// 通过yParser工具对命令行参数进行处理,此处是将version和help进行了简写
const args = yParser(process.argv.slice(2), {
  alias: {
    version: ['v'],
    help: ['h'],
  },
  boolean: ['version'],
});

// 若参数中有version值,并且args._[0]为空,此时将version字段赋值给args._[0]
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}`);
// 若参数中无version值,并且args._[0]为空,此时将help字段复制给args._[0]
} else if (!args._[0]) {
  args._[0] = 'help';
}

处理完versionhelp后,紧接着会执行一段自执行代码:

(async () => {
  try {
    // 读取args._中第一个参数值
    switch (args._[0]) {
      case 'dev':
        // 若当前运行环境是dev,则调用Node.js的核心模块child_process的fork方法衍生一个新的Node.js进程。scriptPath表示要在子进程中运行的模块,这里引用的是forkedDev.ts文件。
        const child = fork({
          scriptPath: require.resolve('./forkedDev'),
        });
        // ref:
        // http://nodejs.cn/api/process/signal_events.html
        // https://lisk.io/blog/development/why-we-stopped-using-npm-start-child-processes
        process.on('SIGINT', () => {
          child.kill('SIGINT');
          // ref:
          // https://github.com/umijs/umi/issues/6009
          process.exit(0);
        });
        process.on('SIGTERM', () => {
          child.kill('SIGTERM');
          process.exit(1);
        });
        break;
      default:
        // 非dev环境皆执行default中的代码
        // 读取args._中的第一个参数,若为build,则认为是要运行生产环境,process.env.NODE_ENV赋值为production
        const name = args._[0];
        if (name === 'build') {
          process.env.NODE_ENV = 'production';
        }
        // 下面的这块代码和dev子进程中执行的forkedDev.ts文件中的核心代码一模一样,此处不再赘述,接下来我们看forkedDev.ts文件中的内容。

        // Init webpack version determination and require hook for build command
        initWebpack();

        await new Service({
          cwd: getCwd(),
          pkg: getPkg(process.cwd()),
        }).run({
          name,
          args,
        });
        break;
    }
  } catch (e) {
    console.error(chalk.red(e.message));
    console.error(e.stack);
    process.exit(1);
  }
})();

forkedDev.ts文件是专门处理dev环境子进程的脚本。

// 获取命令行参数
const args = yParser(process.argv.slice(2));
// dev环境子进程自执行函数
(async () => {
  try {
    // 设置环境变量为development
    process.env.NODE_ENV = 'development';
    // Init webpack version determination and require hook
    // initWebpack方法主要是获取用户的配置,并针对webpack5做特殊处理,可参考源码中的注释,此处不再细说。
    // 源码中说明如下:
    // 1. read user config
    // 2. if have webpack5:
    // 3. init webpack with webpack5 flag
    initWebpack();
    // cwd: 返回 Node.js 进程的当前工作目录
    // pkg: 获取当前目录 package.json
    // 同上段代码,build也是执行的这段代码
    // 实例化Service类,并运行service.run方法,启动进程
    const service = new Service({
      cwd: getCwd(),
      pkg: getPkg(process.cwd()),
    });
    // 执行实例化对象service的run方法,完成命令行的注册
    await service.run({
      name: 'dev',
      args,
    });

    let closed = false;
    // kill(2) Ctrl-C
    process.once('SIGINT', () => onSignal('SIGINT'));
    // kill(3) Ctrl-\
    process.once('SIGQUIT', () => onSignal('SIGQUIT'));
    // kill(15) default
    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) {
    console.error(chalk.red(e.message));
    console.error(e.stack);
    process.exit(1);
  }
})();

上述源码中,Service继承自CoreService,是对CoreService二次封装。它的核心代码在ServiceWithBuiltIn.ts文件中,下面我们来看下它都做了哪些处理:

class Service extends CoreService {
  constructor(opts: IServiceOpts) {
    // 增加全局环境变量字段:UMI_VERSION、UMI_DIR
    process.env.UMI_VERSION = require('../package').version;
    process.env.UMI_DIR = dirname(require.resolve('../package'));
    // 调用父类CoreService的构造函数,注入插件集@umijs/preset-built-in和插件plugins/umiAlias
    super({
      ...opts,
      presets: [
        require.resolve('@umijs/preset-built-in'),
        ...(opts.presets || []),
      ],
      plugins: [require.resolve('./plugins/umiAlias'), ...(opts.plugins || [])],
    });
  }
}

export { Service };

在二次封装的Service中我们看到,它在初始化时注入了一个插件集@umijs/preset-built-in和一个插件plugins/umiAliasplugins/umiAlias只是修改了webpack中的alias,源码很简单,感兴趣的可前往查看,这里不再赘述。我们看下插件集@umijs/preset-built-in

// commands
require.resolve('./plugins/commands/build/build'),
require.resolve('./plugins/commands/build/applyHtmlWebpackPlugin'),
require.resolve('./plugins/commands/config/config'),
require.resolve('./plugins/commands/dev/dev'),
require.resolve('./plugins/commands/dev/devCompileDone/devCompileDone'),
require.resolve('./plugins/commands/dev/mock/mock'),
require.resolve('./plugins/commands/generate/generate'),
require.resolve('./plugins/commands/help/help'),
require.resolve('./plugins/commands/plugin/plugin'),
require.resolve('./plugins/commands/version/version'),
require.resolve('./plugins/commands/webpack/webpack')

preset-built-in目录的入口文件index.ts中可以看出,它引入众多插件,这些插件都是umi内置的核心插件,这里我们只关注命令行的插件注入。

前面讲到,Service调用了CoreService的构造函数,在构造函数中,将传入的插件集@umijs/preset-built-in和插件plugins/umiAlias都进行了初始化。

// 初始化插件集,opts.persets即包括传入的@umijs/preset-built-in
this.initialPresets = resolvePresets({
  ...baseOpts,
  presets: opts.presets || [],
  userConfigPresets: this.userConfig.presets || [],
});
// 初始化插件,opts.plugins即包括传入的plugins/umiAlias
this.initialPlugins = resolvePlugins({
  ...baseOpts,
  plugins: opts.plugins || [],
  userConfigPlugins: this.userConfig.plugins || [],
});

至于插件集和插件是如何实现初始化注册的,请参看本公众号上篇文章[UMI3源码解析系列之插件化架构核心] 。

插件集和插件初始化完成后,也就完成了Service的实例化过程。还记得上面dev的核心脚本?再来回顾下源码:

// 实例化Service类,并运行service.run方法,启动进程
const service = new Service({
  cwd: getCwd(),
  pkg: getPkg(process.cwd()),
});
// 执行实例化对象service的run方法,完成命令行的注册
await service.run({
  name: 'dev',
  args,
});

进程启动需要两步:1、实例化Service,2、调用Servicerun方法。

上面已经完成了Service的实例化,接下来我们看下run方法的调用。

async run({ name, args = {} }: { name: string; args?: any }) {
  args._ = args._ || [];
  // shift the command itself
  if (args._[0] === name) args._.shift();

  this.args = args;
  // ///////////////////////////////////////
  // 第1步:调用init方法,初始化presets和plugins
  // 这里完成了所有插件集和插件的初始化
  // ///////////////////////////////////////
  await this.init();

  logger.debug('plugins:');
  logger.debug(this.plugins);
  // /////////////////////////////
  // 第2步:设置生命周期状态为run运行时
  // /////////////////////////////
  this.setStage(ServiceStage.run);
  // /////////////////////
  // 第3步:触发onStart hook
  // /////////////////////
  await this.applyPlugins({
    key: 'onStart',
    type: ApplyPluginsType.event,
    args: {
      name,
      args,
    },
  });
  // ///////////////////////////
  // 第4步:执行命令脚本函数
  // 插件准备完成后,开始执行命令脚本
  // ///////////////////////////
  return this.runCommand({ name, args });
}

此时,我们已完成umi所有内置命令行的插件注册。

插件注册完成后,立即调用了runCommand方法,来执行命令的脚本函数。

async runCommand({ name, args = {} }: { name: string; args?: any }) {
  assert(this.stage >= ServiceStage.init, `service is not initialized.`);

  args._ = args._ || [];
  // shift the command itself
  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 });
}

runCommand方法中,从this.commands集合中获取当前命令名,这里对command做了格式上的统一。获取到的command是个ICommand类型的对象,从中获取fn属性,并直接调用,从而完成命令行脚本的执行。

下面我们以dev为例,看下每个命令的核心实现逻辑。

dev实现

如上所述,umi内置的核心插件都通过插件集@umijs/preset-built-in注入,我们找到插件集中dev的命令文件,即:

require.resolve('./plugins/commands/dev/dev')

umi注册命令是通过registerCommand核心方法完成的,我们来看下dev文件的registerCommand方法做了什么:

api.registerCommand({
  name: 'dev',
  description: 'start a dev server for development',
  fn: async function ({ args }) {}
});

先来看下registerCommand方法,包括3部分内容:

从 UMI命令注册 小节我们了解到,最终在执行runCommand方法时,实际是在执行每个命令插件的fn方法。那么,我们就来看看dev命令的fn具体是怎么实现的。

fn: async function ({ args }) {
  // 获取默认端口号
  const defaultPort =
    // @ts-ignore
    process.env.PORT || args?.port || api.config.devServer?.port;
  // 为全局变量 port 赋值,若项目配置指定了端口号,则优先采用,否则端口号默认为:8000
  port = await portfinder.getPortPromise({
    port: defaultPort ? parseInt(String(defaultPort), 10) : 8000,
  });
  // @ts-ignore
  // 设置全局hostname。优先读取配置中的host配置,若无,则默认赋值为:0.0.0.0
  // 补充一个知识点:0.0.0.0表示什么?
  // 在IPV4中,0.0.0.0地址被用于表示一个无效的,未知的或者不可用的目标。
  // 如果一个主机有两个IP地址,192.168.1.11 和 172.16.1.11 ,那么使用这两个IP地址访问本地服务都可以,这也就是启动项目时,控制台打印的Network对应的本机IP地址。
  hostname = process.env.HOST || api.config.devServer?.host || '0.0.0.0';
  console.log(chalk.cyan('Starting the development server...'));
  // 若进程采用的是IPC通道衍生,需通过 process.send() 方法通知父进程更新端口号
  // 若进程不是采用IPC通道衍生,则不需要发送
  process.send?.({ type: 'UPDATE_PORT', port });

  // enable https, HTTP/2 by default when using --https
  // 设置环境变量HTTPS值
  const isHTTPS = process.env.HTTPS || args?.https;

  // 清理过期的缓存文件,即 .cache 文件夹中的所有文件
  cleanTmpPathExceptCache({
    absTmpPath: paths.absTmpPath!,
  });

  // 是否开启监听 package.json 变化
  const watch = process.env.WATCH !== 'none';

  // generate files
  // 执行 onGenerateFiles 插件,生成临时文件
  const unwatchGenerateFiles = await generateFiles({ api, watch });
  if (unwatchGenerateFiles) unwatchs.push(unwatchGenerateFiles);

  // 若开启热更新,执行如下逻辑:
  if (watch) {
    // watch pkg changes
    // 通过 chokidar 库,开启 package.json 文件监听任务
    const unwatchPkg = watchPkg({
      cwd: api.cwd,
      onChange() {
        console.log();
        api.logger.info(`Plugins in package.json changed.`);
        api.restartServer();
      },
    });
    unwatchs.push(unwatchPkg);

    // watch config change
    // 同样通过 chokidar 库,开启对配置文件的监听任务
    const unwatchConfig = api.service.configInstance.watch({
      userConfig: api.service.userConfig,
      onChange: async ({ pluginChanged, userConfig, valueChanged }) => {
        if (pluginChanged.length) {
          console.log();
          api.logger.info(
            `Plugins of ${pluginChanged
              .map((p) => p.key)
              .join(', ')} changed.`,
          );
          api.restartServer();
        }
        if (valueChanged.length) {
          let reload = false;
          let regenerateTmpFiles = false;
          const fns: Function[] = [];
          const reloadConfigs: string[] = [];
          valueChanged.forEach(({ key, pluginId }) => {
            const { onChange } = api.service.plugins[pluginId].config || {};
            if (onChange === api.ConfigChangeType.regenerateTmpFiles) {
              regenerateTmpFiles = true;
            }
            if (!onChange || onChange === api.ConfigChangeType.reload) {
              reload = true;
              reloadConfigs.push(key);
            }
            if (typeof onChange === 'function') {
              fns.push(onChange);
            }
          });

          if (reload) {
            console.log();
            api.logger.info(`Config ${reloadConfigs.join(', ')} changed.`);
            api.restartServer();
          } else {
            api.service.userConfig =
              api.service.configInstance.getUserConfig();

            // TODO: simplify, 和 Service 里的逻辑重复了
            // 需要 Service 露出方法
            const defaultConfig = await api.applyPlugins({
              key: 'modifyDefaultConfig',
              type: api.ApplyPluginsType.modify,
              initialValue:
                await api.service.configInstance.getDefaultConfig(),
            });
            api.service.config = await api.applyPlugins({
              key: 'modifyConfig',
              type: api.ApplyPluginsType.modify,
              initialValue: api.service.configInstance.getConfig({
                defaultConfig,
              }) as any,
            });

            if (regenerateTmpFiles) {
              await generateFiles({ api });
            } else {
              fns.forEach((fn) => fn());
            }
          }
        }
      },
    });
    unwatchs.push(unwatchConfig);
  }

  // delay dev server 启动,避免重复 compile
  // https://github.com/webpack/watchpack/issues/25
  // https://github.com/yessky/webpack-mild-compile
  await delay(500);

  // 以上都是dev运行的准备工作,下面则是核心的dev操作

  // dev
  // 获取实例化后的 bundler 和 配置
  const { bundler, bundleConfigs, bundleImplementor } =
    await getBundleAndConfigs({ api, port });

  // 调用实例化后的 bundler 的 setupDevServerOpts 方法,这个方法做了如下几件事:
  // 1. 调用webpack方法,获取webpack的编译器实例 compiler
  // 2. 编译器实例 compiler 通过 webpack-dev-middleware 封装器,将webpack处理过的文件封装成 server 能接收的格式
  // 3. 通过调用 sockjs 的 sockWrite 方法,实现热更新
  // 4. 处理服务类 Server 实例化时需要的 onListening 和 onConnection 函数
  const opts: IServerOpts = bundler.setupDevServerOpts({
    bundleConfigs: bundleConfigs,
    bundleImplementor,
  });

  // 处理前置中间件
  const beforeMiddlewares = [
    ...(await api.applyPlugins({
      key: 'addBeforeMiddewares',
      type: api.ApplyPluginsType.add,
      initialValue: [],
      args: {},
    })),
    ...(await api.applyPlugins({
      key: 'addBeforeMiddlewares',
      type: api.ApplyPluginsType.add,
      initialValue: [],
      args: {},
    })),
  ];

  // 处理后置中间件
  const middlewares = [
    ...(await api.applyPlugins({
      key: 'addMiddewares',
      type: api.ApplyPluginsType.add,
      initialValue: [],
      args: {},
    })),
    ...(await api.applyPlugins({
      key: 'addMiddlewares',
      type: api.ApplyPluginsType.add,
      initialValue: [],
      args: {},
    })),
  ];

  // 实例化进程server,并传入bundler.setupDevServerOpts处理过的 compilerMiddleware、onListening、onConnection
  server = new Server({
    ...opts,
    compress: true,
    https: !!isHTTPS,
    headers: {
      'access-control-allow-origin': '*',
    },
    proxy: api.config.proxy,
    beforeMiddlewares,
    afterMiddlewares: [
      ...middlewares,
      createRouteMiddleware({ api, sharedMap }),
    ],
    ...(api.config.devServer || {}),
  });

  // 启动实例化后的server
  const listenRet = await server.listen({
    port,
    hostname,
  });
  return {
    ...listenRet,
    compilerMiddleware: opts.compilerMiddleware,
    destroy,
  };
}

至此,dev命令的核心运行脚本已解读完毕。

以上,通过命令注册原理和 dev 命令注册流程的源码解读,我们已经了解到 UMI 是怎么实现命令注册的。实际上,还是通过插件的形式实现了,再次印证了 UMI 一切皆插件的设计思想。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8