使用 Node 创建真实世界的 CLI 应用程序

2025-06-07

使用 Node 创建真实世界的 CLI 应用程序

本文最初发布于 Timber.io。欢迎了解我们,试用我们的产品,或阅读我们的其他文章。我们是一个由开发者为开发者打造的云端日志平台。减少调试时间,将更多时间用于交付。

命令行是 JavaScript 开发领域中尚未得到足够重视的用户界面。事实上,大多数开发工具都应该有一个 CLI,以便像我们这样的技术宅使用,并且用户体验应该与精心打造的 Web 应用媲美。这包括美观的设计、实用的菜单、清晰的错误信息和输出、加载指示器和进度条等等。

关于使用 Node 构建命令行界面,市面上很少有实际的教程,所以这是系列教程的第一篇,它将超越基本的“hello world”命令行应用。我们将创建一个名为 的应用outside-cli,它可以显示任意地点的当前天气和未来 10 天的天气预报。

屏幕截图 2018-04-26 下午 4.19.16

注意:市面上有不少库可以帮助创建复杂的 CLI,例如oclifyargscommander,但为了方便演示,我们将尽量减少依赖项,以便您更好地理解底层工作原理。本教程假设您具备 JavaScript 和 Node.js 的基本使用知识。

设置项目

与所有 JavaScript 项目一样,创建 package.json 和入口文件是启动项目的最佳方式。我们可以尽量简化——目前还不需要任何依赖项。

包.json
{
  "name": "outside-cli",
  "version": "1.0.0",
  "license": "MIT",
  "scripts": {},
  "devDependencies": {},
  "dependencies": {}
}
Enter fullscreen mode Exit fullscreen mode
index.js
module.exports = () => {
  console.log('Welcome to the outside!')
}
Enter fullscreen mode Exit fullscreen mode

创建 bin 文件

我们需要一种方法来调用我们新创建的应用并显示欢迎消息,并将其添加到系统路径,以便可以从任何地方调用它。bin 文件就是实现这一点的方法。

垃圾桶/外面
#!/usr/bin/env node
require('../')()
Enter fullscreen mode Exit fullscreen mode

没见过#!/usr/bin/env node?这叫做shebang。它的作用是告诉系统这不是一个 shell 脚本,应该使用不同的解释器。

保持二进制文件的精简非常重要,因为它的唯一用途就是调用应用程序。我们所有的代码都应该放在二进制文件之外,这样才能保持模块化和可测试性。如果我们将来想提供对库的编程访问,这也会很有帮助。

为了直接运行 bin 文件,我们需要赋予它正确的文件系统权限。如果您使用的是 UNIX 系统,这很简单,只需运行 即可chmod +x bin/outside。如果您使用的是 Windows 系统,建议您使用 Linux 子系统。

接下来,我们将二进制文件添加到 package.json 文件中。当用户将我们的包作为全局变量 ( ) 安装时,它将自动添加到用户的系统路径中npm install -g outside-cli

包.json
{
  "name": "outside-cli",
  "version": "1.0.0",
  "license": "MIT",
  "bin": {
    "outside": "bin/outside"
  },
  "scripts": {},
  "devDependencies": {},
  "dependencies": {}
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以通过运行 直接调用我们的 bin 文件./bin/outside。您应该会看到欢迎消息。npm link在项目根目录中运行 会将二进制文件符号链接到系统路径,这样就可以通过运行 在任何位置访问它outside

解析命令和参数

运行 CLI 应用时,它由参数和命令组成。参数(或“标志”)是以一个或两个连字符(例如-d,--debug--env production)开头的值,用于将选项传递给应用。命令是所有其他不带标志的值。与命令不同,参数不需要按任何特定顺序指定。例如,我们可以运行并假设第二个命令始终是位置——但如果我们将来想要添加更多选项,那么outside today Brooklyn运行不是更好吗?outside today --location Brooklyn

为了使我们的应用能够正常使用,我们需要解析这些命令和参数,并将它们转换为对象。我们可以尝试process.argv自己动手做,但首先让我们安装第一个依赖项minimist来帮我们处理这个问题。

$ npm install --save minimist
Enter fullscreen mode Exit fullscreen mode
index.js
const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))
  console.log(args)
}
Enter fullscreen mode Exit fullscreen mode

