编写 Shell 脚本:问题
创建 shell 脚本(由 Bash 或 zsh 等 shell 执行的脚本)可能是自动执行重复任务的好方法。 Node.js 似乎是编写 shell 脚本的理想选择,因为它为我们提供了许多核心模块,并允许我们导入我们选择的任何库。它还使我们能够访问 JavaScript 提供的语言特性和内置函数。
但如果您尝试编写一个在 Node.js 下运行的 shell 脚本,您可能会发现它并不像您希望的那么顺利。您需要为子进程编写特殊处理,处理转义命令行参数,然后最终搞乱stdout(标准输出)和stderr(标准错误)。它不是特别直观,并且会使 shell 脚本编写变得非常尴尬。
Bash shell 脚本语言是编写 shell 脚本的流行选择。无需编写代码来处理子进程,并且它具有用于使用stdout和 的内置语言功能stderr。但用 Bash 编写 shell 脚本也不是那么容易。语法可能非常混乱,使得逻辑实现或处理提示用户输入之类的事情变得困难。
Google 的 zx库有助于使 Node.js 的 shell 脚本变得高效且有趣。
跟随要求
遵循本文有一些要求:
- 理想情况下,您应该熟悉 JavaScript 和 Node.js 的基础知识。
- 您需要熟悉在终端中运行命令。
- 您需要安装Node.js >= v14.13.1。
本文中的所有代码均可在 GitHub 上获取。
Google 的 zx 是如何工作的?
Google 的 zx 提供了封装子进程的创建以及这些进程的stdout处理的函数。stderr我们将使用的主要函数是$函数。这是一个实际的例子:
import { $ } from "zx";
await $`ls`;
这是执行该代码的输出:
$ ls
bootstrap-tool
hello-world
node_modules
package.json
README.md
typescript
上面示例中的 JavaScript 语法可能看起来有点奇怪。它使用一种称为标记模板文字的语言功能。它在功能上与 write 相同await $("ls")。
Google 的 zx 提供了其他几个实用函数来使 shell 脚本编写更容易,例如:
cd()。这允许我们更改当前的工作目录。question()。这是 Node.js readline模块的包装器。它使得提示用户输入变得简单。
除了 zx 提供的实用函数之外,它还为我们提供了几个流行的库,例如:
- 粉笔。该库允许我们为脚本的输出添加颜色。
- 极简主义。解析命令行参数的库。然后它们暴露在
argv物体下面。 - 拿来。 Fetch API的流行 Node.js 实现。我们可以用它来发出 HTTP 请求。
- fs-额外。一个公开 Node.js 核心fs 模块的库,以及许多其他方法,使使用文件系统变得更容易。
现在我们知道 zx 给了我们什么,让我们用它创建我们的第一个 shell 脚本。
Hello World 与 Google 的 zx
首先,我们创建一个新项目:
mkdir zx-shell-scripts
cd zx-shell-scripts
npm init --yes
然后我们就可以安装这个zx库了:
npm install --save-dev zx
注意:zx文档建议使用 npm 全局安装该库。通过将其安装为项目的本地依赖项,我们可以确保始终安装 zx,并控制 shell 脚本使用的版本。
顶层await
为了await在 Node.js 中使用顶级(await在函数之外),我们需要在支持顶级的ECMAScript (ES) 模块async中编写代码。我们可以通过添加our 来表明项目中的所有模块都是 ES 模块,也可以将单个脚本的文件扩展名设置为.我们将在本文的示例中使用文件扩展名。await``"type": "module"``package.json``.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
您会注意到 shell 脚本的输出中的一些内容:
- 我们运行的命令 (
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
将 Google 的 zx 与 TypeScript 结合使用
如果我们想要编写zx在 TypeScript 中使用的 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 行,告诉我们的操作系统内核使用程序运行脚本ts-node:
#! ./node_modules/.bin/ts-node
为了await在 TypeScript 代码中使用关键字,我们需要将其包装在立即调用的函数表达式 (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 类似,但需要一些额外的配置和代码包装。
构建项目引导工具
现在我们已经学习了使用 Google 的 zx 编写 shell 脚本的基础知识,我们将用它构建一个工具。该工具将自动创建一个通常非常耗时的流程:引导新 Node.js 项目的配置。
我们将创建一个交互式 shell 脚本来提示用户输入。它还将使用捆绑的chalk库zx以不同颜色突出显示输出并提供友好的用户体验。我们的 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
我们还将定义一个辅助函数,它以红色文本输出错误消息并退出 Node.js 进程,错误退出代码为1:
function exitWithError(errorMessage) {
console.error(chalk.red(errorMessage));
process.exit(1);
}
当我们需要处理错误时,我们将通过 shell 脚本在不同的地方使用这个辅助函数。
检查依赖关系
我们正在创建的工具需要运行使用三个不同程序的命令:git、node和npx。我们可以使用该库来帮助我们检查这些程序是否已安装并且可以使用。
首先,我们需要安装该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包,它解析传递给我们脚本的任何命令行参数。这些已解析的命令行参数argv由包提供zx。
让我们添加对名为 的命令行参数的检查directory:
let targetDirectory = argv.directory;
if (!targetDirectory) {
exitWithError("Error: You must specify the --directory argument");
}
如果directory参数已传递给我们的脚本,我们要检查它是否是存在的目录的路径。我们将使用fs.pathExists以下提供的方法fs-extra:
targetDirectory = path.resolve(targetDirectory);
if (!(await fs.pathExists(targetDirectory))) {
exitWithError(`Error: Target directory '${targetDirectory}' does not exist`);
}
如果目标目录存在,我们将使用cd提供的函数来zx更改当前工作目录:
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;
}
您会注意到我们正在关闭verbosezx 默认设置的模式。这意味着,当我们运行git config命令时,命令及其发送到标准输出的任何内容都不会显示。我们在函数末尾重新打开详细模式,这样就不会影响稍后在脚本中添加的任何其他命令。
现在我们将创建一个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.emailGit 设置是否已设置:
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 模块(CJS) 。用于
module.exports导出函数和对象,并将require()它们加载到另一个模块中。 - ECMAScript 模块(ESM) 。用于
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;
}
上面的函数使用了questionzx提供的函数。
我们现在将创建一个getNodeModuleSystem函数来调用我们的promptForModuleSystem函数。它将检查输入的值是否有效。如果不是,它将再次询问该问题:s
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;
}
package.json我们现在可以通过运行npm init命令来生成项目文件:
await $`npm init --yes`;
然后我们将使用readPackageJson辅助函数来读取新创建的package.json文件。我们将询问项目应该使用哪个模块系统,将其设置为对象type中属性的值packageJson,然后将其写回到项目的package.json文件中:
const packageJson = await readPackageJson(targetDirectory);
const selectedModuleSystem = await getNodeModuleSystem();
packageJson.type = selectedModuleSystem;
await writePackageJson(targetDirectory, packageJson);
提示:要在使用该标志package.json运行时获得合理的默认值,请确保设置 npm配置设置。npm init``--yes``init-*
安装所需的项目依赖项
为了在运行引导工具后轻松开始项目开发,我们将创建一个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.gitignore包从GitHub 的 gitignore templates中提取 Node.js文件。
为了生成EditorConfig、Prettier和ESLint配置文件,我们将使用名为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 包。它还提供了大量的配置选项,允许我们调整生成的配置文件以符合我们的个人喜好。
生成基本自述文件
我们可以使用readPackageJson辅助函数从项目package.json文件中读取项目名称。然后我们可以生成一个基本的 Markdown 格式的 README 并将其写入README.md文件:
const { name: projectName } = await readPackageJson(targetDirectory);
const readmeContents = `# ${projectName}
...
`;
await fs.writeFile(`${targetDirectory}/README.md`, readmeContents);
在上面的函数中,我们使用的fs.writeFile是fs-extra.
将项目骨架提交到 Git
最后,是时候提交我们使用以下命令创建的项目框架了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
观看我们所做的一切的实际行动!
结论
在本文中,我们了解了如何借助 Google 的 zx 库在 Node.js 中创建强大的 shell 脚本。我们使用它提供的实用函数和库来创建灵活的命令行工具。
到目前为止我们构建的工具仅仅是一个开始。以下是您可能想尝试自己添加的一些功能想法:
- 自动创建目标目录。 如果目标目录尚不存在,则提示用户并询问他们是否希望为其创建该目录。
- 开源卫生。 询问用户是否正在创建一个开源项目。如果是,请运行命令来生成许可证和贡献者约定文件。
- 自动在 GitHub 上创建存储库。添加使用GitHub CLI在 GitHub 上创建远程存储库的命令。一旦使用 Git 提交了初始框架,就可以将新项目推送到此存储库。
本文中的所有代码均可在 GitHub 上获取。
有关 Google ZX 和 Node Shell 脚本的常见问题 (FAQ)
什么是 Google ZX?它与传统的 shell 脚本有何不同?
Google ZX是Google开发的一款工具,可以简化编写shell脚本的过程。与繁琐且复杂的传统 shell 脚本不同,Google ZX 使用许多开发人员已经熟悉的 JavaScript 语言。这使得编写脚本变得更加容易,特别是对于那些不熟悉 shell 脚本的人来说。 Google ZX 还配备了一些内置功能和实用程序,使其使用起来更加方便。
如何安装 Google ZX?
Google ZX 可以使用 Node.js 的包管理器 npm 进行安装。您可以通过运行命令在系统上全局安装它npm i -g zx。安装后,您可以开始使用 JavaScript 编写脚本。
我可以将 async/await 与 Google ZX 一起使用吗?
是的,Google ZX 的优势之一是它支持现代 JavaScript 功能,例如 async/await。这使得编写异步代码变得更加容易,这在编写涉及 IO 操作或网络请求的脚本时特别有用。
如何使用 Google ZX 执行 shell 命令?
Google ZX 提供了一个特殊的函数,=, for executing shell commands. You can use it like this: await ='ls -la'`。该命令将在子进程中执行,该函数将返回一个包含命令输出的对象。
我可以将 Google ZX 与现有 shell 脚本一起使用吗?
是的,Google ZX 可以与现有的 shell 脚本一起使用。您可以使用 `= 函数从 ZX 脚本调用 shell 脚本。这允许您逐步将 shell 脚本迁移到 ZX,或者将 ZX 用于可从 JavaScript 功能中受益的部分脚本。
如何处理 Google ZX 中的错误?
Google ZX 提供了一种简单的方法来处理错误。如果 shell 命令失败,`= 函数将抛出异常。您可以使用 try/catch 块捕获此异常并根据需要处理错误。
我可以将 npm 包与 Google ZX 一起使用吗?
是的,您可以将任何 npm 包与 Google ZX 一起使用。您可以使用该import语句导入包,就像在任何其他 JavaScript 代码中一样。这使您可以在脚本中利用 npm 包的庞大生态系统。
如何调试 Google ZX 脚本?
调试 Google ZX 脚本与调试任何其他 JavaScript 代码类似。您可以用来console.log打印调试信息,或者使用 Node.js 或 Chrome DevTools 中内置的调试器。
我可以在 Windows 上使用 Google ZX 吗?
是的,Google ZX 适用于 Windows、Linux 和 macOS。但是,请记住,不同操作系统上的 shell 命令可能有所不同,因此您可能需要相应地调整脚本。
Google ZX 适合大型脚本吗?
Google ZX 旨在简化 shell 脚本编写,它可用于任何大小的脚本。但是,对于非常大的脚本或需要高级功能的脚本,Python 或 Ruby 等成熟的脚本语言可能更合适。