UMI框架解析

背景

最近打算自己写一个运维管理平台,给我们内部使用,对于一个运维+后端程序员来说,写前端无疑是最大的挑战了,前端的知识栈真是庞大又杂乱,只掌握了最基础的html+css+js来说是远远不够的,前端发展了这么几十年,每一个领域都有一大堆的标准、组件、框架,比如css有less、sass等扩展,js领域又有react, vue等框架,还有typescript这种带类型的js,光ts的语法就可以复杂到令人发指,此外,还有很多现成的ui库,像阿里的antd,国外的mui,本以为react就已经是学习的终点了,但是还有umi这种基于react的前端框架,这还没完,还有再上一层的基于umi的antd pro框架,一层接一层,而且很多公司还在热衷于创造新的框架,这些各个方面各个维度的框架组件,多到让新踏入这个领域的小白无所适从。然而虽然学习成本增加了,但是这种越来越上层的框架组件,是无数前辈大佬的经验总结,能够让前端开发变得快速高效标准,尤其是对我这种小白来说,遵循这些优秀的框架标准,就可以继承这些经验,少走很多弯路,专注于做业务逻辑的开发。

阿里开源的umi就是这样基于react的框架,它把涉及到前端开发的几乎所有方面都纳入到这个框架的管理范围内,比如路由、打包、国际化、状态管理、依赖管理等等,有了统一的标准,就可以让团队协作开发变得高效顺畅,由于它包罗万象,因此它的实现方式也很有特点,即全插件化,一个个功能都是通过插件来实现的,即使是umi最核心的功能也是通过插件实现的,开发人员也可以开发自己的插件,去满足特定需求。这套框架实现的相当不错,对我这种新手来说,简直就是福音,可以直接继承大厂的开发经验,使用上最前沿的开发技术,以最正确的姿势去做我想做的事情,但是umi的文档写的还是差那么点意思,对新手来说不太友好,如果不懂代码的话,看懂文档还是很困难的,尤其是那一堆插件,每个插件的作用是什么,插件该怎么使用,光看文档还是云里雾里。所以,代码就是最好的文档,本篇文章主要是分析下umi的插件实现原理,这样再去分析各种插件的功能时,就比较清晰了。

介绍

其实umi在官方文档中,对插件有一个比较详细的介绍:开发插件,介绍了核心概念以及一些基本操作和原理,建议阅读。关于umi的插件机制,有一点要先明确,就是umi插件是分两种的:编译时插件和运行时插件,所谓编译指的是umi将项目源代码打包成实际运行的代码的过程,这是个后端行为,是在nodejs运行的,在这个过程中,umi使用各种各样的编译时插件去对编译过程进行定义,产生出最终的运行代码;而运行时插件,指的是在编译过程中,会由编译插件生成很多的临时文件,这些临时文件也是通过插件方式来组织的,这些临时文件,再加上自己编写的项目代码,就是最终要运行在浏览器的前端代码,这是个前端行为,很多编译时插件做的主要事情就是生成运行时插件。umi中所有的功能都是以插件的形式实现的,正如文档所说,通过插件,可以实现修改代码打包配置,修改启动代码,约定目录结构,修改 HTML 等丰富的功能,本篇文章也主要介绍编译时插件。

先来看看下一个典型的编译时插件长什么样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { IApi } from 'umi';

export default (api: IApi) => {
api.describe({
key: 'changeFavicon',
config: {
schema(joi) {
return joi.string();
},
},
enableBy: api.EnableBy.config
});
api.modifyConfig((memo)=>{
memo.favicon = api.userConfig.changeFavicon;
return memo;
});
};

这是来自上面umi官方文档的一个例子,这个插件的作用是根据用户配置的 changeFavicon 值来更改配置中的 favicon,(一个很简单且没有实际用途的例子)。插件都是直接export一个方法,在该方法中,通过api接口,调用各种api方法注册自己的具体实现逻辑。