注意:我们之所以删除前两个参数,.slice(2)是因为第一个参数始终是解释器,后跟被解释的文件名。我们只关心其后的参数。

现在运行outside today应该会输出{ _: ['today'] }。如果运行outside today --location "Brooklyn, NY",它应该输出{ _: ['today'], location: 'Brooklyn, NY' }。稍后我们实际使用位置时会更深入地讨论参数,但现在这足以设置我们的第一个命令。

参数语法

为了更好地理解参数语法的工作原理,您可以阅读此文。基本上,标志位可以是单连字符或双连字符,并且会获取命令中紧跟的值,如果命令中没有值则为 true。单连字符标志位也可以组合起来,形成简写布尔值(-a -b -c或者-abc会返回{ a: true, b: true, c: true }.)。

重要的是要记住,如果值包含特殊字符或空格,则必须用引号引起来。运行--foo bar baz会返回{ _: ['baz'], foo: 'bar' },但运行--foo "bar baz"又会返回{ foo: 'bar baz' }

运行命令

最好将每个命令的代码拆分开来,只在调用时才加载到内存中。这样可以加快启动速度,并避免加载不必要的模块。minimist 提供的 main 命令上有一个 switch 语句,操作起来很简单。使用这种设置,每个命令文件都应该导出一个函数,在本例中,我们将参数传递给每个命令,以便稍后使用。

index.js
const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))
  const cmd = args._[0]

  switch (cmd) {
    case 'today':
      require('./cmds/today')(args)
      break
    default:
      console.error(`"${cmd}" is not a valid command!`)
      break
  }
}
Enter fullscreen mode Exit fullscreen mode
cmds/today.js
module.exports = (args) => {
  console.log('today is sunny')
}
Enter fullscreen mode Exit fullscreen mode

现在,如果你运行outside today,你会看到“今天是晴天”的消息;如果你运行outside foobar,它会告诉你“foobar”不是一个有效的命令。显然,我们仍然需要查询天气 API 来获取真实数据,但这是一个很好的开始。

预期命令

每个 CLI 都应该包含以下几个命令和参数:help--help-h,它们显然应该显示帮助菜单; 、version它们应该输出当前的应用程序版本。如果没有指定任何命令,我们也应该默认使用主帮助菜单。--version-v

在我们当前的设置中,只需在 switch 语句中添加两个 case 条件、为cmd变量添加一个默认值,并为 help 和 version 参数标志实现一些 if 语句,即可轻松实现此操作。Minimist 会自动将参数解析为键/值对,因此运行后outside --version将使args.versionequal 语句为真。

const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))

  let cmd = args._[0] || 'help'

  if (args.version || args.v) {
    cmd = 'version'
  }

  if (args.help || args.h) {
    cmd = 'help'
  }

  switch (cmd) {
    case 'today':
      require('./cmds/today')(args)
      break

    case 'version':
      require('./cmds/version')(args)
      break

    case 'help':
      require('./cmds/help')(args)
      break

    default:
      console.error(`"${cmd}" is not a valid command!`)
      break
  }
}
Enter fullscreen mode Exit fullscreen mode

要实现我们的新命令,请遵循与命令相同的格式today

cmds/version.js
const { version } = require('../package.json')

module.exports = (args) => {
  console.log(`v${version}`)
}
Enter fullscreen mode Exit fullscreen mode
cmds/help.js
const menus = {
  main: `
    outside [command] <options>

    today .............. show weather for today
    version ............ show package version
    help ............... show help menu for a command`,

  today: `
    outside today <options>

    --location, -l ..... the location to use`,
}

module.exports = (args) => {
  const subCmd = args._[0] === 'help'
    ? args._[1]
    : args._[0]

  console.log(menus[subCmd] || menus.main)
}
Enter fullscreen mode Exit fullscreen mode

