likes
comments
collection
share

组件开发Node Cli工具实战教学

作者站长头像
站长
· 阅读数 17

仓库地址:github.com/gayyou/lego…

什么是Node Cli工具?

Cli工具(Command Line Interface)是在命令行界面下与用户交互的工具,用户通过键盘输入指令,计算机接收到指令之后启动工具应用,从而执行命令封装逻辑,vue-cli、git、cra等都是常见的Cli工具。

组件Cli工具开发

本Cli是一个组件开发脚手架工具,而组件又是组成页面的“积木”,所以草率地将本Cli工具命名为lego-cli

lego-cli能力

组件生命周期包含组件创建、组件开发、组件发布,cli分别为这三个生命周期提供指令支持,三个指令分别是:

  • lego create:用户使用lego create时,通过对话形式创建组件项目模板。
  • lego build:使用lego build后会基于webpack构建项目;
  • lego publish:做版本升级和npm包发布

如果后续做得成熟了,那么其他相应配套能力都要有,如组件版本管理、组件调试等等。

基础配置

项目初始化

首先我们通过npm创建一个仓库:

npm init

输入命令后,我们按照提示填写内容,最终可以获得到下面的package.json文件:

{
  "name": "lego-cli",
  "version": "1.0.0",
  "description": "node cli for component development",
  "main": "dist/index.js",
  "scripts": {
    "test": "npm run test"
  },
  "keywords": [
    "cli",
    "component",
    "cli",
    "node"
  ],
  "author": "Weybn",
  "license": "MIT"
}

接下来我们在package.json添加bin字段,并在项目创建bin/lego.js文件,此处作用是lego输入时,系统会寻找./bin/lego.js文件:

{
  "name": "lego-cli",
  "version": "1.0.0",
  "description": "node cli for component development",
  "main": "dist/index.js",
  "bin": {
    "lego": "./bin/lego.js"
  },
  "scripts": {
    "test": "npm run test"
  },
  "keywords": [
    "cli",
    "component",
    "cli",
    "node"
  ],
  "author": "Weybn",
  "license": "MIT"
}

lego.js文件内容为:

#! /usr/bin/env node

console.log('lego is start!');

接下来输入npm link将命令链接到全局并输入lego,命令行会执行lego.js文件,此时一个简单的命令行工具就完成了!

组件开发Node Cli工具实战教学

项目配置

