在这篇文章中,我们将学习到Google的zx库提供了哪些功能,以及我们如何在NodeJs中用它写shell 脚本。在文章最后,我会手把手教你用zx写一个命令行工具,来更深入理解zx的使用。
编写shell脚本:有点不爽的地方
我们经常要做一些重复工作,比如启动项目,打包等等,这时候写一个命令行工具来自动化这些工作是一个非常好的方式。最常见的方式就是用Nodejs
来写,因为Nodejs
本身有很多内置模块功能,并且不够用时我们还可以引入我们想要的包来解决问题。现在大部分的打包工具都是这样写的,比如webapck、vite
等。
一切都是那么的美好,但是,当我们想要用Nodejs
去处理:运行shell脚本语句 时,我们就会发现处理起来有一点麻烦。我们要特别引入子进程,并且在写要执行的命令语句时,要小心写丢了命令行参数,还有可能被stdout
和stderr
搞得糊里糊涂。下面是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库 可以帮助我们更加高效地用Nodejs
写shell 脚本。
环境准备
- 你需要有一定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
变量暴露。
实战环节
安装
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);
运行后输出结果如下:
从输出结果我们可以看出如下问题:
-
$ 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();
再跑一次程序,输出结果就看起来正常了:
写一个创建项目的命令行工具
下面我们来写一个自动化创建项目的命令行工具,主要包括的功能有
- 检测是否安装了我们需要的依赖
- 需要用户输入创建项目的目标文件夹
- 检测git是否配置了全局的,user.name and user.email
- 初始化git仓库
- 询问用户选择什么模块系统
- 生成package.json文件
- 重写package.json配置
- 生成gitignore文件
- 生成eslint、prettier、editorconfig配置模版文件,同时安转需要的npm包
- 生成README文件
- 提交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模块系统时,其实可以用用户选择而不是输入的方式,可以避免用户输入错误
一些参考链接:
- mrm:Codemods for your project config files
- Contributor Covenant
- Choose an open source license
原文翻译改编自:How to Write Shell Scripts in Node with Google’s zx Library