现在,如果您运行outside help todayoutside today -h,您应该会看到该命令的帮助菜单today。运行outsideoutside -h应该会显示主帮助菜单。

外部帮助

添加另一个命令

这个项目设置非常棒,因为如果您需要添加一个新命令,您需要做的就是在cmds文件夹中创建一个新文件,将其添加到 switch 语句中,并添加一个帮助菜单(如果有)。

cmds/forecast.js
module.exports = (args) => {
  console.log('tomorrow is rainy')
}
Enter fullscreen mode Exit fullscreen mode
index.js
// ...
    case 'forecast':
      require('./cmds/forecast')(args)
      break
// ...
Enter fullscreen mode Exit fullscreen mode
cmds/help.js
const menus = {
  main: `
    outside [command] <options>

    today .............. show weather for today
    forecast ........... show 10-day weather forecast
    version ............ show package version
    help ............... show help menu for a command`,

  today: `
    outside today <options>

    --location, -l ..... the location to use`,

  forecast: `
    outside forecast <options>

    --location, -l ..... the location to use`,
}

// ...
Enter fullscreen mode Exit fullscreen mode

加载指示器

有时,命令可能需要很长时间才能运行。如果您正在从 API 获取数据、生成内容、将文件写入磁盘,或者执行任何其他耗时超过几毫秒的进程,您需要向用户提供一些反馈,让他们知道您的应用没有卡顿,只是在努力运行。有时,您可以衡量操作的进度,显示进度条是合理的;但有时,情况会更加复杂,显示加载指示器更合理。

对于我们的应用,我们无法衡量 API 请求的进度,因此我们将使用一个基本的旋转器来显示正在发生的事情。为我们的网络请求和旋转器安装另外两个依赖项:

$ npm install --save axios ora
Enter fullscreen mode Exit fullscreen mode

现在让我们创建一个实用程序,它将向 Yahoo 天气 API 发出请求,以获取某个位置的当前状况和预报。

注意:Yahoo API 使用“YQL”语法,有点奇怪——不要试图理解它,只需复制粘贴即可。这是我能找到的唯一一个不需要 API 密钥的天气 API。

utils/weather.js
const axios = require('axios')

module.exports = async (location) => {
  const results = await axios({
    method: 'get',
    url: 'https://query.yahooapis.com/v1/public/yql',
    params: {
      format: 'json',
      q: `select item from weather.forecast where woeid in
        (select woeid from geo.places(1) where text="${location}")`,
    },
  })

  return results.data.query.results.channel.item
}
Enter fullscreen mode Exit fullscreen mode
cmds/today.js
const ora = require('ora')
const getWeather = require('../utils/weather')