lego-cli开发时基于ts开发,以下步骤解决ts卡法环境问题。

  1. 首先在项目中添加tsconfig.json文件:
{
  "include": [
    "src"
  ],
  "compilerOptions": {
    // 产物输出文件目录
    "outDir": "./esm",
    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs",                                /* Specify what module code is generated. */
    "rootDir": "./src",                                  /* Specify the root folder within your source files. */
    "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  }
}
  1. 接下来安装typescript依赖,并且设置命令:
{
  "name": "lego-cli",
  "version": "1.0.0",
  "description": "node cli for component development",
  "main": "dist/index.js",
  "bin": {
    "lego": "./bin/lego.js"
  },
  "scripts": {
    "build": "rm -rf ./esm && tsc",
    "watch": "rm -rf ./esm && tsc --watch"
  },
  "keywords": [
    "cli",
    "component",
    "cli",
    "node"
  ],
  "author": "Weybn",
  "license": "MIT",
  "devDependencies": {
    "typescript": "^5.0.4"
  }
}
  1. src/index.ts文件中写入代码
console.log('hello typescript');
  1. 同时修改./bin/lego.js文件
#! /usr/bin/env node

// ts编译产物会输出在这个文件中,require这个文件启动命令
require('../esm/index.js');

此时通过npm run build就可以看到结果了,此时开发环境已经搭建好了:

组件开发Node Cli工具实战教学

命令行辅助工具

node有许多命令行辅助工具,如解析用户命令行输入的sade库,命令行对话工具inquirer,提供输出颜色控制的chalk库,提供编译进度条提示的progress-estimator库。

Sade

一般情况下命令行工具程序如果要获取用户输入内容,编写解析命令代码,sade则是完成这一项任务的工具库。sade非常容易使用:

import sade, {Sade} from 'sade';

const prog = sade('lego');

prog.command('create <name>')
  .describe('Create a component project')
  .option('-t, --type', 'JavaScript or TypeScript', 'typescript')
  .example('build src build --global --config my-conf.js')
  .example('build app public -o main.js')
  .action((name, opts) => {
    console.log(`> create component name ${name}`);
    console.log('> these are extra opts', opts);
  });

// 解析命令行内容  
prog.parse(process.argv);

当用户在命令行中输入lego create demo --type=javascript,那么会输出:

组件开发Node Cli工具实战教学

chalk

chalk是一个命令行文本高亮工具,内置多种颜色,可直接通过chalk.xxx(文本内容)修改文本颜色,如本项目的Logo就是使用chalk修改颜色:

组件开发Node Cli工具实战教学

inquirer

提供命令行交互工具,可以理解为命令行中的“表单”。 组件开发Node Cli工具实战教学

项目结构设计

lego目前只有createbuildpublish命令,复杂度不高。所以把所有代码都放到同一个文件并没有什么问题。但是如果这么做的话欠缺设计,如果后续多了版本回滚、代码下载等等其他功能,那么很有可能需要做一版重构。所以我将lego项目架构分成action层(即控制层)、service层、api层(如有):

  • action层:根据命令业务逻辑调用一个或者多个service能力;
  • service层:提供原子能力,如构建、发布、版本变更等闭环原子能力;
  • api层:封装调用服务能力,lego暂时不涉及;
├── bin
│   └── lego.js
├── src
│   ├── actions # 控制层代码
│   │   ├── build.ts
│   │   ├── create.ts
│   │   ├── index.ts
│   │   ├── prog.ts
│   │   └── publish.ts
│   ├── index.ts
│   ├── services # 原子业务模块
│   │   ├── build.ts # 构建相关逻辑
│   │   ├── create.ts # 创建项目相关逻辑
│   │   ├── index.ts 
│   │   └── publish.ts # 发布相关逻辑
│   └── utils # 工具函数
│       └── index.ts
├── tsconfig.json
├── package-lock.json
└── package.json

命令行开发

组件创建

组件创建分为三个步骤:项目模板拷贝、项目内容填充、依赖安装,具体流程图如下:

组件开发Node Cli工具实战教学

  1. 用户对话框

组件创建时需要填写一些必填信息,如组件名称、作者、组件描述等信息,用于后续信息填充。这里使用到inquirer作为信息填写表单工具。

// ./src/services/create.ts
import inquirer from 'inquirer';

const prompt = inquirer.createPromptModule();

async function callPrompt(options: any) {
  if (!Array.isArray(options)) {
    return prompt([options])
  }
  return prompt(options);
}

const namePrompt = {
  type: 'input',
  name: 'name',
  message: '请输入组件名称',
  validate: async (name: string) => {
    if (name.length === 0) {
      return '组件名不能为空';
    }

    if (name[0].charCodeAt(0) < '9'.charCodeAt(0) && name[0].charCodeAt(0) > '0'.charCodeAt(0)) {
      return '组件名不能以数字开头';
    }

    if (!/^[A-Za-z0-9-]+$/.test(name)) {
      return '请输入英文、数字和横线组合名称';
    }

    return true;
  },
};

const authorPrompt = {
  type: 'input',
  name: 'author',
  message: '请输入作者',
  validate: async (name: string) => {
    if (name.length === 0) {
      return '作者不能为空';
    }

    return true;
  },
};

const descPrompt = {
  type: 'input',
  name: 'desc',
  message: '请输入描述',
}

const prompts = [
  namePrompt,
  authorPrompt,
  descPrompt,
]

export async function create() {
  // 1. 获取用户填写信息
  const info = await callPrompt(prompts);
  console.log(info);
}

效果如下:

组件开发Node Cli工具实战教学

  1. 项目模板拷贝 & 填充信息

我们在lego项目中创建一个react项目模板,用于提供样板代码:

└── templates
│   └── react
│       ├── assets
│       │   └── logo192.png
│       ├── package.json
│       ├── src
│       │   ├── index.css
│       │   ├── index.tsx
│       │   └── types.ts
│       ├── tsconfig.json
│       └── webpack.config.js

接下来把这个模板使用zip压缩起来,用于创建项目时使用。创建项目时先做代码解压拷贝操作,此处我们引入解压工具包compressing解压zip文件,并做项目解压拷贝:

// ./src/utils/index.ts
import compressing from 'compressing';

export const unzip = async (source: string, targetDir: string) => {
  return compressing.zip.decompress(source, targetDir);
}


async function initTemplate(projectPath: string, {name, desc, author}: { name: string; author: string; desc: string }) {
  // 1. 解压代码
  await unzip(path.resolve(__dirname, `../../../templates/react.zip`), projectPath);
  // 2. 移动代码
  await copy(path.resolve(projectPath, 'react'), projectPath, {
    recursive: true
  });
  await rm(path.resolve(projectPath, 'react'), {
    recursive: true
  });
}

接下来根据用户填写内容补充package.json文件:

import {readJSONSync, realpath, writeJSONSync, copy, rm} from 'fs-extra';
import {unzip} from "../../utils";

async function initTemplate(projectPath: string, {name, desc, author}: { name: string; author: string; desc: string }) {
  // 1. 解压代码
  await unzip(path.resolve(__dirname, `../../../templates/react.zip`), projectPath);
  // 2. 移动代码
  await copy(path.resolve(projectPath, 'react'), projectPath, {
    recursive: true
  });
  await rm(path.resolve(projectPath, 'react'), {
    recursive: true
  });

  // 3. 填充信息
  const pkgJsonAddress = path.resolve(projectPath, 'package.json');
  const pkgJson = readJSONSync(pkgJsonAddress);

  Object.assign(pkgJson, {
    name: `@lego-component/${name}`,
    author,
    description: desc,
  });

  writeJSONSync(pkgJsonAddress, pkgJson, {
    spaces: 2
  });
}
  1. 依赖安装

项目初始化完毕后,需要安装项目依赖,此时需要通过node去调用其他命令行能力,这里使用到execa库,并做一次浅封装:

export const execCmd = async (cmd: string[]) => {
  await execa(cmd[0], cmd.slice(1));
};

在项目中调用依赖安装命令:

export async function create() {
  // ...code
  await execCmd(['npm', 'install']);
}

到这里,一个react组件创建命令行工具已经完成了,我们看一下效果:

组件开发Node Cli工具实战教学

组件构建

创建完组件之后,接下来需要提供组件调试、构建能力,lego调试、构建能力时基于webpack实现,所以此时需要在lego项目安装webpack依赖:

npm install webpack

接下来是编译模块代码内容,lego需要提供默认webpack配置,并在lego build命令内部调用webpack完成编译任务:

import webpack from 'webpack';
import path from 'path';
import { existsSync, realpathSync } from "fs-extra";

const resolveApp = function (relativePath) {
  const appDirectory = realpathSync(process.cwd());
  return path.resolve(appDirectory, relativePath);
};

// 默认配置
const defaultConfig = {
  entry: resolveApp('src/index.tsx'),
  output: {
    path: resolveApp('dist'),
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.jsx'],
  },
  externals: {
    react: 'React'
  },
  mode: process.env.NODE_ENV = 'development' ? 'development' : 'production',
  module: {
    rules: [
      {
        test: /.css$/,
        use: [
          'css-loader',
        ]
      },
      {
        test: /.png$/,
        type: 'asset/resource',
      },
      {
        test: /.tsx?$/,
        use: [
          'ts-loader'
        ]
      }
    ]
  }
}

export async function build() {
  let webpackConfig;
  const configPath = resolveApp('webpack.config.js');

  if (existsSync(configPath)) {
    webpackConfig = require(configPath);
  } else {
    webpackConfig = defaultConfig;
  }
  webpack(webpackConfig, (err, result) => {
    if (err) {
      console.error(err);
    }
    console.log('编译成功');
  });
}

构建功能就完成了,此时无需在lego组件中安装webpack,同时lego组件也可以没有配置文件。如果有需要,我们可以在webpack基础上实现二次编译配置封装。我们在demo中运行lego build命令,可以看到以下结果:

组件开发Node Cli工具实战教学

发布版本

发布版本会涉及到两个步骤:版本变更、代码构建发布。

  1. 版本变更:根据实际场景,按照semver规范升级版本:

    1. 测试包发布:发布带后缀测试包,如1.0.1-alpha.1
    2. 正式包发布:发布正式包版本,如1.0.1
  2. 代码构建发布:

    1. 代码构建:使用build service
    2. 代码发布:使用npm publish命令发布代码。

版本升级

版本升级遵循semver规范,此处使用了semver作为版本升级工具库,并配备两个对话框帮助做版本升级:

  1. 对话框
const publishTypeChoice = {
  type: 'list',
  message: '请选择发布类型',
  name: 'type',
  choices: [
    { name: '线上版本', value: Type.LATEST, key: '1' },
    { name: '测试版本', value: Type.NEXT, key: '2' },
  ],
};

export enum Type {
  LATEST = 'latest',
  NEXT = 'next',
}

export enum VersionChangeValue {
  major = 'major',
  minor = 'minor',
  patch = 'patch',
}

const versionChangeTypePrompt = {
  type: 'list',
  name: 'reason',
  message: '请选择变更原因',
  // 正式版本需要选择升级的原因。如果要做得更加专业,可以使用changelog + 消费changelog完成版本升级
  when({ type }: { type: Type }) {
    return type === Type.LATEST;
  },
  choices: [
    {
      name: 'patch: 本次修改涉及向下兼容的问题修正',
      value: VersionChangeValue.patch,
      key: '3',
    },
    {
      name: 'minor: 本次修改涉及乡下兼容的功能新增',
      value: VersionChangeValue.minor,
      key: '2',
    },
    {
      name: 'major: 本次修改涉及不向下兼容的API修改',
      value: VersionChangeValue.major,
      key: '1',
    },
  ],
};

export default [
  publishTypeChoice,
  versionChangeTypePrompt,
]
  1. 版本升级代码
import {callPrompt, execCmd, resolveApp} from "../../utils";
import prompts, {Type} from './prompt';
import { writeJSONSync } from 'fs-extra';
import semver from 'semver';

export async function publish() {
  // 1. 通过对话方式获取更新版本
  const { type, reason } = await callPrompt(prompts);
  const pkgJson = require(resolveApp('package.json'))
  let version = pkgJson?.version || '';

  if (type === Type.NEXT) {
    version = semver.inc(version, 'prerelease');
  } else {
    version = semver.inc(version, reason) as string;
  }

  // 版本回写
  pkgJson.version = version;
  writeJSONSync(resolveApp('package.json'), pkgJson, {
    spaces: 2
  });
}

代码发布

版本发布借用npm publish命令完成:

export async function publish() {
  // 1. 通过对话方式获取更新版本
  // ...code

  // 2. 调用发布命令发布代码包
  await execCmd(['npm', 'publish', `--tag=${type}`]);
}

组件开发Cli工具到此就开发完毕了~

仓库地址:github.com/gayyou/lego…

还可以做得更好?

如果一个cli工具做到前面的部分,只能说把基础功能都做完了,但是并不是很完美,100分制的话只能够拿到70分。那么怎样做才能做得更好?起码有两方面可以优化:

  1. 全局能力

如果你是在做一个开放平台的cli工具,那么至少为涉及到sso登录、埋点功能、告警功能。那在上面设计架构的基础上是需要花费较多成本去实现这种切面功能的,比如告警能力,需要开发者在每个可能报错的地方加上try {} catch() {}代码,这样明显浪费时间又不优雅。

  1. 性能优化

如果cli工具代码及依赖特别多,那么在启动时会很明显感觉到工具加载速度慢,此时怎么去做优化呢?

下一篇文章带大家做实现Cli架构升级~