再来看一个生成的umi项目的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[root@kubernetes src]# ls -al .umi/
total 16
drwxr-xr-x 8 root root 202 Aug 7 18:44 .
drwxr-xr-x 10 root root 154 Aug 7 18:44 ..
drwxr-xr-x 2 root root 143 Aug 7 18:44 core
-rw-r--r-- 1 root root 1440 Aug 7 18:44 exports.ts
drwxr-xr-x 2 root root 60 Aug 7 18:44 plugin-access
drwxr-xr-x 2 root root 70 Aug 7 18:44 plugin-initialState
drwxr-xr-x 2 root root 173 Aug 7 18:44 plugin-layout
drwxr-xr-x 2 root root 58 Aug 7 18:44 plugin-model
drwxr-xr-x 2 root root 58 Aug 7 18:44 plugin-request
-rw-r--r-- 1 root root 643 Aug 7 18:44 tsconfig.json
-rw-r--r-- 1 root root 2564 Aug 7 18:44 typings.d.ts
-rw-r--r-- 1 root root 1689 Aug 7 18:44 umi.ts
[root@kubernetes src]#

使用umi提供的命令,新建了一个umi项目之后,会看到在src/目录下有一个.umi/的目录,该目录以及里面的文件就是使用的编译时插件生成的,该目录中的umi.ts就是整个项目的入口文件,plugin-*/这些就是运行时插件,而其他目录和文件,都是umi整个框架约定的目录结构,比如pages/里面存放的是路由相关的代码,services/是跟后端服务打交道的业务逻辑代码等等,关于这些目录的作用,umi文档 目录结构 做了比较详细的介绍。

实现

本文分析所使用的版本是umi刚发布的4.x,它的核心实现在 umi/package/core/ 这个目录中,这个 core/ 可以理解成umi这种插件架构的微内核,微内核就是确保这套插件架构能够运行良好的最小实现,可以猜测它的设计思想是在致敬linux kernel的微内核架构(RESPECT:)。 umi的整个微内核本身实现的并不太复杂,下面是它核心的类图结构:

首先来简单介绍下这几个类:

  • Service: 它是umi中最核心的类,它里面维护了各种数组,用来存放注册进来的插件、方法、命令、Hook等,并且对注册进来的插件进行初始化,以及提供了调用注册它里面的各种对象的方法。
  • Plugin: 它是对插件的抽象,每一个注册到Service中的插件都会为其实例化一个Plugin,而插件分两种类型,一种是preset,是一个插件集,它可以引用很多其他插件,甚至还可以再引用其他插件集,主要是方便对插件进行管理,一种是plugin,就是真正的插件了。
  • PluginAPI: 它就是umi文档中介绍到的插件API,里面提供了各种各样的方法(包括核心方法和扩展方法)可以被插件来调用,将插件中的具体实现方法注册到Service对应的数组中,在初始化每一个插件时,也会同时初始化一个PluginAPI,用来作为插件跟Service之间交互的桥梁。
  • Hook: 所谓hook就是umi在一些特定的位置设置了一些锚点,比如onStart, onCheck等,在这些锚点上,可以注册进很多的hook方法,当程序执行到这个锚点时,就会调用该锚点上的所有hook方法,每个插件都可以在这些锚点上注册自己的方法,去实现自己的一些相关功能。

接下来分析下每个类中的关键点,这些关键点是我们理解这个微内核的关键:

Hook

这个类的实现比较简单,关键信息是key和fn,fn是这个hook的具体实现方法,在被调用时,通过key找到这个fn,然后执行这个fn。

Plugin

path变量记录该插件的具体路径,id和key分别是两种插件的标识。

apply方法的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export class Plugin {
apply: Function;

this.apply = () => {
register.register({
implementor: esbuild,
exts: ['.ts', '.mjs'],
});
register.clearFiles();
let ret;
try {
ret = require(this.path);
} catch (e: any) {
throw new Error(
`Register ${this.type} ${this.path} failed, since ${e.message}`,
);
} finally {
register.restore();
}
// use the default member for es modules
return ret.__esModule ? ret.default : ret;
};
}

它的作用就是根据字符串类型的path把这个插件给导入进来,成为一个可执行的模块。

此外,还有一个重要的方法,getPluginsAndPresets(),该方法是静态的,意味着可以直接通过类名来调用,而不用实例化,其实就是一个独立的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
static getPluginsAndPresets(opts: {
cwd: string;
pkg: any;
userConfig: any;
plugins?: string[];
presets?: string[];
prefix: string;
}) {
function get(type: 'plugin' | 'preset') {
const types = `${type}s` as 'plugins' | 'presets';
return [
// opts
...(opts[types] || []), // 从方法参数中
// env
...(process.env[`${opts.prefix}_${types}`.toUpperCase()] || '') //从环境变量中
.split(',')
.filter(Boolean),
...(opts.userConfig[types] || []), // 从用户配置中
].map((path) => {
assert(
typeof path === 'string',
`Invalid plugin ${path}, it must be string.`,
);
let resolved;
try {
resolved = resolve.sync(path, {
basedir: opts.cwd,
extensions: ['.tsx', '.ts', '.mjs', '.jsx', '.js'],
});
} catch (_e) {
throw new Error(`Invalid plugin ${path}, can not be resolved.`);
}

return new Plugin({
path: resolved,
type,
cwd: opts.cwd,
});
});
}

return {
presets: get('preset'),
plugins: get('plugin'),
};
}

