目标
编写如下的代码,使得执行 node cli version
,能够正确打印当前版本号。
import { printHelp, yParser } from "@umijs/utils";
import { Service } from "./service";
export async function run() {
const args = yParser(process.argv.slice(2), {
alias: {
version: ["v"],
help: ["h"],
},
boolean: ["version"],
});
console.log(args);
try {
await new Service({ plugins: [require.resolve("./version")] }).run({
name: args._[0],
args,
});
} catch (e: any) {
console.log(e);
printHelp.exit();
process.exit(1);
}
}
run();
手把手构建 playground
- 新建空白文件夹,mkdir konos-core
你可以根据你使用的电脑执行对应的命令来新建一个文件夹,当然最简单的还是在你想要存放的位置使用鼠标右键新建文件夹。
- 初始化 npm 项目 npm init -y
-y
表示 npm cli init 时提出的所有问题,我们都使用默认,因为这些信息都可以在后续的 package.json 中手动修改,所以我喜欢使用 -y
来跳过这些交互,你完全可以按照你自己的喜好来初始化。
- 安装 @umijs/utils 和 father
pnpm i @umijs/utils father
- 新增 father 配置 .fatherrc.ts
father 是一个代码编译包,它提供了很多丰富和实用的配置,来帮助你构建 node 包和组件库,如果你对 father 感兴趣,可以从官网获取所有配置的说明,我们以下配置表示,使用 cjs 的方式,将 src 文件夹构建到 dist。
import { defineConfig } from 'father';
export default defineConfig({
cjs: {
output: 'dist',
},
});
- 增加执行命令 package.json 中增加 scripts
"scripts": {
"build": "father build",
"dev": "father dev",
"test": "node dist/cli version"
},
- 增加 cli 主入口文件,新建文件 src/cli.ts
import { printHelp, yParser } from "@umijs/utils";
import { Service } from "./service";
export async function run() {
const args = yParser(process.argv.slice(2), {
alias: {
version: ["v"],
help: ["h"],
},
boolean: ["version"],
});
console.log(args);
try {
await new Service({ plugins: [require.resolve("./version")] }).run({
name: args._[0],
args,
});
} catch (e: any) {
console.log(e);
printHelp.exit();
process.exit(1);
}
}
run();
- 增加 version 插件,新建文件 src/version
export default (api: any) => {
api.registerCommand({
name: "version",
alias: "v",
description: "show konos version",
fn({}) {
const version = require("../package.json").version;
console.log(`konos@${version}`);
return version;
},
});
};
- 新建一个自定义服务,新建文件 src/service
// 请手写这个类
export class Service {
constructor(opts?: any) {}
async run(opts: { name: string; args?: any }) {}
}
如果你的 playground 构建正确的话,你可以先执行 pnpm build
构建当前的代码,然后执行 pnpm test
。你应该可以在窗口中看到类似 { _: [ 'version' ] }
的日志输出。如果你没有看到相应的日志,也不确定哪个步骤出现错误,你可以从konos-core init question这里开始,因为包含 cli 初始化的部分,不是这次的重点。
分析
通过观察分析上面的目标,我们不难发现,我们需要实现插件机制,并且实现一个插件 api - registerCommand。
实践
为了便于理解,在这里我们写一个最简单的用例。
首先我们将初始化的配置,保存在 service 实例中,便于类中其他方法获取。
export class Service {
+ opts = {};
constructor(opts?: any) {
+ this.opts = opts;
}
async run(opts: { name: string; args?: any }) {}
}
初始化配置中的 plugins,真实场景中其实和这里是类似的,不过会添加一些约定和内置的插件,还有插件集声明的插件。
const { plugins = [] } = this.opts as any;
// 真实情况下,取到各种来源的 plugins 然后将它们合并到 plugins
while (plugins.length) {
await this.initPlugin({ plugin: plugins.shift()! });
}
这里我们取到的 plugin 是插件对应的文件路径,类似 /Users/congxiaochen/Documents/konos-core/dist/version.js
所以我们要先获取到它的真实方法,这里写一个简单的工具类来实现。
async getPlugin(plugin: string) {
let ret;
try {
ret = require(plugin);
} catch (e: any) {
throw new Error(
`插件 ${plugin} 获取失败,可能是文件路径错误,详情日志为 ${e.message}`
);
}
return ret.__esModule ? ret.default : ret;
}
然后我们就可以在 initPlugin 函数中,使用 getPlugin 来获取到真实的插件对象了。
async initPlugin(opts: { plugin: any }) {
let ret = await this.getPlugin(opts.plugin);
ret();
}
这样我们就执行了所有的插件对象了。是不是比想象中的要简单呢?
可以简单的测试一下,在 src/version.ts
中添加简单的日志。
export default (api: any) => {
+ console.log('执行了 version 插件');
api.registerCommand({
name: "version",
alias: "v",
description: "show konos version",
fn({}) {
const version = require("../package.json").version;
console.log(`konos@${version}`);
return version;
},
});
};
执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:
{ _: [ 'version' ] }
执行了 version 插件
TypeError: Cannot read properties of undefined (reading 'registerCommand')
其实这个错误日志很明显了,因为我们调用了 api.registerCommand
,而在执行 ret()
的时候,我们并没有传入任何参数。
有些朋友可能到这里才恍然大悟,“原来 umi 插件就是一个传入了插件 api 的普通函数”。
比如,我们可以简单的传入一个对象,用来兜底插件 api,这个小技巧在 umi 插件开发测试的时候也用得上。
const pluginApi = {
registerCommand: (option) => {
console.log(option);
//
},
};
ret(pluginApi);
执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:
执行了 version 插件
{
name: 'version',
alias: 'v',
description: 'show konos version',
fn: [Function: fn]
}
我们简单的实现一下“注册命令”,将命令和对应的 fn 保存起来。
let commands = {};
const pluginApi = {
registerCommand: (option) => {
const { name } = option;
commands[name] = option;
},
};
ret(pluginApi);
因为我们注册完命令,需要在 run 中执行,因此我们可以将 commands 保存在 service 类中。
export class Service {
opts = {};
+ commands: any = {};
}
然后整理一下,上面的 pluginApi
export interface IOpts {
name: string;
description?: string;
options?: string;
details?: string;
alias?: string;
fn: {
({ args }: { args: yParser.Arguments }): void;
};
}
class PluginAPI {
service: Service;
constructor(opts: { service: Service }) {
this.service = opts.service;
}
registerCommand(opts: IOpts) {
const { alias } = opts;
delete opts.alias;
const registerCommand = (commandOpts: Omit<typeof opts, "alias">) => {
const { name } = commandOpts;
this.service.commands[name] = commandOpts;
};
registerCommand(opts);
if (alias) {
registerCommand({ ...opts, name: alias });
}
}
}
整理一下 initPlugin
async initPlugin(opts: { plugin: any }) {
const ret = await this.getPlugin(opts.plugin);
const pluginApi = new PluginAPI({ service: this });
ret(pluginApi);
}
最后在 run 函数里面,找到对应的命令,执行注册的 fn 就可以了。
const { name, args = {} } = opts;
const command = this.commands[name];
if (!command) {
throw Error(`命令 ${name} 执行失败,因为它没有定义。`);
}
let ret = await command.fn({ args });
return ret;
最终的 src/service.ts
文件如下:
import { yParser } from "@umijs/utils";
export class Service {
commands: any = {};
opts = {};
constructor(opts?: any) {
this.opts = opts;
}
async getPlugin(plugin: string) {
let ret;
try {
ret = require(plugin);
} catch (e: any) {
throw new Error(
`插件 ${plugin} 获取失败,可能是文件路径错误,详情日志为 ${e.message}`
);
}
return ret.__esModule ? ret.default : ret;
}
async initPlugin(opts: { plugin: any }) {
const ret = await this.getPlugin(opts.plugin);
const pluginApi = new PluginAPI({ service: this });
ret(pluginApi);
}
async run(opts: { name: string; args?: any }) {
const { plugins = [] } = this.opts as any;
while (plugins.length) {
await this.initPlugin({ plugin: plugins.shift()! });
}
const { name, args = {} } = opts;
const command = this.commands[name];
if (!command) {
throw Error(`命令 ${name} 执行失败,因为它没有定义。`);
}
let ret = await command.fn({ args });
return ret;
}
}
export interface IOpts {
name: string;
description?: string;
options?: string;
details?: string;
alias?: string;
fn: {
({ args }: { args: yParser.Arguments }): void;
};
}
class PluginAPI {
service: Service;
constructor(opts: { service: Service }) {
this.service = opts.service;
}
registerCommand(opts: IOpts) {
const { alias } = opts;
delete opts.alias;
const registerCommand = (commandOpts: Omit<typeof opts, "alias">) => {
const { name } = commandOpts;
this.service.commands[name] = commandOpts;
};
registerCommand(opts);
if (alias) {
registerCommand({ ...opts, name: alias });
}
}
}
执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:
> node dist/cli version
{ _: [ 'version' ] }
执行了 version 插件
konos@1.0.0
闲话
有朋友问我,是不是又要写一个新的系列,其实我也不太清楚这算不算一个新的系列,大概可以归类为《umi 源码阅读》《手写 umi》之类的,但是我并没有将这个系列写完的计划,只是突然想起什么就写一写,所以如果这系列的文章比较多的话,后面再整理成专栏,如果不多的话,就这样吧。
另外去年写的文章太少了,今年想多写一点,数量上去了,质量可能会降低。所以如果你很忙的话,不建议阅读。如果你有什么疑问的话,欢迎评论区交流或者私信我,我很乐意与你成为朋友。