How to hand-write the core plug-in module of umi

Goal

Write the following code so that the current version number can be printed correctly when node cli version is executed.

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();

Build a playground by hand

  1. Create a new blank folder, mkdir konos-core

You can execute the corresponding command according to the computer you use to create a new folder. Of course, the easiest way is to use the right mouse button to create a new folder where you want to store it.

  1. Initialize npm project npm init -y

-y means all the questions raised by npm cli init, we use the default, because these information can be manually modified in the subsequent package.json, so I like to use -y to skip these interactions, you can initialize it according to your own preferences.

  1. Install @umijs/utils and father
pnpm i @umijs/utils father
  1. Add father configuration in .fatherrc.ts

father is a code compilation package, which provides a lot of rich and practical configurations to help you build node packages and component libraries. If you are interested in father, you can get all configuration instructions from the official website. We use cjs for the following configuration way, build the src folder to dist.

import {<!-- --> defineConfig } from 'father';

export default defineConfig({<!-- -->
  cjs: {<!-- -->
    output: 'dist',
  },
});
  1. Add execution command Add scripts in package.json
 "scripts": {<!-- -->
    "build": "father build",
    "dev": "father dev",
    "test": "node dist/cli version"
  },
  1. Add the cli main entry file and create a new file 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. Add the version plugin and create a new file 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. Create a new custom service, create a new file src/service
// Please write this class by hand
export class Service {<!-- -->
  constructor(opts?: any) {<!-- -->}
  async run(opts: {<!-- --> name: string; args?: any }) {<!-- -->}
}

If your playground is built correctly, you can first execute pnpm build to build the current code, and then execute pnpm test. You should see log output like { _: [ 'version' ] } in the window. If you don’t see the corresponding log, and you are not sure which step is wrong, you can start from the konos-core init question, because the part that includes cli initialization is not the focus of this time.

Analysis

By observing and analyzing the above goals, it is not difficult to find that we need to implement the plug-in mechanism and implement a plug-in api – registerCommand.

Practice

For ease of understanding, here we write the simplest use case.

First, we save the initialized configuration in the service instance, which is convenient for other methods in the class to obtain.

export class Service {
 + opts = {};
  constructor(opts?: any) {
 + this.opts = opts;
  }
  async run(opts: { name: string; args?: any }) {}
}

The plugins in the initialization configuration are actually similar to this in the real scene, but some conventions and built-in plugins, as well as plugins declared by the plugin set, will be added.

const {<!-- --> plugins = [] } = this.opts as any;
// In reality, fetch plugins from various sources and merge them into plugins
while (plugins. length) {<!-- -->
    await this.initPlugin({<!-- --> plugin: plugins.shift()! });
}

The plugin we got here is the file path corresponding to the plugin, similar to /Users/congxiaochen/Documents/konos-core/dist/version.js

So we need to get its real method first, and write a simple tool class here to achieve it.

 async getPlugin(plugin: string) {<!-- -->
    let ret;
    try {<!-- -->
      ret = require(plugin);
    } catch (e: any) {<!-- -->
      throw new Error(
        `Plugin ${<!-- -->plugin} failed to get, maybe the file path is wrong, the detailed log is ${<!-- -->e.message}`
      );
    }
    return ret.__esModule ? ret.default : ret;
  }

Then we can use getPlugin to get the real plug-in object in the initPlugin function.

 async initPlugin(opts: {<!-- --> plugin: any }) {<!-- -->
    let ret = await this. getPlugin(opts. plugin);
    ret();
  }

In this way, we have executed all the plug-in objects. Is it simpler than imagined?

You can simply test it and add a simple log in src/version.ts.

export default (api: any) => {
 + console.log('executed the version plugin');
  api.registerCommand({
    name: "version",
    alias: "v",
    description: "show konos version",
    fn({}) {
      const version = require("../package.json").version;
      console.log(`konos@${version}`);
      return version;
    },
  });
};

Execute pnpm build to build the code, and then execute pnpm test, you will see a log similar to the following:

{<!-- --> _: [ 'version' ] }
Executed the version plugin
TypeError: Cannot read properties of undefined (reading 'registerCommand')

In fact, this error log is very obvious, because we called api.registerCommand, but we did not pass in any parameters when executing ret(). Some friends may come here and suddenly realize, “It turns out that the umi plug-in is just an ordinary function passed in the plug-in api.”

For example, we can simply pass in an object to cover the plug-in api. This trick can also be used when developing and testing umi plug-ins.

const pluginApi = {<!-- -->
    registerCommand: (option) => {<!-- -->
        console. log(option);
        //
    },
};
ret(pluginApi);

Execute pnpm build to build the code, and then execute pnpm test, you will see a log similar to the following:

executes the version plugin
{<!-- -->
  name: 'version',
  alias: 'v',
  description: 'show konos version',
  fn: [Function: fn]
}

Let’s simply implement the “register command” and save the command and the corresponding fn.

let commands = {<!-- -->};

const pluginApi = {<!-- -->
    registerCommand: (option) => {<!-- -->
      const {<!-- --> name } = option;
      commands[name] = option;
    },
};
ret(pluginApi);

Because we have registered the command and need to execute it in run, we can save the commands in the service class.

export class Service {
  opts = {};
 + commands: any = {};
}

Then sort it out, the above 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 });
    }
  }
}

Tidy up initPlugin

 async initPlugin(opts: {<!-- --> plugin: any }) {<!-- -->
    const ret = await this. getPlugin(opts. plugin);
    const pluginApi = new PluginAPI({<!-- --> service: this });
    ret(pluginApi);
  }

Finally, in the run function, find the corresponding command and execute the registered fn.

 const {<!-- --> name, args = {<!-- -->} } = opts;
    const command = this.commands[name];
    if (!command) {<!-- -->
      throw Error(`The command ${<!-- -->name} failed because it is not defined.`);
    }
    let ret = await command. fn({<!-- --> args });
    return ret;

The final src/service.ts file is as follows:

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 ${<!-- -->plugin} failed to get, maybe the file path is wrong, the detailed log is ${<!-- -->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(`The command ${<!-- -->name} failed because it is not defined.`);
    }
    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 });
    }
  }
}

Execute pnpm build to build the code, and then execute pnpm test, you will see a log similar to the following:

> node dist/cli version

{<!-- --> _: [ 'version' ] }
Executed the version plugin
[email protected]

source code archive

Gossip

A friend asked me if I wanted to write a new series. In fact, I don’t know if this is a new series. It can be classified as “umi source code reading” and “handwritten umi”. I have no plan to finish writing this series, I just write what I suddenly think of, so if there are more articles in this series, I will organize them into columns later, if there are not many, then so be it.

In addition, I wrote too few articles last year, and I want to write more this year. If the number increases, the quality may decrease. So not recommended to read if you are busy. If you have any questions, welcome to communicate in the comment area or private message me, I am happy to become friends with you.