[抛砖引玉] 如何用 Node 构建命令行(CLI) 应用

132 阅读4分钟

先看效果

SCR-20230406-jyw.png

背景

写本文的原因主要由于最近在做一个客户端实时预览系统,需要在开发过程中将 DSL 下发到手机客户端并实时渲染出来,其中需要监听开发过程中的文件变化以及将预览二维码打印在终端。而 Node 生态中相关的工具链已经非常成熟完善,所以最终决定用 Node 来开发整套上传下发流程。

但介于公司隐私政策暂无法将实时渲染系统公开,故通过构建一个可在终端运行展示天气预报信息的应用来抛砖引玉,探讨一下 Node 在工具链亦或是 CLI 应用场景下的无限可能。本文也会对相关用到的库进行简单介绍以及其在该项目中用到了哪些功能。

项目依赖

yargs

NPM 中有繁多的从 CLI 读取参数相关的工具库,如 yargs、commander、minimist、oclif、inquirer 等等。其中有些是读取常规传入的带有中划线的参数 -h -w 进行解析,有些是通过交互的形式进行解析,有些则是集大成者。介于我比较熟悉 yargs 故采用该库对传入参数进行解析。

cli-table3

该库用于在终端展示 Unicode 表格,能比较优秀的在各平台下展示表格的库并不多,cli-table3 算一个。且该库能和终端颜色库 colors 完美配合,故选择该库进行表格展示。

node-fetch

用于获取天气信息,fetch 作为目前所有主流浏览器原生支持的 API 没有理由不用,但在 node 中 18.0 版本才开始支持,故选择 node-fetch 进行 polyfill。

typescript

不赘述了,基本是所有 Node 开发标配了。

rollup

由于该项目较为简单,用 webpack 有点杀鸡用牛刀的感觉,且 rollup 配置相对较为简单,所以选择 rollup 作为打包编译工具。

文件结构

.
├── output
│  └── bundle.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
│  ├── consts.ts
│  ├── entry.ts
│  ├── index.ts
│  └── request.ts
├── tsconfig.json
└── yarn.lock

entry.ts

import yargs from "yargs";
import { run } from ".";

const showHelp = () => {
  const options = yargs(process.argv.slice(2))
    .usage("Fuguang is command-line tool for showing weather next seven days.")
    .usage("Usage: fuguang [OPTION]")
    .option("w", {
      alias: "wind",
      describe: "show wind speed and direction",
      type: "boolean",
      demandOption: false,
    })
    .option("p", {
      alias: "percip",
      describe: "show precipitation",
      type: "boolean",
      demandOption: false,
    })
    .help("h")
    .alias("h", "help")
    .example(
      "fuguang -w",
      "Run the project by given config file in background."
    )
    .version(false).argv;
  return options;
};

const start = async () => {
  const { w, p } = await showHelp();
  run({ w, p });
};

start();

首先设置 yargs, 接收从命令行传来的参数。

  • usage 打印说明信息
  • option 设置需要接收的参数
  • help 打印帮助信息
  • alias 设置别名
  • example 打印出示例

运行后,大体是这个样子。 SCR-20230406-mna.png

index.ts

import { query } from "./request";
import Table from "cli-table3";
import colors from "@colors/colors";
import { WEATHER_CODE, weekDay, WIND_MAP } from "./consts";
const run = async (opt: { w?: boolean; p?: boolean }) => {
  const latitude = "31.11";
  const longitude = "121.37";
  try {
    const weatherData: any = await query({
      latitude,
      longitude,
      daily:
        "weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,windspeed_10m_max,winddirection_10m_dominant",
      timezone: "auto",
    });

    const { daily } = weatherData;
    const {
      time,
      temperature_2m_max,
      temperature_2m_min,
      precipitation_sum,
      weathercode,
      windspeed_10m_max,
      winddirection_10m_dominant,
    } = daily;
    const table = new Table({
      head: [...time.map((t: string) => weekDay(t))],
      style: {
        head: [],
        border: [],
      },
    });
    const cells: string[] = []; // 声明单元格行
    for (let i = 0; i < time.length; i++) {
      const cell: string[] = [];
      const max = colors.red(temperature_2m_max[i]);
      const min = colors.green(temperature_2m_min[i]);
      const precipitation = precipitation_sum[i];
      const windspeed = windspeed_10m_max[i];
      const winddirection = winddirection_10m_dominant[i];
      cell.push(WEATHER_CODE[weathercode[i]]); // 每次 push 代表需要展示的天气信息
      cell.push(`${min}°C ~ ${max}°C`);

      if (opt.w) {
        cell.push(
          `${colors.cyan(WIND_MAP(winddirection))} ${colors.yellow(
            windspeed
          )} km/h`
        );
      }

      if (opt.p) {
        cell.push(colors.dim(precipitation + " mm"));
      }

      cells.push(cell.join("\n")); // 合并所有信息
    }
    table.push(cells); // 插入一行信息
    console.log(
      `Latitude: ${colors.dim(latitude)} Longitude: ${colors.dim(longitude)}`
    );
    console.log(table.toString());
  } catch (err) {
    console.error(err);
  }
};
export { run };

