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_PLUGINS
package.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