【强推】Google的zx库,让你在Node中写shell脚本命令更愉快

2,376 阅读3分钟

在这篇文章中,我们将学习到Google的zx库提供了哪些功能,以及我们如何在NodeJs中用它写shell 脚本。在文章最后,我会手把手教你用zx写一个命令行工具,来更深入理解zx的使用。

编写shell脚本:有点不爽的地方

我们经常要做一些重复工作,比如启动项目,打包等等,这时候写一个命令行工具来自动化这些工作是一个非常好的方式。最常见的方式就是用Nodejs来写,因为Nodejs本身有很多内置模块功能,并且不够用时我们还可以引入我们想要的包来解决问题。现在大部分的打包工具都是这样写的,比如webapck、vite等。

一切都是那么的美好,但是,当我们想要用Nodejs去处理:运行shell脚本语句 时,我们就会发现处理起来有一点麻烦。我们要特别引入子进程,并且在写要执行的命令语句时,要小心写丢了命令行参数,还有可能被stdoutstderr搞得糊里糊涂。下面是webapck源码中一段用Nodejs运行命令行语句的代码,就非常的典型:

const cp = require("child_process");
// 用子进程执行命令行语句
cp.exec(`node ${path.resolve(__dirname, "../bin/webpack.js")} ${args} ${displayReasons} ${commonArgs}`, (error, stdout, stderr) => {
    if (stderr)
        console.log(stderr);
    if (error !== null)
        console.log(error);
    try {
        readme = tc.replaceResults(readme, process.cwd(), stdout.replace(/[\r?\n]*$/, ""), prefix);
    } catch (e) {
        console.log(stderr);
        throw e;
    }
    callback();
});

如果我们直接用Bash来写,很天然,一行就搞定。但是Bash的语法确实生涩难懂,并且实现稍微复杂一点的逻辑就很麻烦,比如提示用户输入这种,所以确实是不太适合用它来写复杂的项目。

而Google的zx库 可以帮助我们更加高效地用Nodejsshell 脚本

环境准备

  • 你需要有一定Javascript和Nodejs基础
  • 知道如何运行命令行
  •  Node.js 版本要在 v14.13.1及以上

Google的zx的运行原理

Google的zx 封装了子进程的创建以及stdout 和 stderr 的处理。最基本的就是$函数:

import { $ } from 'zx';
// 运行shell的ls命名
await $`ls`;

这个语法在 javascript 中叫做Template literals,它的功能等同于await $('ls')

Google的 zx 还提供了很多其他的工具函数, 比如:

  • cd():改变当前工作的文件夹

  • question():封装了 Node.js readline 模块,让用户输入交互更简单。 同时 zx 也内置了很多流行的库的功能给我们使用,如:

  • chalk:给输出加颜色。

  • minimist:解析命令行参数, 通过 argv 变量暴露。

  • fetch: 一个流行的 Node.js  Fetch API 的实现, 我们能用它发起http请求。

  • fs-extra: 封装了 Node.js 核心 fs 模块, 让文件系统操作更便捷。

实战环节

安装

npm i -D zx

官方建议全局安装,但我们用项目级别的安装,这样可以确保使用者安装了zx库,并且版本有保障。

顶层 await

为了能够在Node.js顶层使用 await,即不必被async函数包裹,我们需要以ES Module的形式写代码。有两种方法,一是在package.json中加入"type": "module",声明本项目所有的模块都是ES Module的形式,二是文件用.mjs结尾。本文我们用方法二。

跑一个简单的例子

我们新建一个hello-world.mjs, 申明 shebang line, 告诉操作系统 内核 用node来运行该文件:

#! /usr/bin/env node

然后,加入如下代码:

#! /usr/bin/env node

import { $ } from "zx";

const output = (await $`ls`).stdout;
console.log(output);

运行后输出结果如下:

截屏2022-01-23 上午10.01.36.png

从输出结果我们可以看出如下问题:

  • $ ls,也被输出了

  • 结果被输出了两次

  • 输出结果最后还有一个空行

    zx 默认以 verbose 模式运行。 它将会输出你传递给$ 函数的命令(你console.log打印的),同时也会输出标准输出(ls 命令本来就会在控制台输出内容)。 我们可以在运行ls命令之前把$函数verbose模式变成false:

$.verbose = false;

大多数命令行语句, 如 ls, 会在输出结果后,新输出一行,让输出结果更方便阅读。 我们可以用js的 String#trim() 函数去掉这一行:

- const output = (await $`ls`).stdout;
+ const output = (await $`ls`).stdout.trim();

再跑一次程序,输出结果就看起来正常了:

截屏2022-01-23 上午10.12.58.png

写一个创建项目的命令行工具

下面我们来写一个自动化创建项目的命令行工具,主要包括的功能有

  1. 检测是否安装了我们需要的依赖
  2. 需要用户输入创建项目的目标文件夹
  3. 检测git是否配置了全局的,user.name and user.email
  4. 初始化git仓库
  5. 询问用户选择什么模块系统
  6. 生成package.json文件
  7. 重写package.json配置
  8. 生成gitignore文件
  9. 生成eslint、prettier、editorconfig配置模版文件,同时安转需要的npm包
  10. 生成README文件
  11. 提交git记录