该方法从三个地方去找插件:

  • 从方法参数中,即外面调用该方法时,会传入plugins和presets参数;
  • 从环境变量中,即 UMI_PLUGINSUMI_PRESETS 指定的 plugins 和 presets;
  • 从用户配置中,即umi配置文件中指定的plugins和presets;

找到之后,针对每一个插件,实例化一个Plugin对象,最终返回一个plugins和presets的插件列表。

PluginAPI

PluginAPI是Plugin和Service之间的桥梁,我们在编写插件时,主要就是跟PluginAPI打交道,通过PluginAPI提供的各种方法,将相关的功能函数注册到Service对应的数组中去。这些方法分成两种:核心方法和扩展方法,核心方法是指PluginAPI提供的最基础的API,比如描述该插件信息的 describe() 方法,还有将各种对象注册到Service中的 register-*() 等方法,而扩展方法则是通过插件的形式注册到Service中的 pluginMethods 列表中的,而真实对外的PluginAPI实际上是一个PluginAPI的代理,在调用其方法时,会先去Service中的 pluginMethods 中找是否有对应的方法,没有的话,再去PluginAPI中找,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 static proxyPluginAPI(opts: {
pluginAPI: PluginAPI;
service: Service;
serviceProps: string[];
staticProps: Record<string, any>;
}) {
return new Proxy(opts.pluginAPI, {
get: (target, prop: string) => {
if (opts.service.pluginMethods[prop]) {
return opts.service.pluginMethods[prop].fn;
}

......

// @ts-ignore
return target[prop];
},
});
}
}

使用 PluginAPI提供的 registerMethods() 方法,就可以向Service中注册扩展方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
registerMethod(opts: { name: string; fn?: Function }) {
assert(
!this.service.pluginMethods[opts.name],
`api.registerMethod() failed, method ${opts.name} is already exist.`,
);
this.service.pluginMethods[opts.name] = {
plugin: this.plugin,
fn:
opts.fn ||
// 这里不能用 arrow function,this 需指向执行此方法的 PluginAPI
// 否则 pluginId 会不会,导致不能正确 skip plugin
function (fn: Function | Object) {
// @ts-ignore
this.register({
key: opts.name,
...(lodash.isPlainObject(fn) ? (fn as any) : { fn }),
});
},
};
}

可以看到该方法的作用就是向 pluginMethods 对象中添加方法fn,key为方法名,但是需要注意的是 registerMethod()fn 参数是可选的,即可以只指定一个方法名 name 参数,而不指定 fn 参数, 这种情况下,会注册一个默认的方法,而这个方法的定义是输入另一个 fn 参数,然后将该 fn 通过PluginAPI的 register() 方法注册到Service中,我们先来看看这个 register() 方法:

1
2
3
4
5
6
7
8
9
10
register(opts: Omit<IHookOpts, 'plugin'>) {
assert(
this.service.stage <= ServiceStage.initPlugins,
'api.register() should not be called after plugin register stage.',
);
this.service.hooks[opts.key] ||= [];
this.service.hooks[opts.key].push(
new Hook({ ...opts, plugin: this.plugin }),
);
}

可以看到它的作用就是将 opts 中的 fn, key 等参数组成一个Hook对象,然后将该Hook对象添加到Service中的hooks列表中。所以如果 registerMethod() 方法不传 fn 参数的话,那么它注册到Service中的都是同一个 fn,即默认的 fn,它的作用就是向Service中注册hook。来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// umi/package/core/src/service/servicePlugin.ts

import { PluginAPI } from './pluginAPI';

export default (api: PluginAPI) => {
[
'onCheck',
'onStart',
'modifyAppData',
'modifyConfig',
'modifyDefaultConfig',
'modifyPaths',
].forEach((name) => {
api.registerMethod({ name });
});
};

