在本文中,我们将了解 Google 的 zx 库提供了什么,以及我们如何使用它来使用 Node.js 编写 shell 脚本。然后,我们将通过构建一个命令行工具来学习如何使用 zx 的功能,该工具可以帮助我们为新的 Node.js 项目引导配置。
编写 Shell 脚本:问题 {#writingshellscriptstheproblem}
创建一个 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 脚本。
跟随要求 {#requirementsforfollowingalong}
跟随本文有一些要求:
-
理想情况下,您应该熟悉 JavaScript 和 Node.js 的基础知识。
-
您需要能够在终端中轻松运行命令。
-
您需要安装Node.js >= v14.13.1。
Google 的 zx 是如何工作的? {#howdoesgoogleszxwork}
Google 的 zx 提供了封装子进程的创建以及处理stdout
这些stderr
进程的函数。我们将使用的主要函数是$
函数。这是它的一个例子:
import { $ } from "zx";await $`ls`;
这是执行该代码的输出:
$ lsbootstrap-toolhello-worldnode_modulespackage.jsonREADME.mdtypescript
上面示例中的 JavaScript 语法可能看起来有点古怪。它使用一种称为标记模板文字的语言功能。它在功能上与写作相同await $("ls")
。
Google 的 zx 提供了其他几个实用函数来简化 shell 脚本编写,例如:
-
cd()
. 这允许我们更改当前的工作目录。 -
question()
. 这是 Node.js readline模块的包装器。它可以直接提示用户输入。
除了 zx 提供的实用功能外,它还为我们提供了几个流行的库,例如:
-
chalk:这个库允许我们为脚本的输出添加颜色。
-
minimist:解析命令行参数的库。然后将它们暴露在
argv
物体下。 -
fetch:Fetch API 的流行 Node.js 实现。我们可以使用它来发出 HTTP 请求。
-
fs-extra:一个库,它公开了 Node.js 核心fs 模块,以及许多其他方法,可以更轻松地使用文件系统。
现在我们知道 zx 给了我们什么,让我们用它创建我们的第一个 shell 脚本。
使用 Google 的 zx 的 Hello World {#helloworldwithgoogleszx}
首先,让我们创建一个新项目:
mkdir zx-shell-scriptscd zx-shell-scriptsnpm init --yes
然后我们可以安装zx
库:
npm install --save-dev zx
注意:zx
文档建议使用 npm 全局安装库。通过将其安装为我们项目的本地依赖项,我们可以确保始终安装 zx,并控制我们的 shell 脚本使用的版本。
顶层 await
{#toplevelawait}
为了await
在 Node.js中使用顶层------在函数await
之外async
------我们需要在支持顶层的ECMAScript (ES) 模块await
中编写代码。"type": "module"
我们可以通过添加我们的 来表明一个项目中的所有模块都是 ES 模块package.json
,或者我们可以将单个脚本的文件扩展名设置为.mjs
. 我们将为.mjs
本文中的示例使用文件扩展名。
运行命令并捕获其输出 {#runningacommandandcapturingitsoutput}
让我们创建一个名为hello-world.mjs
. 我们将添加一个shebang 行,它告诉操作系统 (OS)内核使用程序运行脚本node
:
#! /usr/bin/env node
现在我们将添加一些使用 zx 运行命令的代码。
在下面的代码中,我们正在运行一个命令来执行ls程序。该ls
程序将列出当前工作目录(脚本所在的目录)中的文件。我们将捕获命令进程的标准输出,将其存储在变量中,然后将其记录到终端:
// hello-world.mjsimport { $ } 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
我们现在应该看到以下输出:
$ lshello-world.mjsnode_modulespackage.jsonpackage-lock.jsonREADME.mdhello-world.mjsnode_modulespackage.jsonpackage-lock.jsonREADME.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.mjsnode_modulespackage.jsonpackage-lock.json
使用 Google 的 zx 和 TypeScript {#usinggoogleszxwithtypescript}
如果我们想编写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.tsimport { $ } from "zx";
void (async function () {
await $`ls`;})();
然后我们需要使脚本可执行,以便我们可以直接执行它:
chmod u+x hello-world-typescript.ts
当我们运行脚本时:
./hello-world-typescript.ts
...我们应该看到以下输出:
$ lshello-world-typescript.tsnode_modulespackage.jsonpackage-lock.jsonREADME.mdtsconfig.json
使用 TypeScript编写脚本与zx
使用 JavaScript 类似,但需要对我们的代码进行一些额外的配置和包装。
构建项目引导工具 {#buildingaprojectbootstrappingtool}
现在我们已经了解了使用 Google 的 zx 编写 shell 脚本的基础知识,我们将使用它构建一个工具。此工具将自动创建一个通常很耗时的过程:引导新 Node.js 项目的配置。
我们将创建一个提示用户输入的交互式 shell 脚本。它还将使用捆绑的chalk
库zx
来突出显示不同颜色的输出并提供友好的用户体验。我们的 shell 脚本还将安装我们的新项目所需的 npm 包,因此我们可以立即开始开发。
入门 {#gettingstarted}
让我们创建一个名为的新文件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 脚本在不同的地方使用这个辅助函数。
检查依赖项 {#checkdependencies}
我们正在创建的工具将需要运行使用三个不同程序的命令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"]);
添加目标目录选项 {#addatargetdirectoryoption}
由于我们正在构建的工具将帮助我们引导新的 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.mjsError: You must specify the --directory argument
检查全局 Git 设置 {#checkglobalgitsettings}
稍后,我们将在项目目录中初始化一个新的 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;}
您会注意到我们正在关闭verbose
zx 默认设置的模式。这意味着,当我们运行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.`)
);
}
}}
让我们调用 add a call tocheckGlobalGitSettings
并检查user.name
和user.email
Git 设置是否已设置:
await checkGlobalGitSettings(["user.name", "user.email"]);
初始化一个新的 Git 存储库 {#initializeanewgitrepository}
我们可以通过添加以下命令在项目目录中初始化一个新的 Git 存储库:
await $`git init`;
生成package.json
文件 {#generateapackagejsonfile}
每个 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;}
上面的函数使用了question
zx 提供的函数。
我们现在将创建一个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;}
我们现在可以通过运行npm init命令生成我们的项目package.json
文件:
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-*
安装所需的项目依赖项 {#installrequiredprojectdependencies}
为了在运行我们的引导工具后更容易开始项目开发,我们将创建一个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}`;}
生成工具配置 {#generateconfigurationfortooling}
创建项目配置是我们使用项目引导工具实现自动化的完美之选。首先,让我们添加一个命令来生成.gitignore
文件,这样我们就不会意外地在 Git 存储库中提交我们不想要的文件:
await $`npx gitignore node`;
上面的命令使用gitignore包从GitHub 的 gitignore 模板中拉入 Node.js.gitignore
文件。
为了生成我们的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 包。它还提供了大量的配置选项,允许我们调整生成的配置文件以匹配我们的个人喜好。
生成一个基本的 README {#generateabasicreadme}
我们可以使用我们的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 {#committheprojectskeletontogit}
最后,是时候提交我们创建的项目框架了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.`));
引导一个新项目 {#bootstrapanewproject}
现在我们可以使用我们创建的工具来引导一个新项目:
mkdir new-project./bootstrap-tool.mjs --directory new-project
并观看我们在行动中整合的所有内容!
结论 {#conclusion}
在本文中,我们学习了如何借助 Google 的 zx 库在 Node.js 中创建强大的 shell 脚本。我们使用它提供的实用程序函数和库来创建灵活的命令行工具。