如何手写 umi 的核心插件模块

1,832 阅读4分钟

目标

编写如下的代码,使得执行 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
  1. 新建空白文件夹,mkdir konos-core

你可以根据你使用的电脑执行对应的命令来新建一个文件夹,当然最简单的还是在你想要存放的位置使用鼠标右键新建文件夹。

  1. 初始化 npm 项目 npm init -y

-y 表示 npm cli init 时提出的所有问题,我们都使用默认,因为这些信息都可以在后续的 package.json 中手动修改,所以我喜欢使用 -y 来跳过这些交互,你完全可以按照你自己的喜好来初始化。

  1. 安装 @umijs/utils 和 father
pnpm i @umijs/utils father 
  1. 新增 father 配置 .fatherrc.ts

father 是一个代码编译包,它提供了很多丰富和实用的配置,来帮助你构建 node 包和组件库,如果你对 father 感兴趣,可以从官网获取所有配置的说明,我们以下配置表示,使用 cjs 的方式,将 src 文件夹构建到 dist。

import { defineConfig } from 'father';

export default defineConfig({
  cjs: {
    output: 'dist',
  },
});
  1. 增加执行命令 package.json 中增加 scripts
  "scripts": {
    "build": "father build",
    "dev": "father dev",
    "test": "node dist/cli version"
  },
  1. 增加 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();
  1. 增加 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;
    },
  });
};
  1. 新建一个自定义服务,新建文件 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》之类的,但是我并没有将这个系列写完的计划,只是突然想起什么就写一写,所以如果这系列的文章比较多的话,后面再整理成专栏,如果不多的话,就这样吧。

另外去年写的文章太少了,今年想多写一点,数量上去了,质量可能会降低。所以如果你很忙的话,不建议阅读。如果你有什么疑问的话,欢迎评论区交流或者私信我,我很乐意与你成为朋友。