上面就是一个umi内置的核心插件,可以看到它就是调用了 registerMethod(),但是只传递了 name 参数,注册了好几个扩展方法到Service的 pluginMethods 数组中,这些扩展方法的实现都是上面默认的方法,然后再通过PluginAPI的代理,就可以在别的插件中,这样来调用扩展方法了:

1
2
3
4
5
6
7
8
export default (api: IApi) => {
api.onCheck(async () => {

......

});
});
}

这样就将async () => {} 这个方法注册到 onCheck 这个hook列表中了,如果在其他插件中也调用 api.onCheck() 方法,则会向 onCheck hook列表中追加hook,这样就会在 onCheck hook列表中,有很多hook了。

所以通过 registerMethod() 方法注册到 Service pluginMethods 中的方法,通过PluginAPI代理,对外就表现为PluginAPI的API,或者叫插件API,或者叫扩展方法,而这些方法本质上的作用就是向各种Hook列表中注册hook,所以PluginAPI除了自己类中那几个基础的API之外,其它API都是通过扩展方法的方式注册进来的,之后再调用这些扩展方法,也并没有做什么实际的动作,只是简单执行了注册Hook的逻辑。所以这个地方挺绕的,如果把方法名改下我觉得就比较好理解了,比如 onCheck 改为 registerOnCheck,但是注册到service hooks中的key仍然为onCheck,让别人知道 api.onCheck() 仍然是在做注册的事情,比如:

1
2
3
api.registerMethod( {"registerOnCheck"} ); //注册完这个method之后,就可以被api调用了
api.registerOnCheck(async () = {}); //向onCheck hook列表中注册Hook
service.hooks["onCheck"] = [Hook];

那这些注册进来的Hook在哪又是怎么被使用呢?这就是Service里面的逻辑了。

Service

Service就是一个集大成者了,首先它里面存放了上面介绍到的各种数组,主要有以下几种:

  • commands: 是用来存放所有注册到umi中的命令的,要知道umi中一切都是插件,命令也不例外,比如dev, build等都有对应的插件;
  • plugins:是存放所有注册进来的经过Plugin类实例化过之后的插件的,其中preset也作为一种特殊的插件被存储进来;
  • pluginMethods: 是用来存放插件API的扩展方法的,它以方法名为Key,方法的实现为Value;
  • hooks: 用来存放hook的数组,以方法名为key,value是一个hook列表,相同方法名的hook注册到同一个列表中,形式如:{"hook1": [HookA, HookB], "hook2": [HookC, HookD]}

然后就是它启动服务的 run() 方法了,在该方法中大致步骤如下:

  1. 读取用户配置;
  2. 从各个地方找插件;
  3. 进行插件的初始化;
  4. 调用启动服务的各种hook方法,比如onCheck, onStart等;
  5. 执行相应command对应的方法,比如 dev, build等;

其关键步骤如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
async run(opts: { name: string; args?: any }) {

......

// 从各个地方查找插件
const { plugins, presets } = Plugin.getPluginsAndPresets({
cwd: this.cwd,
pkg,
plugins: [require.resolve('./generatePlugin')].concat(
this.opts.plugins || [],
),
presets: [require.resolve('./servicePlugin')].concat(
this.opts.presets || [],
),
userConfig: this.userConfig,
prefix,
});

......

// 注册presets和plugins
const presetPlugins: Plugin[] = [];
while (presets.length) {
await this.initPreset({
preset: presets.shift()!,
presets,
plugins: presetPlugins,
});
}

plugins.unshift(...presetPlugins); // unshift() 插入到数组的最前面

while (plugins.length) {
await this.initPlugin({ plugin: plugins.shift()!, plugins });
}

const command = this.commands[name];

......

// 调用各种hook
this.paths = await this.applyPlugins({
key: 'modifyPaths',
initialValue: paths,
});

......

await this.applyPlugins({
key: 'onCheck',
});

await this.applyPlugins({
key: 'onStart',
});

// 执行command对应的方法
let ret = await command.fn({ args });

......
}

1. 获取插件

首先就是调用 Plugin.getPluginsAndPresets() 从各个地方找插件,这个方法在上面Plugin小节就介绍过,此处还通过参数分别传递了一个preset和一个plugin,./servicePlugin 这个里面就是去注册一些核心的插件API到Service中,比如onCheck, onStart等,而./generatePlugin则是注册一个generate命令,这些都是最基础的方法,会被其他插件调用到的,所以放到了这里来进行注册。

