文章目录
- 设置工具命令
- package.json bin 字段
- 注释:#!/usr/bin/env node
- 设置环境变量
- 接收命令选项参数
- process 实现
- commander
- 命令行交互:inquirer
- 下载项目模板:download-git-repo
- 执行额外命令:自动安装依赖
- child_process
- execa
- 体验优化
- 输出彩色信息:chalk
- loading 加载状态提示:ora
- 控制台输出表格:easy-table
- 控制台输出方框:boxen
- 输出 ASCII 的艺术字体:figlet
通过学习搭建脚手架工具,学习 nodejs 开发命令行工具知识。
设置工具命令
以 create-vite 为例,怎么终端就能识别 create-vite 为一个命令呢?并且会执行一些逻辑呢?这些逻辑代码写在哪?
- 通过 bin 字段。
package.json bin 字段
bin 字段用来设置内部命令,并且指定命令对应的执行文件。
设置一个命令为 ikun,命令行执行 ikun 其实就是执行 main.js 文件。
{"bin": {"ikun": "./src/main.js"}
}
注释:#!/usr/bin/env node
#!/usr/bin/env node
是一个称为 shebang(或 hashbang)的特殊注释,它出现在脚本文件的第一行。这个注释的作用是告诉 Unix 或类 Unix 系统的操作系统,当用户尝试执行这个脚本时,应该使用哪个解释器来运行这个脚本。
在这个例子中,#!/usr/bin/env node 指示系统使用 env 命令来查找 node 解释器,并使用 node 来执行该脚本。env 命令会在系统的环境变量中查找 node 可执行文件,这样可以确保无论 node 安装在哪个目录下,脚本都能正确地运行。
#!/usr/bin/env nodeconsole.log(234);
设置环境变量
学过 Java 都知道,第一步就是设置环境变量,没有设置环境变量,是不能在任意路径下使用 java 命令的。
我们的 ikun 命令自然也是一样的,也需要添加入环境变量。
如果这个包npm publish
发布后,用户npm install
安装这个包。package.json 中有 bin 字段,就会自动添加入环境变量。用户可直接使用 ikun 命令。
那我们在开发的时候怎么办?
可以使用npm link
给这个命令建立一个软连接,连接到全局。这样我们就能像全局安装了这个包一样,能全局直接用了。
# 建立连接
npm link --global# 断开连接,也是全局删除这个包
npm unlink cli-demo --global
npm uninstall 文档中可以发现,unlink 其实是 uninstall 的别名,实质上也是删除了包。
pnpm link | pnpm中文文档 | pnpm中文网
pnpm unlink | pnpm中文文档 | pnpm中文网
# 建立连接
pnpm link --global# 断开连接(其实就是全局卸载这个包)
pnpm uninstall --global cli-demo
接收命令选项参数
命令通常可以接收一些选项参数 options。比如几乎所有命令都有的一个版本。
java --version
并且选项还可以添加一些参数,如 git 提交 -m 选项后填写参数,作为提交说明:
git add -m "第一次提交"
我们的 ikun 命令怎么实现这个?
- 原生实现
- 第三方库:commander
process 实现
process 全局对象
#!/usr/bin/env node
const package = require("../package.json");const option = process.argv[2];if (option === "-v" || option === "--version") {// package.json 会自动解析为 js 对象console.log(package.version);
}
CommonJS 能直接解析 JSON 文件,但是 ESM 不可以,目前只是实验性支持:断言类型为 JSON
import packageModule from "../../package.json" assert { type: "json" };import("../../package.json", { assert: { type: "json" } }).then(({ default: packageModule }) => {});
(node:8072) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time
因此 node 中处理 JSON 文件,最好的办法还是通过 fs 模块 IO 将 JSON 文件内容读取为字符串,然后 JSON.parse。
import fs from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";const packageJsonUrl = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json"))
const packageJsonStr = fs.readFileSync(packageJsonUrl, "utf-8");
const packageModule = JSON.parse(packageJsonStr);
这样原生是可以实现,但是太麻烦了。
commander
commander
实现类似 vue-cli 的效果:
命令行输入ikun create 项目名
,就会在该目录下生成一个 vue 项目模板。
const { program } = require('commander')function helpOptions() {// 1.处理--version的操作const version = require('../../package.json').versionprogram.version(version, '-v --version')// 2.增强其他的options的操作program.option('-w --why', "a why cli program~")program.option('-d --dest <dest>', 'a destination folder, 例如: -d src/components')program.on('--help', () => {console.log("")console.log("others:")console.log(" xxx")console.log(" yyy")})
}module.exports = helpOptions
const { program } = require('commander')
const { createProjectAction, addComponentAction } = require('./core/actions')
const helpOptions = require('./core/help-options')// 1.配置所有的options
helpOptions()// 2.增加子命令实现具体功能
program.command("create <project> [...others]").description("create vue project into a folder, 比如: whycli create airbnb").action(createProjectAction)program.command("addcpn <cpnname> [...others]").description("add vue component into a folder, 比如: whycli addcpn NavBar -d src/components").action(addComponentAction)// 让commander解析process.argv参数
program.parse(process.argv)
命令行交互:inquirer
命令行交互,比如执行命令后,一问一答询问你项目名要填什么,是否要支持 ts 等等。
inquirer 、enquirer、prompts。这三个库都可以处理复杂的用户输入,完成命令行输入交互。
Inquirer 是一个强大的命令行交互工具,用于与用户进行交互和收集信息。它提供了各种丰富的交互式提示(如输入框、选择列表、确认框等),可以帮助你构建灵活的命令行界面。通过 Inquirer,你可以向用户提出问题,获取用户的输入,并根据用户的回答采取相应的操作。
Inquirer.js 提供了一系列的预设问题类型,包括:
- 输入(Input):让用户输入一些文本。
- 确认(Confirm):让用户回答是或否的问题。
- 列表(List):让用户从列表中选择一个选项。
- 检查框(Checkbox):让用户从多个选项中选择多个。
- 扩展列表(Expand):让用户通过键盘快捷键从多个选项中选择一个。
- 密码(Password):让用户输入密码,输入时字符会被隐藏。
以下是一个使用 Inquirer.js 的简单示例:
在这个示例中,Inquirer.js 会依次显示三个问题,并根据用户的回答存储在 answers 对象中。
import inquirer from "inquirer";async function promptQuestions() {let answers;try {answers = await inquirer.prompt([// ... questions{type: "input",name: "username",message: "请输入您的用户名:"},{type: "confirm",name: "isVIP",message: "您是VIP用户吗?",default: false},{type: "list",name: "plan",message: "请选择您的计划:",choices: ["Free", "Professional", "Enterprise"],filter: function (val) {return val.toLowerCase();}}]);console.log(JSON.stringify(answers, null, " "));} catch (error) {if (error.isTtyError) {console.log("Prompt couldn't be rendered in the current environment");} else {console.log("Something else went wrong");}}return answers;
}export default promptQuestions;// {
// "username": "hh",
// "isVIP": false,
// "plan": "professional"
// }
下载项目模板:download-git-repo
本地生成的 vue 项目模块其实 glt clone 下来的,并不是本地生成的。这样的好处就是,模板在 github 上,我可以实时更新,但是命令行工具代码不用变。
去 github clone 项目的操作没必要自己实现,使用第三方库即可。
Phillip Lanclos / download-git-repo · GitLab
Download-git-repo 是一个用于下载 Git 仓库的 npm 库。它提供了一个简单的接口,可以方便地从远程 Git 仓库中下载项目代码。你可以指定要下载的仓库和目标目录,并可选择指定分支或标签。Download-git-repo 支持从各种 Git 托管平台(如 GitHub、GitLab、Bitbucket 等)下载代码。
download-git-repo
const VUE_REPO = "direct:https://github.com/coderwhy/vue3_template.git#main"module.exports = {VUE_REPO
}
async function createProjectAction(project) {try {// 1.从编写的项目模板中clone下来项目await download(VUE_REPO, project, { clone: true })// 2. 模板下载好后,给予一些提示,比如进入项目,下载依赖等console.log(`cd ${project}`)console.log(`npm install`)console.log(`npm run dev`)} catch (error) {console.log("github连接失败, 请稍后重试")}
}
执行额外命令:自动安装依赖
下载好项目模板后,可以继续帮用户自动执行npm install
下载好依赖。
当前 node 主线程正在执行我们的 ikun 命令,此时又要执行 npm 命令。因此我们需要在 node 中开一个子进程。
https://www.yuque.com/ahcheng123/project/dbhhnl6sxm7zgwmw#Ym8tS
child_process
子进程 childProcess
const { spawn } = require('child_process')function execCommand(...args) {return new Promise((resolve) => {// npm install/npm run dev// 1.开启子进程执行命令const childProcess = spawn(...args)// 2.获取子进程的输出和错误信息childProcess.stdout.pipe(process.stdout)childProcess.stderr.pipe(process.stderr)// 3.监听子进程执行结束, 关闭掉childProcess.on('close', () => {resolve()})})
}module.exports = execCommand
要执行 npm 命令要注意,不同平台执行的实际命令不同。
- win:
npm.cmd
- mac:
npm
- linux:
npm
async function createProjectAction(project) {try {// 1.从编写的项目模板中clone下来项目await download(VUE_REPO, project, { clone: true })// 2.很多的脚手架, 都是在这里给予提示// 3.帮助执行npm installconsole.log(process.platform)const commandName = process.platform === 'win32'? 'npm.cmd': 'npm'await execCommand(commandName, ["install"], { cwd: `./${project}` })// 4.帮助执行npm run devawait execCommand(commandName, ["run", "dev"], { cwd: `./${project}` })} catch (error) {console.log("github连接失败, 请稍后重试")}
}
至此,其实一个 cli 工具的主体功能基本完成了。
execa
https://github.com/sindresorhus/execa?tab=readme-ov-file
Execa 是一个流行的 Node.js 库,它提供了一种更简单、更现代的方式来执行子进程。它对 Node.js 原生的 child_process
模块进行了封装,提供了一系列的改进和额外功能,使得执行外部命令更加方便和可靠。
Execa 的主要特点包括:
- 清晰的 API:Execa 提供了简洁明了的 API,使得执行命令和获取输出变得更加简单。
- 跨平台支持:它能够在不同的操作系统上提供一致的表现,例如在 Windows 上正确处理路径和扩展名。
- 流式输出:Execa 支持流式输出,这意味着你可以实时地获取命令的输出,而不是等到命令执行完毕后一次性获取。
- Promise-based:Execa 使用 Promise,使得异步操作更加易于管理和组合。
- 改进的错误处理:它能够提供更详细的错误信息,包括命令的退出码和信号。
- 同步模式:除了默认的异步模式外,Execa 还支持同步执行命令。
以下是一个使用 Execa 执行命令的简单示例:
Execa 支持交互式命令,你可以使用 stdio: ‘inherit’ 将子进程的输入/输出/错误传递给父进程:
const execa = require('execa');async function runInteractiveCommand() {try {await execa('git', ['commit'], { stdio: 'inherit' });} catch (error) {console.error('命令执行出错:', error);}
}runInteractiveCommand();
Execa 是许多现代 Node.js 应用程序的流行选择,特别是那些需要频繁与系统命令交互的应用程序,例如构建工具、测试框架和开发工具等。
import { execa } from "execa";/*** 执行命令* @param {string} command — The program/script to execute.* @param {string[]} args — Arguments to pass to file on execution.* @param {*} options*/
export async function execCommand(command, args) {try {await execa(command, args, { stdio: "inherit" });} catch (error) {console.log("error", error);}
}
体验优化
输出彩色信息:chalk
- https://github.com/chalk/chalk
使用 console.log 输出提示信息太丑了,使用第三方库 chalk 可以输出彩色的控制台信息。
搭建 ts koa 项目模板
import chalk from "chalk";// 定义主题和对应的 emoji
const theme = {info: { color: chalk.blue, emoji: " 💡 " },success: { color: chalk.green, emoji: " 🎉 " },warning: { color: chalk.yellow, emoji: " 🔔 " },error: { color: chalk.red, emoji: " 🚨 " },debug: { color: chalk.gray, emoji: " 🔍 " },highlight: { color: chalk.bold, emoji: " 🌟 " }
};// 日志工具方法
const logInfo = message => console.log(`${theme.info.emoji}${theme.info.color(message)}`);
const logSuccess = message => console.log(`${theme.success.emoji}${theme.success.color(message)}`);
const logWarning = message => console.log(`${theme.warning.emoji}${theme.warning.color(message)}`);
const logError = message => console.log(`${theme.error.emoji}${theme.error.color(message)}`);
const logDebug = message => console.log(`${theme.debug.emoji}${theme.debug.color(message)}`);
const logHighlight = message => console.log(`${theme.highlight.emoji}${theme.highlight.color(message)}`);// 导出方法
export { logInfo, logSuccess, logWarning, logError, logDebug, logHighlight };
loading 加载状态提示:ora
- https://github.com/sindresorhus/ora
Ora 是一个用于在命令行界面显示加载动画的 npm 库。它可以帮助你在执行耗时的任务时提供一个友好的加载状态提示。Ora 提供了一系列自定义的加载动画,如旋转器、进度条等,你可以根据需要选择合适的加载动画效果,并在任务执行期间显示对应的加载状态。
控制台输出表格:easy-table
- https://github.com/eldargab/easy-table
var Table = require('easy-table')var data = [{ id: 123123, desc: 'Something awesome', price: 1000.00 },{ id: 245452, desc: 'Very interesting book', price: 11.45},{ id: 232323, desc: 'Yet another product', price: 555.55 }
]var t = new Tabledata.forEach(function(product) {t.cell('Product Id', product.id)t.cell('Description', product.desc)t.cell('Price, USD', product.price, Table.number(2))t.newRow()
})console.log(t.toString())
Product Id Description Price, USD
---------- --------------------- ----------
123123 Something awesome 1000.00
245452 Very interesting book 11.45
232323 Yet another product 555.55
控制台输出方框:boxen
- https://github.com/sindresorhus/boxen
Boxen 是一个轻量级的 Node.js 库,用于将文本内容格式化为带框的文本,通常用于美化命令行输出。它可以将文本包裹在固定的宽度中,并在文本周围添加边框,使得输出更加整洁和易于阅读。
使用 Boxen,你可以轻松地创建各种形状和大小的框,包括单行和多行的文本。Boxen 支持多种边框样式,并且可以根据需要调整边框、填充和间距等样式属性。
import boxen from 'boxen';console.log(boxen('unicorn', {padding: 1}));
/*
┌─────────────┐
│ │
│ unicorn │
│ │
└─────────────┘
*/console.log(boxen('unicorn', {padding: 1, margin: 1, borderStyle: 'double'}));
/*╔═════════════╗║ ║║ unicorn ║║ ║╚═════════════╝*/console.log(boxen('unicorns love rainbows', {title: 'magical', titleAlignment: 'center'}));
/*
┌────── magical ───────┐
│unicorns love rainbows│
└──────────────────────┘
*/
输出 ASCII 的艺术字体:figlet
- https://github.com/patorjk/figlet.js
很多库都有这样的标题,比如 spring。
figlet.text("Boo!",{font: "Ghost",horizontalLayout: "default",verticalLayout: "default",width: 80,whitespaceBreak: true,},function (err, data) {if (err) {console.log("Something went wrong...");console.dir(err);return;}console.log(data);}
);
.-. .-') ,---.
\ ( OO ) | |;-----.\ .-'),-----. .-'),-----. | || .-. | ( OO' .-. '( OO' .-. '| || '-' /_)/ | | | |/ | | | || || .-. `. \_) | |\| |\_) | |\| || .'| | \ | \ | | | | \ | | | |`--'| '--' / `' '-' ' `' '-' '.--.`------' `-----' `-----' '--'