社区精选|如何使用 zx 编写 shell 脚本
在这篇文章中,我们将学习谷歌的 zx 库提供了什么,以及我们如何使用它来用 Node.js 编写 shell 脚本。然后,我们将学习如何通过构建一个命令行工具来使用 zx 的功能,帮助我们为新的 Node.js 项目引导配置。
编写 Shell 脚本的问题
创建一个由 Bash 或者 zsh 执行的 shell 脚本,是自动化重复任务的好方法。Node.js 似乎是编写 shell 脚本的理想选择,因为它为我们提供了许多核心模块,并允许我们导入任何我们选择的库。它还允许我们访问 JavaScript 提供的语言特性和内置函数。
如果你尝试编写运行在 Node.js 中的 shell 脚本,你会发现这没有你想象中的那么顺利。你需要为子进程编写特殊的处理程序,注意转义命令行参数,然后最终与 stdout(标准输出)和 stderr(标准错误)打交道。这不是特别直观,而且会使 shell 脚本变得相当笨拙。
Bash shell 脚本语言是编写 shell 脚本的普遍选择。不需要编写代码来处理子进程,而且它有内置的语言特性来处理 stdout 和 stderr。但是用 Bash 编写 shell 脚本也不是那么容易。语法可能相当混乱,使得它实现逻辑,或者处理诸如提示用户输入的事情非常困难。
谷歌的 zx 库有助于让使用 Node.js 编写的 shell 脚本变得高效和舒适。
前置条件
往下阅读之前,有几个前置条件需要遵循:
理想情况下,你应该熟悉 JavaScript 和 Node.js 的基础知识。
你需要适应在终端中运行命令。
你需要安装 Node.js >= v14.13.1。
zx 如何运作
Google 的 zx 提供了创建子进程的函数,以及处理这些进程的 stdout 和 stderr 的函数。我们将使用的主要函数是$函数。下面是它的一个实际例子:
import { $ } from "zx";
await $`ls`;
下面是执行上述代码的输出:
$ ls
bootstrap-tool
hello-world
node_modules
package.json
README.md
typescript
上面的例子中的 JavaScript 语法可能看起来有点古怪。它使用了一种叫做带标签的模板字符串 https://link.segmentfault.com/?enc=VUkodq5er%2Fynbhfl3MUQZA%3D%3D.%2Flx6oaDCVK4XuYyYLqvDi2QMWjCwW1jKBvNQgfaGG0AVwpl7I2CYD4sJYHuonDSA6jj1qSSypc0aGVO%2BYuBMUiibG6pBkVwusg%2Bai1hbMXetqlwMTWVUEAbtabMCbXIs 的语言特性。它在功能上与编写 await $("ls")相同。
谷歌的 zx 提供了其他几个实用功能,使编写 shell 脚本更容易。比如:
cd()。允许我们更改当前工作目录。 question()。这是 Node.js readline 模块的包装器。它使提示用户输入变得简单明了。
除了 zx 提供的实用功能外,它还为我们提供了几个流行的库,比如:
chalk。https://link.segmentfault.com/?enc=%2FL15Y8OQNrp05Scx6N4iaQ%3D%3D.r8AZkfcE1Ye%2BlNUznFA9RNJMhfyM0lttTiD0TmqjJQwoi7zjQjs5YiLjI%2FWDEooY 这个库允许我们为脚本的输出添加颜色。
minimist。https://link.segmentfault.com/?enc=5tMQ5d6qJ4STEHZeuiN0MA%3D%3D.x9GPOpXVZp5TGKknZCkwA10QP%2Ftw%2BW7fwnpYGg%2BnlJPJLK3RboT3jqy5WuPLFwPZ 一个解析命令行参数的库。然后它们在 argv 对象下被暴露出来。
fetch。https://link.segmentfault.com/?enc=71LC44tCu%2FZoLOY2B7MfuA%3D%3D.GO%2FMi37T0KTmj8UXau9xdFUBiIk1I%2F8M%2FHk2hZYrhRb%2Fd3Vl0bzlr05hanNxzuVb Fetch API 的 Node.js 实现。我们可以用它来进行 HTTP 请求。
fs-extra。https://link.segmentfault.com/?enc=cPZzXniTRFJdbq87FdwLWw%3D%3D.IsmhSUEjIcZAqw8FDidbUnNhhIVPT1gsFSo%2BchiXCAs9AaxUVT%2FEgbIvswUDVmK2 一个暴露 Node.js 核心 fs 模块的库,以及一些额外的方法,使其更容易与文件系统一起工作。
现在我们知道了 zx 给了我们什么,让我们用它创建第一个 shell 脚本。
zx 如何使用
首先,我们先创建一个新项目:
mkdir zx-shell-scripts
cd zx-shell-scripts
npm init --yes
然后安装 zx 库:
npm install --save-dev zx
注意:zx 的文档建议用 npm 全局安装该库。通过将其安装为我们项目的本地依赖,我们可以确保 zx 总是被安装,并控制 shell 脚本使用的版本。
顶级 await
为了在 Node.js 中使用顶级 await,也就是 await 位于 async 函数的外部,我们需要在 ES 模块的模式下编写代码,该模式支持顶级 await。
我们可以通过在 package.json 中添加 "type": "module" 怎么怎么来表明项目中的所有模块都是 ES 模块。或者我们可以将单个脚本的文件扩展名设置为 .mjs 。在本文的例子中,我们将使用 .mjs 文件扩展名。
运行命令并捕获输出
创建一个新脚本,将其命名为 hello-world.mjs 。我们将添加一个 Shebang 行,它告诉操作系统(OS)的内核要用 node 程序运行该脚本:
#! /usr/bin/env node
然后,我们添加一些代码,使用 zx 来运行命令。
在下面的代码中,我们运行命令执行 ls 程序。ls 程序将列出当前工作目录(脚本所在的目录)中的文件。我们将从命令的进程中捕获标准输出,将其存储在一个变量中,然后打印到终端:
// hello-world.mjs
import { $ } from "zx";
const output = (await $`ls`).stdout;
console.log(output);
注意:zx 文档建议把/usr/bin/env zx 放在我们脚本的 shebang 行中,但我们用/usr/bin/env node 代替。这是因为我们已经安装 zx,并作为项目的本地依赖。然后我们明确地从 zx 包中导入我们想要使用的函数和对象。这有助于明确我们脚本中使用的依赖来自哪里。
我们使用 chmod 来让脚本可执行:
chmod u+x hello-world.mjs
运行项目:
./hello-world.mjs
可以看到如下输出:
$ ls
hello-world.mjs
node_modules
package.json
package-lock.json
README.md
hello-world.mjs
node_modules
package.json
package-lock.json
README.md
你会注意到:
我们运行的命令(ls)被包含在输出中。
命令的输出显示两次。
在输出的末尾多了一个新行。
zx 默认以 verbose 模式运行。它将输出你传递给$函数的命令,同时也输出该命令的标准输出。我们可以通过在运行 ls 命令前加入以下一行代码来改变这种行为:
$.verbose = false;
大多数命令行程序,如 ls,会在其输出的结尾处输出一个新行字符,以使输出在终端中更易读。这对可读性有好处,但由于我们要将输出存储在一个变量中,我们不希望有这个额外的新行。我们可以用 JavaScript String#trim()函数把它去掉:
- const output = (await $`ls`).stdout;
+ const output = (await $`ls`).stdout.trim();
再次运行脚本,结果看起来好很多:
hello-world.mjs
node_modules
package.json
package-lock.json
引入 TypeScript
如果我们想在 TypeScript 中编写使用 zx 的 shell 脚本,有几个微小的区别我们需要加以说明。
注意:TypeScript 编译器提供了大量的配置选项,允许我们调整它如何编译我们的 TypeScript 代码。考虑到这一点,下面的 TypeScript 配置和代码是为了在大多数 TypeScript 版本下工作。
首先,安装需要运行 TypeScript 代码的依赖:
npm install --save-dev typescript ts-node
ts-node 包提供了一个 TypeScript 执行引擎,让我们能够转译和运行 TypeScript 代码。
需要创建 tsconfig.json 文件包含下面的配置:
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs"
}
}
创建新的脚本,并命名为 hello-world-typescript.ts。首先,添加 Shebang 行,告诉 OS 内核使用 ts-node 程序来运行我们的脚本:
#! ./node_modules/.bin/ts-node
为了在我们的 TypeScript 代码中使用 await 关键字,我们需要把它包装在一个立即调用函数表达式(IIFE)中,正如 zx 文档所建议的那样:
// hello-world-typescript.ts
import { $ } from "zx";
void (async function () {
await $`ls`;
})();
然后需要让脚本可执行:
chmod u+x hello-world-typescript.ts
运行脚本:
./hello-world-typescript.ts
可以看到下面的输出:
$ ls
hello-world-typescript.ts
node_modules
package.json
package-lock.json
README.md
tsconfig.json
在 TypeScript 中用 zx 编写脚本与使用 JavaScript 相似,但需要对我们的代码进行一些额外的配置和包装。
构建项目启动工具
现在我们已经学会了用谷歌的 zx 编写 shell 脚本的基本知识,我们要用它来构建一个工具。这个工具将自动创建一个通常很耗时的过程:为一个新的 Node.js 项目的配置提供引导。
我们将创建一个交互式 shell 脚本,提示用户输入。它还将使用 zx 内置的 chalk 库,以不同的颜色高亮输出,并提供一个友好的用户体验。我们的 shell 脚本还将安装新项目所需的 npm 包,所以它已经准备好让我们立即开始开发。
准备开始
首先创建一个名为 bootstrap-tool.mjs 的新文件,并添加 shebang 行。我们还将从 zx 包中导入我们要使用的函数和模块,以及 Node.js 核心 path 模块:
#! /usr/bin/env node
// bootstrap-tool.mjs
import { $, argv, cd, chalk, fs, question } from "zx";
import path from "path";
与我们之前创建的脚本一样,我们要使我们的新脚本可执行:
chmod u+x bootstrap-tool.mjs
我们还将定义一个辅助函数,用红色文本输出一个错误信息,并以错误退出代码 1 退出 Node.js 进程:
function exitWithError(errorMessage) {
console.error(chalk.red(errorMessage));
process.exit(1);
}
当我们需要处理一个错误时,我们将通过我们的 shell 脚本在各个地方使用这个辅助函数。
检查依赖
我们要创建的工具需要使用三个不同程序来运行命令:git、node 和 npx。我们可以使用 which 库来帮助我们检查这些程序是否已经安装并可以使用。
首先,我们需要安装 which:
npm install --save-dev which
然后引入它:
import which from "which";
然后创建一个使用它的 checkRequiredProgramsExist 函数:
async function checkRequiredProgramsExist(programs) {
try {
for (let program of programs) {
await which(program);
}
} catch (error) {
exitWithError(`Error: Required command ${error.message}`);
}
}
上面的函数接受一个程序名称的数组。它循环遍历数组,对每个程序调用 which 函数。如果 which 找到了程序的路径,它将返回该程序。否则,如果该程序找不到,它将抛出一个错误。如果有任何程序找不到,我们就调用 exitWithError 辅助函数来显示一个错误信息并停止运行脚本。
我们现在可以添加一个对 checkRequiredProgramsExist 的调用,以检查我们的工具所依赖的程序是否可用:
await checkRequiredProgramsExist(["git", "node", "npx"]);
添加目标目录选项
由于我们正在构建的工具将帮助我们启动新的 Node.js 项目,因此我们希望在项目的目录中运行我们添加的任何命令。我们现在要给脚本添加一个 --directory 命令行参数。
zx 内置了 minimist 包,它能够解析传递给脚本的任何命令行参数。这些被解析的命令行参数被 zx 包作为 argv 提供:
让我们为名为 directory 的命令行参数添加一个检查:
let targetDirectory = argv.directory;
if (!targetDirectory) {
exitWithError("Error: You must specify the --directory argument");
}
如果 directory 参数被传递给了我们的脚本,我们要检查它是否是已经存在的目录的路径。我们将使用 fs-extra 提供的 fs.pathExists 方法:
targetDirectory = path.resolve(targetDirectory);
if (!(await fs.pathExists(targetDirectory))) {
exitWithError(`Error: Target directory '${targetDirectory}' does not exist`);
}
如果目标路径存在,我们将使用 zx 提供的 cd 函数来切换当前的工作目录:
cd(targetDirectory);
如果我们现在在没有--directory 参数的情况下运行脚本,我们应该会收到一个错误:
$ ./bootstrap-tool.mjs
Error: You must specify the --directory argument
检查全局 Git 设置
稍后,我们将在项目目录下初始化一个新的 Git 仓库,但首先我们要检查 Git 是否有它需要的配置。我们要确保提交会被 GitHub 等代码托管服务正确归类。
为了做到这一点,这里创建一个 getGlobalGitSettingValue 函数。它将运行 git config 命令来检索 Git 配置设置的值:
async function getGlobalGitSettingValue(settingName) {
$.verbose = false;
let settingValue = "";
try {
settingValue = (
await $`git config --global --get ${settingName}`
).stdout.trim();
} catch (error) {
// Ignore process output
}
$.verbose = true;
return settingValue;
}
你会注意到,我们正在关闭 zx 默认设置的 verbose 模式。这意味着,当我们运行 git config 命令时,该命令和它发送到标准输出的任何内容都不会被显示。我们在函数的结尾处将 verbose 模式重新打开,这样我们就不会影响到我们稍后在脚本中添加的任何其他命令。
现在我们添加 checkGlobalGitSettings 函数,该函数接收 Git 设置名称组成的数组。它将循环遍历每个设置名称,并将其传递给 getGlobalGitSettingValue 函数以检索其值。如果设置没有值,将显示警告信息:
async function checkGlobalGitSettings(settingsToCheck) {
for (let settingName of settingsToCheck) {
const settingValue = await getGlobalGitSettingValue(settingName);
if (!settingValue) {
console.warn(
chalk.yellow(`Warning: Global git setting '${settingName}' is not set.`)
);
}
}
}
让我们给 checkGlobalGitSettings 添加一个调用,检查 user.name 和 user.email 的 Git 设置是否已经被设置:
await checkGlobalGitSettings(["user.name", "user.email"]);
初始化 Git 仓库
我们可以通过添加以下命令在项目目录下初始化一个新的 Git 仓库:
await $`git init`;
生成 package.json
每个 Node.js 项目都需要 package.json 文件。这是我们为项目定义元数据的地方,指定项目所依赖的包,以及添加实用的脚本。
在我们为项目生成 package.json 文件之前,我们要创建几个辅助函数。第一个是 readPackageJson 函数,它将从项目目录中读取 package.json 文件:
async function readPackageJson(directory) {
const packageJsonFilepath = `${directory}/package.json`;
return await fs.readJSON(packageJsonFilepath);
}
然后我们将创建一个 writePackageJson 函数,我们可以用它来向项目的 package.json 文件写入更改:
async function writePackageJson(directory, contents) {
const packageJsonFilepath = `${directory}/package.json`;
await fs.writeJSON(packageJsonFilepath, contents, { spaces: 2 });
}
我们在上面的函数中使用的 fs.readJSON 和 fs.writeJSON 方法是由 fs-extra 库提供的。
在定义了 package.json 辅助函数后,我们可以开始考虑 package.json 文件的内容。
Node.js 支持两种模块类型:
CommonJS Modules (CJS)。https://link.segmentfault.com/?enc=KTweLvts6a1zOlUbn0cnCA%3D%3D.WRSoJ3t37Y2GYjanjStt81N%2BkCOGoR11gK8pv6Fck09uaKTSXHq5mvMcWutIRxYU 使用 module.exports 来导出函数和对象,在另一个模块中使用 require()加载它们。
ECMAScript Modules (ESM)。https://link.segmentfault.com/?enc=OUbF%2BkjeOpAlt3Fo9PFGFw%3D%3D.Ex%2BQVnop7rVNKtYxrnp%2Fhcm08eFRE7K2XLUBlFFQ7D4%3D 使用 export 来导出函数和对象,在另一个模块中使用 import 加载它们。
Node.js 生态系统正在逐步采用 ES 模块,这在客户端 JavaScript 中是很常见的。当事情处于过渡阶段时,我们需要决定我们的 Node.js 项目默认使用 CJS 模块还是 ESM 模块。让我们创建一个 promptForModuleSystem 函数,询问这个新项目应该使用哪种模块类型:
async function promptForModuleSystem(moduleSystems) {
const moduleSystem = await question(
`Which Node.js module system do you want to use? (${moduleSystems.join(
" or "
)}) `,
{
choices: moduleSystems,
}
);
return moduleSystem;
}
上面函数使用的 question 函数由 zx 提供。
现在我们将创建一个 getNodeModuleSystem 函数,以调用 promptForModuleSystem 函数。它将检查所输入的值是否有效。如果不是,它将再次询问:
async function getNodeModuleSystem() {
const moduleSystems = ["module", "commonjs"];
const selectedModuleSystem = await promptForModuleSystem(moduleSystems);
const isValidModuleSystem = moduleSystems.includes(selectedModuleSystem);
if (!isValidModuleSystem) {
console.error(
chalk.red(
`Error: Module system must be either '${moduleSystems.join(
"' or '"
)}'\n`
)
);
return await getNodeModuleSystem();
}
return selectedModuleSystem;
}
现在我们可以通过运行 npm init 命令生成我们项目的 package.json 文件:
await $`npm init --yes`;
然后我们将使用 readPackageJson 辅助函数来读取新创建的 package.json 文件。我们将询问项目应该使用哪个模块系统,并将其设置为 packageJson 对象中的 type 属性值,然后将其写回到项目的 package.json 文件中:
const packageJson = await readPackageJson(targetDirectory);
const selectedModuleSystem = await getNodeModuleSystem();
packageJson.type = selectedModuleSystem;
await writePackageJson(targetDirectory, packageJson);
提示:当你用--yes 标志运行 npm init 时,要想在 package.json 中获得合理的默认值,请确保你设置了 npminit-*的配置设置 https://link.segmentfault.com/?enc=X0aVKO8sVtj0bgZhFmgjVw%3D%3D.hxYbJxX5X2odlcPpk5MdIVLkT7ESAsFMhFnFSRBftI2c2BjNTUCtV1gwdXnpxWGI 。
安装所需项目依赖
为了使运行我们的启动工具后能够轻松地开始项目开发,我们将创建一个 promptForPackages 函数,询问要安装哪些 npm 包:
async function promptForPackages() {
let packagesToInstall = await question(
"Which npm packages do you want to install for this project? "
);
packagesToInstall = packagesToInstall
.trim()
.split(" ")
.filter((pkg) => pkg);
return packagesToInstall;
}
为了防止我们在输入包名时出现错别字,我们将创建一个 identifyInvalidNpmPackages 函数。这个函数将接受一个 npm 包名数组,然后运行 npm view 命令来检查它们是否存在:
async function identifyInvalidNpmPackages(packages) {
$.verbose = false;
let invalidPackages = [];
for (const pkg of packages) {
try {
await $`npm view ${pkg}`;
} catch (error) {
invalidPackages.push(pkg);
}
}
$.verbose = true;
return invalidPackages;
}
让我们创建一个 getPackagesToInstall 函数,使用我们刚刚创建的两个函数:
async function getPackagesToInstall() {
const packagesToInstall = await promptForPackages();
const invalidPackages = await identifyInvalidNpmPackages(packagesToInstall);
const allPackagesExist = invalidPackages.length === 0;
if (!allPackagesExist) {
console.error(
chalk.red(
`Error: The following packages do not exist on npm: ${invalidPackages.join(
", "
)}\n`
)
);
return await getPackagesToInstall();
}
return packagesToInstall;
}
如果有软件包名称不正确,上面的函数将显示一个错误,然后再次询问要安装的软件包。
一旦我们得到需要安装的有效包列表,就可以使用 npm install 命令来安装它们:
const packagesToInstall = await getPackagesToInstall();
const havePackagesToInstall = packagesToInstall.length > 0;
if (havePackagesToInstall) {
await $`npm install ${packagesToInstall}`;
}
为工具生成配置
创建项目配置是我们用项目启动工具自动完成的最佳事项。首先,让我们添加一个命令来生成一个.gitignore 文件,这样我们就不会意外地提交我们不希望在 Git 仓库中出现的文件:
await $`npx gitignore node`;
上面的命令使用 gitignore https://link.segmentfault.com/?enc=V%2FIoxipE2WxmNECDRNKMqg%3D%3D.7Y4d34n%2BoUVlJkYEiJt3NLa9RHn2pYtKq%2BHpO67HIQjfjKAHIxcMynZzTudktHaV 包,从 GitHub 的 gitignore https://link.segmentfault.com/?enc=Szh56TKqT6gYmrJidM7ZXg%3D%3D.BK7Vf5F73GaplJKeN48OsobM6U4EPriatZOr3dBhsSVZIuIUiktyqfS1jhLLtQ7o 模板中拉取 Node.js 的.gitignore 文件。
为了生成我们的 EditorConfig https://link.segmentfault.com/?enc=dGD5%2BEyCTqLfqDSif7%2FHgQ%3D%3D.xgNWBjR6m%2FIUFlG1UmM3CYX2R%2BLXFN531lZhlvCRRWs%3D、Prettier https://link.segmentfault.com/?enc=n7clHFqLMZZala0zu1JjdQ%3D%3D.jivAg4cL8HkSAhuurMUHrlfhGGILOpveTQR0rjCRXyg%3D 和 ESLint https://link.segmentfault.com/?enc=J4jXEAmmBQ83%2FWSrOtTsAQ%3D%3D.b%2FKC0kT%2FZWZQ9KBItXDMQHWJBzc5aW9hUZsxWHBsMY4%3D 配置文件,我们将使用一个叫做 Mrm 的命令行工具。
全局安装我们需要的 mrm 依赖项:
npm install --global mrm mrm-task-editorconfig mrm-task-prettier mrm-task-eslint
然后添加 mrm 命令行生成配置文件:
await $`npx mrm editorconfig`;
await $`npx mrm prettier`;
await $`npx mrm eslint`;
Mrm 负责生成配置文件,以及安装所需的 npm 包。它还提供了大量的配置选项,允许我们调整生成的配置文件以符合我们的个人偏好。
生成 README
我们可以使用我们的 readPackageJson 辅助函数,从项目的 package.json 文件中读取项目名称。然后我们可以生成一个基本的 Markdown 格式的 README,并将其写入 README.md 文件中:
const { name: projectName } = await readPackageJson(targetDirectory);
const readmeContents = `# ${projectName}
...
`;
await fs.writeFile(`${targetDirectory}/README.md`, readmeContents);
在上面的函数中,我们正在使用 fs-extra 暴露的 fs.writeFile 的 promise 变量。
提交项目骨架
最后,是时候提交我们用 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.`));
启动新项目
mkdir new-project
./bootstrap-tool.mjs --directory new-project
并观看我们所做的一切。
总结
在这篇文章中,我们已经学会了如何在 Node.js 中借助 Google 的 zx 库来创建强大的 shell 脚本。我们使用了它提供的实用功能和库来创建一个灵活的命令行工具。
到目前为止,我们所构建的工具只是一个开始。这里有一些功能点子,你可能想尝试自己添加:
自动创建目标目录。如果目标目录还不存在,则提示用户并询问他们是否想要为他们创建该目录。
开源卫生。问问用户他们是否在创建一个将是开源的项目。如果是的话,运行命令来生成许可证和贡献者文件。
自动创建 GitHub 上的仓库。添加使用 GitHub CLI 的命令,在 GitHub 上创建一个远程仓库。一旦用 Git 提交了初始骨架,新项目就可以被推送到这个仓库。
本文中的所有代码都可以在 GitHub 上找到。
本文译自:https://link.segmentfault.com/?enc=5Z%2ByLQNgua%2FY5gnguxXllg%3D%3D.SeNINYETng%2BH6uZupDjhsYNRqM5GWB5jXVDlvMdMqsO9u3CmWfwe8DwT48iAvK%2Fsm8pflQ8jbxHc1sB281%2B6pA%3D%3D
关注公众号:拾黑(shiheibook)了解更多
赞助链接:
关注数据与安全,洞悉企业级服务市场:https://www.ijiandao.com/
四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/
随时掌握互联网精彩