2. 插件初始化

获取到插件之后,接着就会对presets和plugins进行初始化,首先会从presets中获取到它里面包含的所有的plugins,然后将presets中的plugins添加到Service本身中的plugins数组中,需要注意的是,presets中的plugins会被插入到数组的最前面。然后再依次遍历Service中的plugins中的Plugin,挨个进行初始化。

初始化是调用initPlugin()方法对某个Plugin进行初始化的,在该方法中,会为该Plugin创建PluginAPI对象,然后再为该PluginAPI对象创建代理,然后导入并执行该插件,其主要逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async initPlugin(opts: {
plugin: Plugin;
presets?: Plugin[];
plugins: Plugin[];
}) {
this.plugins[opts.plugin.id] = opts.plugin;
const pluginAPI = new PluginAPI({
plugin: opts.plugin,
service: this,
});
const proxyPluginAPI = PluginAPI.proxyPluginAPI({
service: this,
pluginAPI,
......
});
let ret = await opts.plugin.apply()(proxyPluginAPI);
......
}

其中 opts.plugin.apply()(proxyPluginAPI) 是最关键的,apply()方法在上面Plugin小节就介绍过,是将Plugin的模块导入进来,导入进来之后,就直接传递了 proxyPluginAPI 参数进行执行,所以这次我们再来看看上面的插件示例,是不是就可以理解插件为什么要这么写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { IApi } from 'umi';

export default (api: IApi) => {
api.describe({
key: 'changeFavicon',
config: {
schema(joi) {
return joi.string();
},
},
enableBy: api.EnableBy.config
});
api.modifyConfig((memo)=>{
memo.favicon = api.userConfig.changeFavicon;
return memo;
});
};

这里插件传递的参数api,其实就是proxyPluginAPI,然后所谓的导入插件并执行,其实就是在执行api.describe()api.modifyConfig()等插件API的方法,而这些方法就是上面PluginAPI小节介绍的,是插件API的核心方法或者是扩展方法,是用来向Service中注册各种Hook的。

3. 调用hook