该文件主要用于处理从命令行传来的参数,以及对天气数据请求后返回的数据进行处理,并通过表格的形式展示在终端窗口中。这里主要用到了 cli-table3colors 库。

首先需要创建一个新的 Table 实例,设置一下表头信息。其次按日期循环分别处理每一天需要展示的信息,最后将信息插入到 Table 实例中即可。

request.ts

import fetch from "node-fetch";
import qs from "querystring";

const query = async (opt: Record<string, string>) => {
  const params = qs.unescape(qs.encode(opt));
  const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
  const data = await res.json();
  return data;
};

export { query };

这里用到了 node-fetchapi.open-meteo.com 第三方天气服务商。代码比较简单,大家自行看下即可。

consts.ts

const WEATHER_CODE: Record<number, string> = {
  0: "Clear sky",
  1: "Mainly clear",
  2: "Partly cloudy",
  3: "Overcast",
  45: "Fog",
  48: "Rime fog",
  51: "Light drizzle",
  53: "Moderate drizzle",
  55: "Dense drizzle",
  56: "Light freezing Drizzle",
  57: "Dense freezing Drizzle",
  61: "Slight rain",
  63: "Moderate rain",
  65: "Heavy rain",
  66: "Light freezing rain",
  67: "Heavy freezing rain",
  71: "Slight snow",
  73: "Moderate snow",
  75: "Heavy snow",
  77: "Snow grains",
  80: "Slight rain showers",
  81: "Moderate rain showers",
  82: "Violent rain showers",
  85: "Slight snow showers",
  86: "Heavy snow showers",
};

const inRange = (min: number, max: number, val: number) => {
  return val > min && val < max;
};

const WIND_MAP = (dir: number) => {
  if (dir === 0 || dir === 360) return "↑";
  if (inRange(0, 90, dir)) return "↗";
  if (dir === 90) return "→";
  if (inRange(90, 180, dir)) return "↘";
  if (dir === 180) return "↓";
  if (inRange(180, 270, dir)) return "↙";
  if (dir === 270) return "←";
  if (inRange(270, 360, dir)) return "↖";
  return "";
};

const weekDay = (date: string) => {
  var day = new Date(date);
  return day.toLocaleDateString("en", {
    weekday: "short",
    month: "2-digit",
    day: "2-digit",
  });
};

export { WEATHER_CODE, WIND_MAP, weekDay };

这里主要保存一些常量信息。

package.json

{
  "name": "fuguang",
  "version": "0.0.1",
  "dependencies": {
    "@colors/colors": "^1.5.0",
    "@rollup/plugin-node-resolve": "^15.0.2",
    "@rollup/plugin-typescript": "^11.1.0",
    "@types/node": "^18.15.11",
    "@types/yargs": "^17.0.24",
    "cli-table3": "^0.6.3",
    "node-fetch": "^3.3.0",
    "rollup": "^3.20.2",
    "tslib": "^2.5.0",
    "typescript": "^5.0.3",
    "yargs": "^17.6.2"
  },
  "type": "module",
  "scripts": {
    "dev": "tsx watch ./index.ts",
    "start": "npx rollup -w --config rollup.config.js",
    "build": "npx rollup --config rollup.config.js"
  },
  "bin": {
    "fuguang": "output/bundle.js"
  },
  "devDependencies": {
    "tsx": "^3.12.3"
  }
}

rollup.config.js

import rollupTypescript from "@rollup/plugin-typescript";

/**
 * @type {import('rollup').RollupOptions}
 */
const config = {
  input: "./src/entry.ts",
  output: {
    file: "./output/bundle.js",
    format: "es",
    banner:"#!/usr/bin/env node"
  },
  plugins: [rollupTypescript()],
};
export default config;

package.jsonrollup.config.js 按此配置,首次运行时这里务必先完全复制,否则可能会出现编译不过或运行失败的情况,熟悉后可自行调整配置参数。

安装运行

  • 首先,运行 npm run build 或者 yarn run build 进行项目编译。
  • 之后,由于package.json 中声明了 bin 属性,所以可以用 npm link 进行全局安装。
  • 安装后,在命令行输入 fuguang -w 即可在终端展示出未来 7 天的天气预报。

默认是展示上海地区,小伙伴可以自行调整 index.ts 代码中的经纬度信息进行地区修改,亦或是自行修改命令行入参支持经纬度参数。

最后

抛砖引玉,借本文和一个小例子希望帮助到有命令行开发需求的同学,探索 Node 生态在各个领域的可能性。