具体代码如下:

#! /usr/bin/env node

import { $, path, argv, chalk, question, fs, cd } from "zx";

import which from "which";

// 处理错误的公共函数
function exitWithError(errorMessage) {
	console.error(chalk.red(errorMessage));
	process.exit(1);
}
// 检测是否安装了我们需要的依赖

async function checkRequiredProgramsExist(programs) {
	try {
		for (const program of programs) {
			await which(program);
		}
	} catch (error) {
		exitWithError(`Error: Required command ${error.message}`);
	}
}
await checkRequiredProgramsExist(["git", "node", "npx"]);

// 需要一个目标文件夹
let targetDirectory = argv.directory || argv.d;

if (!targetDirectory) {
	exitWithError("Error: You must specify the --directory argument");
}

targetDirectory = path.resolve(targetDirectory);

if (!(await fs.pathExists(targetDirectory))) {
	exitWithError(`Error: Target directory '${targetDirectory}' does not exist`);
}

// 进入这个目标文件夹
cd(targetDirectory);

// 获取全局的git配置,user.name and user.email
async function getGlobalGitSettingValue(settingName) {
	$.verbose = false;
	let settingValue = "";
	try {
		settingValue = (
			await $`git config --global --get ${settingName}`
		).stdout.trim();
	} catch (error) {}
	$.verbose = true;
	return settingValue;
}

// 检测git是否配置了全局的,user.name and user.email
async function checkGlobalGitSettings(settingsToCheck) {
	for (const settingName of settingsToCheck) {
		const settingValue = await getGlobalGitSettingValue(settingName);
		if (!settingValue) {
			console.warn(
				chalk.yellow(`Warning: Global git setting '${settingName}' is not set.`)
			);
		}
	}
}
await checkGlobalGitSettings(["user.name", "user.email"]);

// 初始化git仓库
await $`git init`;

// 生成package.json文件

// 读取package.json配置
async function readPackageJson(directory) {
	return await fs.readJSON(`${directory}/package.json`);
}

// 重写package.json配置
async function writePackageJson(directory, contents) {
	await fs.writeJSON(`${directory}/package.json`, contents, { spaces: 2 });
}

// 询问用户用什么模块系统
/**
 * 目前,NodeJS支持两种模块系统:
 * 1、 CommonJS Modules。用 require 来引入,用 module.exports 来导出
 * 2、 ECMAScript Modules。用 import 来引入,用 export 来导出。NodeJS生态逐渐更接收这种模式,因为,它在客户端已逐渐普遍起来。
 */
async function promptForModuleSystem(moduleSystems) {
	// 这个question 看起来不太好用,还要用户自己输入,明明现在有可以选的那种
	const moduleSystem = await question(
		chalk.green(
			`Which Node.js module system do you want to use? (${moduleSystems.join(
				" or "
			)})`
		),
		{
			choices: moduleSystems,
		}
	);
	return moduleSystem;
}

const moduleSystems = ["module", "commonjs"];
const selectedModuleSystem = await promptForModuleSystem(moduleSystems);
if (!moduleSystems.includes(selectedModuleSystem)) {
	exitWithError(
		`Error: Module system must be either '${moduleSystems.join("' or '")}'\n`
	);
}
// 初始化package.json文件
await $`npm init --yes`;
// 修改 package.json文件的模式
const packageJSON = await readPackageJson(targetDirectory);
packageJSON.type = selectedModuleSystem;

await writePackageJson(targetDirectory, packageJSON);

// 生成gitignore文件

await $`npx gitignore node`;

// 生成配置模版文件,同时安转需要的npm包,并生可以通过配置参数,成个性化的配置文件
// 此处,你需要优先安转 mrm、mrm-task-editorconfig、mrm-task-prettier、mrm-task-eslint 
await $`npx mrm editorconfig`;
await $`npx mrm prettier`;
await $`npx mrm eslint`;

// 生成README文件
const { name: packageName } = await readPackageJson(targetDirectory);
const readmeContents = `# ${packageName}`;
await fs.writeFile(`${targetDirectory}/README.md`, readmeContents);

// 提交git记录

await $`git add .`;
await $`git commit -m "add project skeleton"`;

console.log(
	chalk.green(
		`\n✔️ The project ${projectName} has been successfully bootstrapped!\n`
	)
);
console.log(chalk.green(`Add a git remote and push your changes.`));

当然,这只是一个简单的例子,也还有很多需要改进的地方:

  • 在检测到用户没有创建目标文件夹时,可以询问用户是否需要帮他生成
  • 开源项目需要生成LICENSE
  • question模块,在选择node模块系统时,其实可以用用户选择而不是输入的方式,可以避免用户输入错误

一些参考链接:

原文翻译改编自:How to Write Shell Scripts in Node with Google’s zx Library