|
基于前面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一切皆插件的设计思想。
|
|