接下来就是调用onCheck, onStart等hook去执行启动的相关任务了,调用hook是通过Service提供的applyPlugins()来实现的,其关键逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// overload, for apply event synchronously
applyPlugins<T>(opts: {
key: string;
type?: ApplyPluginsType.event;
initialValue?: any;
args?: any;
sync: true;
}): typeof opts.initialValue | T;
applyPlugins<T>(opts: {
key: string;
type?: ApplyPluginsType;
initialValue?: any;
args?: any;
}): Promise<typeof opts.initialValue | T>;
applyPlugins<T>(opts: {
key: string;
type?: ApplyPluginsType;
initialValue?: any;
args?: any;
sync?: boolean;
}): Promise<typeof opts.initialValue | T> | (typeof opts.initialValue | T) {

const hooks = this.hooks[opts.key] || [];
let type = opts.type;
switch (type) {
case ApplyPluginsType.add:
for (const hook of hooks) {
if (!this.isPluginEnable(hook)) continue;
tAdd.tapPromise(
{
name: hook.plugin.key,
stage: hook.stage || 0,
before: hook.before,
},
async (memo: any) => {
const dateStart = new Date();
const items = await hook.fn(opts.args);
hook.plugin.time.hooks[opts.key] ||= [];
hook.plugin.time.hooks[opts.key].push(
new Date().getTime() - dateStart.getTime(),
);
return memo.concat(items);
},
);
}
case ApplyPluginsType.modify:
......
case ApplyPluginsType.event:
......
}

这里涉及到typescript的知识点,叫 overloads,这个有点类似于Java, C++等语言的多态,就是同一个方法名,但是可以接受不同的参数,返回不同的值。可以看到在方法的实现里面,首先会从hooks对象中取出对应key的hook列表,然后遍历这个hook列表,去执行每个hook中的fn方法,而这些fn方法就是之前各种插件注册进来的自己插件的相关逻辑。当然,在其他地方,比如其他的插件里,也可以通过调用 applyPlugins() 方法来执行注册到Service中的某一个hook。

4. 执行command

最后一步就是执行对应的command,这些command也是通过插件注册到Service中的commands数组中的,通过name找到这个command,然后执行其中的fn:

1
2
const command = this.commands[name];
let ret = await command.fn({ args });

内置插件

了解了上面介绍到的“微内核”的实现原理,我们来大概看下umi内置的插件都有哪些,是怎么传递进去的。首先就是 core Service 中传递进去的plugin,上面在介绍 Service run() 方法时,也提到了,core中的Service会从各个地方去找插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async run(opts: { name: string; args?: any }) {
// 从各个地方查找插件
const { plugins, presets } = Plugin.getPluginsAndPresets({
cwd: this.cwd,
pkg,
plugins: [require.resolve('./generatePlugin')].concat(
this.opts.plugins || [],
),
presets: [require.resolve('./servicePlugin')].concat(
this.opts.presets || [],
),
userConfig: this.userConfig,
prefix,
});
}

generatePluginservicePlugin 这两个就是core中内置的plugin和preset,提供最基础的功能,比如在 servicePlugin 中提供了 onCheck, onStart, modifyConfig 等基础的插件API,而在 generatePlugin 中提供了umi的generate命令的实现。

这两个就是core层面内置的插件了,在往上一层,就是在 umi/package/umi/src/service/service.ts 中的 Service中,它继承了 core中的Service,在这个umi Service这个子类的构造方法中,又传递了presets和plugins:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export class Service extends CoreService {
constructor(opts?: any) {
debugger;
process.env.UMI_DIR = dirname(require.resolve('../../package'));
const cwd = getCwd();
// Why?
// plugin import from umi but don't explicitly depend on it
// and we may also have old umi installed
// ref: https://github.com/umijs/umi/issues/8342#issuecomment-1182654076
require('./requireHook');
super({
...opts,
env: process.env.NODE_ENV,
cwd,
defaultConfigFiles: DEFAULT_CONFIG_FILES,
frameworkName: FRAMEWORK_NAME,
presets: [require.resolve('@umijs/preset-umi'), ...(opts?.presets || [])],
plugins: [
existsSync(join(cwd, 'plugin.ts')) && join(cwd, 'plugin.ts'),
existsSync(join(cwd, 'plugin.js')) && join(cwd, 'plugin.js'),
].filter(Boolean),
});
}
}

可以看到它传递了 @umijs/preset-umi 这个模块中的 preset,以及umi项目根目录中的 plugin.ts,作为presets和plugins参数,关于umi项目根目录的这个plugin.ts,在umi的官方文档中有介绍,这可能是一个项目要实现自己的插件,最简单方便的方式了吧,直接在项目根目录放一个这个文件就好了,然后我们来看看这个 @umijs/preset-umi preset中都包含了哪些plugin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
export default () => {
return {
plugins: [
// registerMethods
require.resolve('./registerMethods'),

// features
require.resolve('./features/appData/appData'),
require.resolve('./features/check/check'),
require.resolve('./features/clientLoader/clientLoader'),
require.resolve('./features/configPlugins/configPlugins'),
require.resolve('./features/crossorigin/crossorigin'),
require.resolve('./features/depsOnDemand/depsOnDemand'),
require.resolve('./features/devTool/devTool'),
require.resolve('./features/esmi/esmi'),
require.resolve('./features/favicons/favicons'),
require.resolve('./features/mock/mock'),
require.resolve('./features/polyfill/polyfill'),
require.resolve('./features/polyfill/publicPathPolyfill'),
require.resolve('./features/routePrefetch/routePrefetch'),
require.resolve('./features/ssr/ssr'),
require.resolve('./features/terminal/terminal'),
require.resolve('./features/tmpFiles/tmpFiles'),
require.resolve('./features/tmpFiles/configTypes'),
require.resolve('./features/transform/transform'),
require.resolve('./features/lowImport/lowImport'),
require.resolve('./features/vite/vite'),
require.resolve('./features/apiRoute/apiRoute'),
require.resolve('./features/monorepo/redirect'),
require.resolve('./features/clickToComponent/clickToComponent'),

// commands
require.resolve('./commands/build'),
require.resolve('./commands/config/config'),
require.resolve('./commands/dev/dev'),
require.resolve('./commands/help'),
require.resolve('./commands/lint'),
require.resolve('./commands/setup'),
require.resolve('./commands/version'),
require.resolve('./commands/generators/page'),
require.resolve('./commands/generators/prettier'),
require.resolve('./commands/generators/tsconfig'),
require.resolve('./commands/generators/jest'),
require.resolve('./commands/generators/tailwindcss'),
require.resolve('./commands/generators/dva'),
require.resolve('./commands/generators/component'),
require.resolve('./commands/generators/mock'),
require.resolve('./commands/generators/cypress'),
require.resolve('./commands/generators/api'),
require.resolve('./commands/plugin'),
require.resolve('./commands/verify-commit'),
require.resolve('./commands/preview'),
],
};
};

这个里面的plugin分为三类,第一类就是通过 registerMethod() 方法,注册了一堆 methods 进去,这些 methods 都会变成 插件API,即PluginAPI,的扩展方法,umi官方给出了插件API的文档,对里面每个扩展方法的作用做了介绍:插件API,所以了解了上面插件实现的原理,就可以容易读懂这个文档了,就可以happy的照着文档去开发插件了。

剩下两个,一个是features,一个是commands,features就是内置的一些实用性的插件了,这些插件也都是相对通用基础的一些插件,而commands是注册进去了很多umi的命令行插件,这两个先不细说了。

这样在执行 umi dev, umi build 等命令时,就会先去实例化umi中的这个Service,会将这些插件,以及 core Service 中的插件一起注册进去,这些内置插件就构成了umi这个项目本身提供的一些基础功能了,而在 umi 4.x 中,又新出了一个概念,叫做 Umi Max,名字听起来挺玄乎的,但实际上,它非常简单,就是在umi内置插件的基础上,又额外加了一些插件,这些插件是阿里蚂蚁集团内部根据工程实践经验,积累出来的一套插件,将他们集成到了一起,叫做 Umi Max,让你有种开箱即用,拎包入住的体验,那max又是怎么将插件传递进去的呢?非常简单:

1
2
3
4
5
6
7
8
// umi/package/max/src/cli.ts
import { run } from 'umi';
run({
presets: [require.resolve('./preset')],
}).catch((e) => {
console.error(e);
process.exit(1);
});

而这个run()方法来自于umi的cli:

1
2
3
4
5
6
7
// umi/package/umi/src/cli/cli.ts

export async function run(opts?: IOpts) {
if (opts?.presets) {
process.env.UMI_PRESETS = opts.presets.join(',');
}
}

Umi Max 中的插件是通过 UMI_PRESETS 环境变量传递进去的,这样在上面介绍到的 Plugin.getPluginsAndPresets() 方法中,就可以从该环境变量中获取到max的preset了,那来看看max的注册进来的preset,都有哪些插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// umi/packages/max/src/preset.ts

export default () => {
return {
plugins: [
require.resolve('@umijs/plugins/dist/access'),
require.resolve('@umijs/plugins/dist/analytics'),
require.resolve('@umijs/plugins/dist/antd'),
require.resolve('@umijs/plugins/dist/dva'),
require.resolve('@umijs/plugins/dist/initial-state'),
require.resolve('@umijs/plugins/dist/layout'),
require.resolve('@umijs/plugins/dist/locale'),
require.resolve('@umijs/plugins/dist/mf'),
require.resolve('@umijs/plugins/dist/model'),
require.resolve('@umijs/plugins/dist/moment2dayjs'),
require.resolve('@umijs/plugins/dist/qiankun'),
require.resolve('@umijs/plugins/dist/request'),
require.resolve('@umijs/plugins/dist/tailwindcss'),
require.resolve('./plugins/maxAlias'),
require.resolve('./plugins/maxAppData'),
require.resolve('./plugins/maxChecker'),
],
};
};

大部分都是 @umijs/plugins 这个项目中的插件,包含权限管理access、布局layout、UI组件antd、数据流管理dva、国际化locale等等,这些就是平时前端开发会经常用到的,相比umi内置的基础的插件更上层的一些功能插件了。

总结

本篇文章大致介绍了umi这个框架的实现原理,梳理了下它的脉络,重点在于理清楚它的插件机制是如何实现的,方便给插件开发者以及使用者在umi原理层面有个认知,以能够更胸有成竹的做umi相关的开发,做到知其然且知其所以然。从umi这个“微内核”架构的实现原理上来看,这个设计还是相当不错的,代码质量也非常的高,真的做到了“一切即插件”,开发粒度可粗可细,并且提供了开箱即用的各种高级功能插件,规范和提升开发效率,真的是非常赞的一个框架。

作者

hackerain

发布于

2022-08-07

更新于

2023-03-11

许可协议