基于前面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';
}
处理完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
:
// 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、调用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步:触发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
为例,看下每个命令的核心实现逻辑。
如上所述,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部分内容:
dev
从 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