Umi插件原理解析
Umi是一个企业级的前端开发框架,它本身是一个插件化的系统。插件化的优点是你可以方便的进行能力拓展。在Umi中,这代表着你可以通过插件来扩展项目的编译时与运行时能力,这包括修改代码打包配置,修改启动代码,约定目录结构,修改 HTML 等功能。
这篇文章主要探索Umi中的插件机制,并解答下面一些问题:
- 如何编写Umi的插件?
- Umi的插件化是如何实现的?
- Umi中plugin与preset有什么区别?
- 我们可以通过Umi插件实现哪些功能?
在这之前,你需要有Umi的使用经验,并阅读过Umi插件的相关文档。本篇文章会结合源码进行解析。
插件初始化
由于本文的目的是分析插件原理,所以一些不相关的流程就省略了。直接进入Service类(源码在core/src/service/service.ts 中)的run
方法。首先看看插件是如何初始化的。
// resolve initial presets and plugins
const presetPlugins: Plugin[] = [];
// 遍历 preset 数组,调用 initPreset 方法进行初始化
while (presets.length) {
await this.initPreset({
preset: presets.shift()!,
presets,
plugins: presetPlugins,
});
}
//
plugins.unshift(...presetPlugins);
// 遍历 plugin 数组,调用 initPlugin 进行初始化
while (plugins.length) {
await this.initPlugin({ plugin: plugins.shift()!, plugins });
}
在Umi中有插件(plugin)与插件集(presets)两个概念,插件集用来引入多个插件。
- presets会先于plugins做初始化。
- 一个preset中如果返回了presets或plugins,则返回的presets会接着被初始化,返回的plugins会被插入到插件数组中。
- preset与plugin的唯一区别是:preset中可以注册多个preset与plugin,即上面的第二点,而plugin中不行。
这个区别反映在initPreset()
的实现中:
async initPreset(opts: {
preset: Plugin;
presets: Plugin[];
plugins: Plugin[];
}) {
// preset 也是调用 initPlugin 进行初始化
const { presets, plugins } = await this.initPlugin({
plugin: opts.preset,
presets: opts.presets,
plugins: opts.plugins,
});
opts.presets.unshift(...(presets || [])); // 向传入的presets数组首部插入返回的presets
opts.plugins.push(...(plugins || [])); // 向传入的plugins数组中插入返回的plugins
}
initPlugin()
主要做了下面一些事情:
- 构造需要传入给插件方法的
api
对象,并将一些属性的访问代理到service
对象上 - 执行插件方法(Umi插件本质上就是一个方法,它接收一个
api
参数) - 如果方法的返回值包含presets或plugins,则将它们返回
总结重点:这部分了解了preset与plugin的概念,preset先于plugin初始化,preset中可以注册多个plugin。
插件方法的api参数
Umi插件只有一个参数:一个api
对象。对于实现插件功能来说,它无疑是非常重要的。api
中的核心内容来自三部分:
-
service(core/src/service/service.ts)
- 包括
appData
,userConfig
,applyPlugins
等
- 包括
-
pluginAPI(core/src/service/pluginAPI.ts)
- 包括
describe
,registerCommand
等
- 包括
-
@umijs/preset-umi或其他插件
- @umijs/preset-umi会在其他自定义插件之前被初始化。其内部通过
api.registerMethod
注入了许多方法,如addBeforeBabelPlugins,modifyHTML,onBuildComplete等。这些方法都可以在api
对象中访问。
- @umijs/preset-umi会在其他自定义插件之前被初始化。其内部通过
大家可能会发现Umi插件API中有分为核心API与拓展方法。里面的核心API就来自service
与pluginAPI
对象,而拓展方法都是在插件中通过registerMethod
核心API注册的。也就是说,我们自定义的插件中也是可以注册拓展方法的,并且这个拓展方法还可以在后面注册的其他插件中使用。
下面是代理到service
对象的实现。拓展方法就是注册在这个 service.pluginMethods
中的,这样插件中可以通过api.someMethod
方法直接访问到拓展方法了。
static proxyPluginAPI(opts: {
pluginAPI: PluginAPI;
service: Service;
serviceProps: string[];
staticProps: Record<string, any>;
}) {
return new Proxy(opts.pluginAPI, {
get: (target, prop: string) => {
// 如果存在 service.pluginMethods[prop],则返回其中的 fn 方法
if (opts.service.pluginMethods[prop]) {
return opts.service.pluginMethods[prop].fn;
}
if (opts.serviceProps.includes(prop)) {
const serviceProp = opts.service[prop];
return typeof serviceProp === 'function'
? serviceProp.bind(opts.service)
: serviceProp;
}
if (prop in opts.staticProps) {
return opts.staticProps[prop];
}
return target[prop];
},
});
}
总结重点:插件的本质是一个方法,这个方法有一个唯一参数:api
。api
的核心方法来自service与pluginAPI对象,而它的拓展方法可以通过核心方法registerMethod()
注册。如果一个插件中注册了拓展方法,那么之后注册的所有插件中都可以使用它。拓展方法给Umi带来了极大的拓展性。
拓展方法的注册与执行
那么一个拓展方法是怎么注册?又是如何工作的呢?这里以 modifyConfig 为例。使用示例如下:
import { IApi } from 'umi';
export default (api: IApi) => {
api.describe({
key: 'changeFavicon',
config: {
schema(joi) {
return joi.string();
},
},
enableBy: api.EnableBy.config
});
// 注册 modifyConfig 的钩子(hook)
api.modifyConfig((memo)=>{
memo.favicon = api.userConfig.changeFavicon;
return memo;
});
};
modifyConfig
用来修改Umi配置,上面的示例中修改了默认的favicon属性,从而达到自定义favicon的效果。这里在modifyConfig
里传入了一个函数,我们先简单的理解为它把这个函数插入了一个队列里存了起来,以便后面取出调用。
modifyConfig
是在 core/src/service/servicePlugin.ts 这个插件中定义的,这个插件同样会在其他自定义插件之前注册。内容如下,即调用api.registerMethod
核心方法来注册一些方法。
// service/servicePlugin
export default (api: PluginAPI) => {
[
'onCheck',
'onStart',
'modifyAppData',
'modifyConfig',
'modifyDefaultConfig',
'modifyPaths',
'modifyTelemetryStorage',
].forEach((name) => {
api.registerMethod({ name });
});
};
那何时执行前面插入到队列里的函数呢?在run
方法中需要获取umi配置时就会通过applyPlugins
方法执行modifyConfig
这个API队列里注册的函数。它会把初始值传入到队列里的第一个函数中,函数执行后返回修改后的值,这个值又会接着传递给下一个注册函数,直到所有注册函数执行完,再将最后结果返回。
async resolveConfig() {
const config = await this.applyPlugins({
key: 'modifyConfig',
initialValue,
args: { paths: this.paths },
});
// ...
return { config, defaultConfig };
}
总结重点:一个拓展方法可以通过api.registerMethod()
方法进行注册。在注册后,其他插件中可以通过api.method(fn)
向这个拓展方法中传入回调函数。这些回调函数会在一个特定的时期被取出执行。Umi中的编译时与运行时能力的拓展都是通过拓展方法的方式实现的。
Hook的执行
终于到这一步了。前面向拓展方法中传入的回调函数我们暂且称它为钩子(hook)函数。对于前面的内容,大家可能会疑问,传入的钩子函数具体是怎么执行的呢?是按注册的时间顺序执行吗?入参与返回值又有什么要求呢?
在看具体执行逻辑之前,我们先看看两个与注册相关的核心方法:regiter
与registerMethod
。
register
用来注册一个Hook。它定义了一个数组service.hooks[key]
,每次执行都会向数组中插入一条信息。信息中包括一个函数fn
,before
与state
可以先记在脑中,它们与fn
的执行时机相关。
register(opts: { key: string, fn, before?: string, stage?: number}) {
this.service.hooks[opts.key] ||= [];
this.service.hooks[opts.key].push(
new Hook({ ...opts, plugin: this.plugin }),
);
}
registerMethod
用来注册一个拓展方法,这个方法名是参数name
(name
是唯一的)。其内容存储在
service.pluginMethods[name]
中(🌟前面讲属性代理时有提到)。在不传入fn
参数时,它默认会通过register
方法注册一个Hook。
registerMethod(opts: { name: string; fn?: Function }) {
this.service.pluginMethods[opts.name] = {
plugin: this.plugin,
fn:
opts.fn ||
function (fn) {
this.register({
key: opts.name,
fn
});
},
};
}
所以,当你使用下面的方式注册一个addFoo
方法时,调用api.addFoo(fn)
会发生什么呢?
api.registerMethod({
name: 'addFoo'
})
它会向service.hooks.addFoo
中插入一条数据:
{
key: 'addFoo',
fn: fn
}
这样,我们注册在拓展方法(Hook)中的回调就存在了service.hooks[methodName]
里,后面可以方便的访问。
接下来就是执行Hook了!applyPlugins()
定义在Service类中。applyPlugins()
中会将指定Hook的回调一次性取出执行。执行这些钩子函数使用了Tapable这个库,Tapable支持指定执行顺序、同步异步以及返回值传递等特性(钩子队列里的函数如何执行就靠它了)。
applyPlugins
中有三种不同类型的钩子,通过传入的key以什么开头来区分:
- add("add"开头,如
addBeforeBabelPlugins
) - modify("modify"开头,如
modifyConfig
) - event("on"开头,如
onBeforeCompile
)
每种钩子都可以绑定多个回调,不同类别的钩子回调的执行方式有以下区别:
add
:按照hook函数的顺序依次执行。它们的返回值会按执行顺序拼接成一个数组。最后将这个数组返回。modify
:按照hook函数顺序依次执行。与add不同的是,每一个钩子处理完传入的初始值后,会将这个值传递给下一个钩子继续处理。最后一个钩子的返回值即是最终返回值。这样即达到了modify的效果。event
:按照hook函数顺序依次执行。没有返回值。
applyPlugins()
源码如下,前面提到的register
方法的before
与stage
参数会被透传给Tapable的tabPromise
方法。具体使用方法可以查看官方文档。
applyPlugins(opts){
const hooks = this.hooks[opts.key] || [];
let type = opts.type;
// ... type分为3种类型: add,modify,event
switch (type) {
case ApplyPluginsType.add:
const tAdd = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
tAdd.tapPromise(
{
name: hook.plugin.key,
stage: hook.stage || 0,
before: hook.before,
},
async (memo: any) => {
const items = await hook.fn(opts.args);
return memo.concat(items);
},
);
}
return tAdd.promise(opts.initialValue || []);
case ApplyPluginsType.modify:
const tModify = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
tModify.tapPromise(
{
name: hook.plugin.key,
stage: hook.stage || 0,
before: hook.before,
},
async (memo: any) => {
const ret = await hook.fn(memo, opts.args);
return ret;
},
);
}
return tModify.promise(opts.initialValue);
case ApplyPluginsType.event:
if (opts.sync) {
const tEvent = new SyncWaterfallHook(['_']);
hooks.forEach((hook) => {
if (this.isPluginEnable(hook)) {
tEvent.tap(
{...},
() => {
hook.fn(opts.args);
},
);
}
});
return tEvent.call(1) as T;
}
const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) {
tEvent.tapPromise(
{...},
async () => {
await hook.fn(opts.args);
},
);
}
return tEvent.promise(1);
}
}
总结重点:一个拓展方法就是一个Hook,里面可以注册多个回调。Hook有几种类型:add
,modify
与event
,它们各自的作用不同。Hook的执行依赖了Tapable这个库。
问题回顾
- 如何编写Umi的插件?Umi插件的本质就是一个函数,它接收一个
api
参数,里面包含了核心API,拓展方法,以及一些属性。你可以使用拓展方法注册自定义处理逻辑。 - Umi的插件化是如何实现的?可以从拓展方法的实现去理解:首先注册一个拓展方法(
registerMethod
),之后可以向这个Hook中插入回调(api.someMethod
)。在特定时期,这个拓展方法的回调会被取出执行(applyPlugins
)。值得注意的是,Hook有三种不同类型,它们的作用有所区别。 - Umi中plugin与preset有什么区别?preset可以用来注册一组plugin,仅此区别。
- 我们可以通过Umi插件实现哪些功能?不仅能通过已有的Umi拓展方法来自定义逻辑,还能自己实现其他的拓展方法,并且可以在另外的插件中使用。
转载自:https://juejin.cn/post/7214400156593324091