module.exports = async (args) => {
  const spinner = ora().start()

  try {
    const location = args.location || args.l
    const weather = await getWeather(location)

    spinner.stop()

    console.log(`Current conditions in ${location}:`)
    console.log(`\t${weather.condition.temp}° ${weather.condition.text}`)
  } catch (err) {
    spinner.stop()

    console.error(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,如果您运行outside today --location "Brooklyn, NY",您将看到在发出请求时快速旋转的窗口,然后显示当前的天气状况。

由于请求速度过快,加载指示器可能难以看清。如果您想手动减慢加载速度以便查看,可以在天气实用程序函数的开头添加以下代码await new Promise(resolve => setTimeout(resolve, 5000))

外部活动指示器

太棒了!现在让我们把这段代码复制到forecast命令中,并稍微修改一下格式。

cmds/forecast.js
const ora = require('ora')
const getWeather = require('../utils/weather')

module.exports = async (args) => {
  const spinner = ora().start()

  try {
    const location = args.location || args.l
    const weather = await getWeather(location)

    spinner.stop()

    console.log(`Forecast for ${location}:`)
    weather.forecast.forEach(item =>
      console.log(`\t${item.date} - Low: ${item.low}° | High: ${item.high}° | ${item.text}`))
  } catch (err) {
    spinner.stop()

    console.error(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

现在运行 ,你就可以看到未来 10 天的天气预报了outside forecast --location "Brooklyn, NY"。看起来不错!让我们再添加一个实用程序,如果命令中没有指定位置,它会自动根据 IP 地址获取我们的位置。

utils/location.js
const axios = require('axios')

module.exports = async () => {
  const results = await axios({
    method: 'get',
    url: 'https://api.ipdata.co',
  })

  const { city, region } = results.data
  return `${city}, ${region}`
}
Enter fullscreen mode Exit fullscreen mode
cmds/today.jscmds/forecast.js
// ...
const getLocation = require('../utils/location')

module.exports = async (args) => {
  // ...
    const location = args.location || args.l || await getLocation()
    const weather = await getWeather(location)
  // ...
}
Enter fullscreen mode Exit fullscreen mode

现在,如果您只是在没有任何位置的情况下跑步outside forecast,您将看到当前位置的预测。

外部预测

错误和退出代码

我没有详细介绍如何最好地处理错误(这将在后续教程中介绍),但最重要的是要记住使用正确的退出代码。如果您的 CLI 出现严重错误,您应该使用 退出process.exit(1)。这会让终端知道程序没有正常退出——例如,CI 服务会通知您。让我们创建一个快速实用程序来执行此操作,以便在运行不存在的命令时获取正确的退出代码。

实用程序/错误.js
module.exports = (message, exit) => {
  console.error(message)
  exit && process.exit(1)
}
Enter fullscreen mode Exit fullscreen mode
index.js
// ...
const error = require('./utils/error')

module.exports = () => {
  // ...
    default:
      error(`"${cmd}" is not a valid command!`, true)
      break
  // ...
}
Enter fullscreen mode Exit fullscreen mode

发布到 NPM

将我们的库发布到公共库的最后一步是将其发布到包管理器。由于我们的应用是用 JavaScript 编写的,因此发布到 NPM 是合理的。让我们进一步完善package.json一下:

{
  "name": "outside-cli",
  "version": "1.0.0",
  "description": "A CLI app that gives you the weather forecast",
  "license": "MIT",
  "homepage": "https://github.com/timberio/outside-cli#readme",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/timberio/outside-cli.git"
  },
  "engines": {
    "node": ">=8"
  },
  "keywords": [
    "weather",
    "forecast",
    "rain"
  ],
  "preferGlobal": true,
  "bin": {
    "outside": "bin/outside"
  },
  "scripts": {},
  "devDependencies": {},
  "dependencies": {
    "axios": "^0.18.0",
    "minimist": "^1.2.0",
    "ora": "^2.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 设置engine将确保安装我们应用的任何人都拥有更新版本的 Node。由于我们使用了未经转译的 async/await 语法,因此我们要求 Node 8.0 或更高版本。
  • 如果使用而不是进行安装,设置preferGlobal将会警告用户npm install --savenpm install --global

就这样!现在就可以运行了npm publish,你的应用就可以下载了。如果你想更进一步,在其他包管理器(比如 Homebrew)上发布,可以试试pkgnexe,它们可以帮助你将应用打包成一个独立的二进制文件。

总结

在Timber ,我们所有的 CLI 应用都遵循这种结构,它有助于保持系统的条理性和模块化。对于只浏览过本教程的人来说,这里有一些关键要点:

  • Bin 文件是任何 CLI 应用程序的入口点,并且应该只调用主函数
  • 除非需要,否则不应要求命令文件
  • 始终包含helpversion命令
  • 保持命令文件精简——它们的主要目的是调用函数并显示用户消息
  • 始终显示某种活动指示器
  • 使用正确的错误代码退出

我希望你现在对如何在 Node.js 中创建和组织 CLI 应用有了更好的理解。这是系列教程的第一部分,稍后我们将更深入地讲解如何添加设计、ASCII 字符和颜色、接受用户输入、编写集成测试等等,请稍后再回来学习。你可以在 GitHub 上查看我们今天编写的所有源代码。

文章来源:https://dev.to/timber/creating-a-real-world-cli-app-with-node-5am5
PREV
JavaScript 中创建对象的不同方法总结
NEXT
为什么不做出反应?