找回密码
 会员注册
查看: 30|回复: 0

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

[复制链接]

3

主题

0

回帖

10

积分

新手上路

积分
10
发表于 2024-9-19 17:55:05 | 显示全部楼层 |阅读模式
基于前面umi插件机制的原理可以了解到,umi是一个插件化的企业级前端框架,它配备了完善的插件体系,这也使得umi具有很好的可扩展性。umi的全部功能都是由插件完成的,构建功能同样是以插件的形式完成的。下面将从以下两个方面来了解umi的构建原理。UMI命令注册想了解umi命令的注册流程,咱们就从umi生成的项目入手。从umi初始化的项目package.json文件看,umi执行dev命令,实际执行的是start:dev,而start:dev最终执行的是umidev。"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 noderequire('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';}处理完version和help后,紧接着会执行一段自执行代码:(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/umiAlias。plugins/umiAlias只是修改了webpack中的alias,源码很简单,感兴趣的可前往查看,这里不再赘述。我们看下插件集@umijs/preset-built-in:// commandsrequire.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-inthis.initialPresets = resolvePresets({  ...baseOpts,  presets: opts.presets || [],  userConfigPresets: this.userConfig.presets || [],});// 初始化插件,opts.plugins即包括传入的plugins/umiAliasthis.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、调用Service的run方法。上面已经完成了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步:触发onStarthook  // /////////////////////  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部分内容:name:命令的名称,此处为devdescription:命令的描述说明fn:命令执行的核心脚本从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一切皆插件的设计思想。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 22:55 , Processed in 0.706775 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表