• 如何使用zx编写shell脚本


    编写Shell脚本的问题

    创建一个由Bash或者zsh执行的shell脚本,是自动化重复任务的好方法。Node.js似乎是编写shell脚本的理想选择,因为它为我们提供了许多核心模块,并允许我们导入任何我们选择的库。它还允许我们访问JavaScript提供的语言特性和内置函数。

    如果你尝试编写运行在Node.js中的shell脚本,你会发现这没有你想象中的那么顺利。你需要为子进程编写特殊的处理程序,注意转义命令行参数,然后最终与stdout(标准输出)和stderr(标准错误)打交道。这不是特别直观,而且会使shell脚本变得相当笨拙。

    Bash shell脚本语言是编写shell脚本的普遍选择。不需要编写代码来处理子进程,而且它有内置的语言特性来处理stdoutstderr。但是用Bash编写shell脚本也不是那么容易。语法可能相当混乱,使得它实现逻辑,或者处理诸如提示用户输入的事情非常困难。

    谷歌的zx库有助于让使用Node.js编写的shell脚本变得高效和舒适。

    前置条件

    往下阅读之前,有几个前置条件需要遵循:

    • 理想情况下,你应该熟悉JavaScript和Node.js的基础知识。
    • 你需要适应在终端中运行命令。
    • 你需要安装Node.js >= v14.13.1

    本文中的所有代码都可以从GitHub上获得。

    zx如何运作

    Google的zx提供了创建子进程的函数,以及处理这些进程的stdoutstderr的函数。我们将使用的主要函数是$函数。下面是它的一个实际例子:

    1. import { $ } from "zx";
    2. await $`ls`;

    下面是执行上述代码的输出:

    1. $ ls
    2. bootstrap-tool
    3. hello-world
    4. node_modules
    5. package.json
    6. README.md
    7. typescript

    上面的例子中的JavaScript语法可能看起来有点古怪。它使用了一种叫做带标签的模板字符串的语言特性。它在功能上与编写await $("ls")相同。

    谷歌的zx提供了其他几个实用功能,使编写shell脚本更容易。比如:

    • cd()。允许我们更改当前工作目录。
    • question()。这是Node.js readline模块的包装器。它使提示用户输入变得简单明了。

    除了zx提供的实用功能外,它还为我们提供了几个流行的库,比如:

    • chalk。这个库允许我们为脚本的输出添加颜色。
    • minimist。一个解析命令行参数的库。然后它们在argv对象下被暴露出来。
    • fetch。Fetch API的Node.js实现。我们可以用它来进行HTTP请求。
    • fs-extra。一个暴露Node.js核心fs模块的库,以及一些额外的方法,使其更容易与文件系统一起工作。

    现在我们知道了zx给了我们什么,让我们用它创建第一个shell脚本。

    zx如何使用

    首先,我们先创建一个新项目:

    1. mkdir zx-shell-scripts
    2. cd zx-shell-scripts
    3. 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程序将列出当前工作目录(脚本所在的目录)中的文件。我们将从命令的进程中捕获标准输出,将其存储在一个变量中,然后打印到终端:

    1. // hello-world.mjs
    2. import { $ } from "zx";
    3. const output = (await $`ls`).stdout;
    4. 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
    

    可以看到如下输出:

    1. $ ls
    2. hello-world.mjs
    3. node_modules
    4. package.json
    5. package-lock.json
    6. README.md
    7. hello-world.mjs
    8. node_modules
    9. package.json
    10. package-lock.json
    11. README.md

    你会注意到:

    • 我们运行的命令(ls)被包含在输出中。
    • 命令的输出显示两次。
    • 在输出的末尾多了一个新行。

    zx默认以verbose模式运行。它将输出你传递给$函数的命令,同时也输出该命令的标准输出。我们可以通过在运行ls命令前加入以下一行代码来改变这种行为:

    $.verbose = false;
    

    大多数命令行程序,如ls,会在其输出的结尾处输出一个新行字符,以使输出在终端中更易读。这对可读性有好处,但由于我们要将输出存储在一个变量中,我们不希望有这个额外的新行。我们可以用JavaScript String#trim()函数把它去掉:

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

    再次运行脚本,结果看起来好很多:

    1. hello-world.mjs
    2. node_modules
    3. package.json
    4. 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文件包含下面的配置:

    1. {
    2. "compilerOptions": {
    3. "target": "es2017",
    4. "module": "commonjs"
    5. }
    6. }

    创建新的脚本,并命名为hello-world-typescript.ts。首先,添加Shebang行,告诉OS内核使用ts-node程序来运行我们的脚本:

    #! ./node_modules/.bin/ts-node
    

    为了在我们的TypeScript代码中使用await关键字,我们需要把它包装在一个立即调用函数表达式(IIFE)中,正如zx文档所建议的那样:

    1. // hello-world-typescript.ts
    2. import { $ } from "zx";
    3. void (async function () {
    4. await $`ls`;
    5. })();

    然后需要让脚本可执行:

    chmod u+x hello-world-typescript.ts
    

    运行脚本:

    ./hello-world-typescript.ts
    

    可以看到下面的输出:

    1. $ ls
    2. hello-world-typescript.ts
    3. node_modules
    4. package.json
    5. package-lock.json
    6. README.md
    7. tsconfig.json

    在TypeScript中用zx编写脚本与使用JavaScript相似,但需要对我们的代码进行一些额外的配置和包装。

    构建项目启动工具

    现在我们已经学会了用谷歌的zx编写shell脚本的基本知识,我们要用它来构建一个工具。这个工具将自动创建一个通常很耗时的过程:为一个新的Node.js项目的配置提供引导。

    我们将创建一个交互式shell脚本,提示用户输入。它还将使用zx内置的chalk库,以不同的颜色高亮输出,并提供一个友好的用户体验。我们的shell脚本还将安装新项目所需的npm包,所以它已经准备好让我们立即开始开发。

    准备开始

    首先创建一个名为bootstrap-tool.mjs的新文件,并添加shebang行。我们还将从zx包中导入我们要使用的函数和模块,以及Node.js核心path模块:

    1. #! /usr/bin/env node
    2. // bootstrap-tool.mjs
    3. import { $, argv, cd, chalk, fs, question } from "zx";
    4. import path from "path";

    与我们之前创建的脚本一样,我们要使我们的新脚本可执行:

    chmod u+x bootstrap-tool.mjs
    

    我们还将定义一个辅助函数,用红色文本输出一个错误信息,并以错误退出代码1退出Node.js进程:

    1. function exitWithError(errorMessage) {
    2. console.error(chalk.red(errorMessage));
    3. process.exit(1);
    4. }

    当我们需要处理一个错误时,我们将通过我们的shell脚本在各个地方使用这个辅助函数。

    检查依赖

    我们要创建的工具需要使用三个不同程序来运行命令:gitnodenpx。我们可以使用which库来帮助我们检查这些程序是否已经安装并可以使用。

    首先,我们需要安装which

    npm install --save-dev which
    

    然后引入它:

    import which from "which";
    

    然后创建一个使用它的checkRequiredProgramsExist函数:

    1. async function checkRequiredProgramsExist(programs) {
    2. try {
    3. for (let program of programs) {
    4. await which(program);
    5. }
    6. } catch (error) {
    7. exitWithError(`Error: Required command ${error.message}`);
    8. }
    9. }

    上面的函数接受一个程序名称的数组。它循环遍历数组,对每个程序调用which函数。如果which找到了程序的路径,它将返回该程序。否则,如果该程序找不到,它将抛出一个错误。如果有任何程序找不到,我们就调用exitWithError辅助函数来显示一个错误信息并停止运行脚本。

    我们现在可以添加一个对checkRequiredProgramsExist的调用,以检查我们的工具所依赖的程序是否可用:

    await checkRequiredProgramsExist(["git", "node", "npx"]);
    

    添加目标目录选项

    由于我们正在构建的工具将帮助我们启动新的Node.js项目,因此我们希望在项目的目录中运行我们添加的任何命令。我们现在要给脚本添加一个 --directory命令行参数。

    zx内置了minimist包,它能够解析传递给脚本的任何命令行参数。这些被解析的命令行参数被zx包作为argv提供:

    让我们为名为directory的命令行参数添加一个检查:

    1. let targetDirectory = argv.directory;
    2. if (!targetDirectory) {
    3. exitWithError("Error: You must specify the --directory argument");
    4. }

    如果directory参数被传递给了我们的脚本,我们要检查它是否是已经存在的目录的路径。我们将使用fs-extra提供的fs.pathExists方法:

    1. targetDirectory = path.resolve(targetDirectory);
    2. if (!(await fs.pathExists(targetDirectory))) {
    3. exitWithError(`Error: Target directory '${targetDirectory}' does not exist`);
    4. }

    如果目标路径存在,我们将使用zx提供的cd函数来切换当前的工作目录:

    cd(targetDirectory);
    

    如果我们现在在没有--directory参数的情况下运行脚本,我们应该会收到一个错误:

    1. $ ./bootstrap-tool.mjs
    2. Error: You must specify the --directory argument

    检查全局Git设置

    稍后,我们将在项目目录下初始化一个新的 Git 仓库,但首先我们要检查 Git 是否有它需要的配置。我们要确保提交会被GitHub等代码托管服务正确归类。

    为了做到这一点,这里创建一个getGlobalGitSettingValue函数。它将运行 git config命令来检索Git配置设置的值:

    1. async function getGlobalGitSettingValue(settingName) {
    2. $.verbose = false;
    3. let settingValue = "";
    4. try {
    5. settingValue = (
    6. await $`git config --global --get ${settingName}`
    7. ).stdout.trim();
    8. } catch (error) {
    9. // Ignore process output
    10. }
    11. $.verbose = true;
    12. return settingValue;
    13. }

    你会注意到,我们正在关闭zx默认设置的verbose模式。这意味着,当我们运行git config命令时,该命令和它发送到标准输出的任何内容都不会被显示。我们在函数的结尾处将verbose模式重新打开,这样我们就不会影响到我们稍后在脚本中添加的任何其他命令。

    现在我们添加checkGlobalGitSettings函数,该函数接收Git设置名称组成的数组。它将循环遍历每个设置名称,并将其传递给getGlobalGitSettingValue函数以检索其值。如果设置没有值,将显示警告信息:

    1. async function checkGlobalGitSettings(settingsToCheck) {
    2. for (let settingName of settingsToCheck) {
    3. const settingValue = await getGlobalGitSettingValue(settingName);
    4. if (!settingValue) {
    5. console.warn(
    6. chalk.yellow(`Warning: Global git setting '${settingName}' is not set.`)
    7. );
    8. }
    9. }
    10. }

    让我们给checkGlobalGitSettings添加一个调用,检查user.nameuser.email的Git设置是否已经被设置:

    await checkGlobalGitSettings(["user.name", "user.email"]);
    

    初始化Git仓库

    我们可以通过添加以下命令在项目目录下初始化一个新的 Git 仓库:

    await $`git init`;
    

    生成package.json

    每个Node.js项目都需要package.json文件。这是我们为项目定义元数据的地方,指定项目所依赖的包,以及添加实用的脚本。

    在我们为项目生成package.json文件之前,我们要创建几个辅助函数。第一个是readPackageJson函数,它将从项目目录中读取package.json文件:

    1. async function readPackageJson(directory) {
    2. const packageJsonFilepath = `${directory}/package.json`;
    3. return await fs.readJSON(packageJsonFilepath);
    4. }

    然后我们将创建一个writePackageJson函数,我们可以用它来向项目的package.json文件写入更改:

    1. async function writePackageJson(directory, contents) {
    2. const packageJsonFilepath = `${directory}/package.json`;
    3. await fs.writeJSON(packageJsonFilepath, contents, { spaces: 2 });
    4. }

    我们在上面的函数中使用的fs.readJSONfs.writeJSON方法是由fs-extra库提供的。

    在定义了package.json辅助函数后,我们可以开始考虑package.json文件的内容。

    Node.js支持两种模块类型:

    • CommonJS Modules (CJS)。使用module.exports来导出函数和对象,在另一个模块中使用require()加载它们。
    • ECMAScript Modules (ESM)。使用export来导出函数和对象,在另一个模块中使用import加载它们。

    Node.js生态系统正在逐步采用ES模块,这在客户端JavaScript中是很常见的。当事情处于过渡阶段时,我们需要决定我们的Node.js项目默认使用CJS模块还是ESM模块。让我们创建一个promptForModuleSystem函数,询问这个新项目应该使用哪种模块类型:

    1. async function promptForModuleSystem(moduleSystems) {
    2. const moduleSystem = await question(
    3. `Which Node.js module system do you want to use? (${moduleSystems.join(
    4. " or "
    5. )}) `,
    6. {
    7. choices: moduleSystems,
    8. }
    9. );
    10. return moduleSystem;
    11. }

    上面函数使用的question函数由zx提供。

    现在我们将创建一个getNodeModuleSystem函数,以调用 promptForModuleSystem函数。它将检查所输入的值是否有效。如果不是,它将再次询问:

    1. async function getNodeModuleSystem() {
    2. const moduleSystems = ["module", "commonjs"];
    3. const selectedModuleSystem = await promptForModuleSystem(moduleSystems);
    4. const isValidModuleSystem = moduleSystems.includes(selectedModuleSystem);
    5. if (!isValidModuleSystem) {
    6. console.error(
    7. chalk.red(
    8. `Error: Module system must be either '${moduleSystems.join(
    9. "' or '"
    10. )}'\n`
    11. )
    12. );
    13. return await getNodeModuleSystem();
    14. }
    15. return selectedModuleSystem;
    16. }

    现在我们可以通过运行npm init命令生成我们项目的package.json文件:

    await $`npm init --yes`;
    

    然后我们将使用readPackageJson辅助函数来读取新创建的package.json文件。我们将询问项目应该使用哪个模块系统,并将其设置为packageJson对象中的type属性值,然后将其写回到项目的package.json文件中:

    1. const packageJson = await readPackageJson(targetDirectory);
    2. const selectedModuleSystem = await getNodeModuleSystem();
    3. packageJson.type = selectedModuleSystem;
    4. await writePackageJson(targetDirectory, packageJson);

    提示:当你用--yes标志运行npm init时,要想在package.json中获得合理的默认值,请确保你设置了npminit-*配置设置

    安装所需项目依赖

    为了使运行我们的启动工具后能够轻松地开始项目开发,我们将创建一个 promptForPackages函数,询问要安装哪些npm包:

    1. async function promptForPackages() {
    2. let packagesToInstall = await question(
    3. "Which npm packages do you want to install for this project? "
    4. );
    5. packagesToInstall = packagesToInstall
    6. .trim()
    7. .split(" ")
    8. .filter((pkg) => pkg);
    9. return packagesToInstall;
    10. }

    为了防止我们在输入包名时出现错别字,我们将创建一个identifyInvalidNpmPackages函数。这个函数将接受一个npm包名数组,然后运行npm view命令来检查它们是否存在:

    1. async function identifyInvalidNpmPackages(packages) {
    2. $.verbose = false;
    3. let invalidPackages = [];
    4. for (const pkg of packages) {
    5. try {
    6. await $`npm view ${pkg}`;
    7. } catch (error) {
    8. invalidPackages.push(pkg);
    9. }
    10. }
    11. $.verbose = true;
    12. return invalidPackages;
    13. }

    让我们创建一个getPackagesToInstall函数,使用我们刚刚创建的两个函数:

    1. async function getPackagesToInstall() {
    2. const packagesToInstall = await promptForPackages();
    3. const invalidPackages = await identifyInvalidNpmPackages(packagesToInstall);
    4. const allPackagesExist = invalidPackages.length === 0;
    5. if (!allPackagesExist) {
    6. console.error(
    7. chalk.red(
    8. `Error: The following packages do not exist on npm: ${invalidPackages.join(
    9. ", "
    10. )}\n`
    11. )
    12. );
    13. return await getPackagesToInstall();
    14. }
    15. return packagesToInstall;
    16. }

    如果有软件包名称不正确,上面的函数将显示一个错误,然后再次询问要安装的软件包。

    一旦我们得到需要安装的有效包列表,就可以使用npm install命令来安装它们:

    1. const packagesToInstall = await getPackagesToInstall();
    2. const havePackagesToInstall = packagesToInstall.length > 0;
    3. if (havePackagesToInstall) {
    4. await $`npm install ${packagesToInstall}`;
    5. }

    为工具生成配置

    创建项目配置是我们用项目启动工具自动完成的最佳事项。首先,让我们添加一个命令来生成一个.gitignore文件,这样我们就不会意外地提交我们不希望在Git仓库中出现的文件:

    await $`npx gitignore node`;
    

    上面的命令使用gitignore包,从GitHub的gitignore模板中拉取Node.js的.gitignore文件。

    为了生成我们的EditorConfigPrettierESLint配置文件,我们将使用一个叫做Mrm的命令行工具。

    全局安装我们需要的mrm依赖项:

    npm install --global mrm mrm-task-editorconfig mrm-task-prettier mrm-task-eslint
    

    然后添加mrm命令行生成配置文件:

    1. await $`npx mrm editorconfig`;
    2. await $`npx mrm prettier`;
    3. await $`npx mrm eslint`;

    Mrm负责生成配置文件,以及安装所需的npm包。它还提供了大量的配置选项,允许我们调整生成的配置文件以符合我们的个人偏好。

    生成README

    我们可以使用我们的readPackageJson辅助函数,从项目的package.json文件中读取项目名称。然后我们可以生成一个基本的Markdown格式的README,并将其写入README.md文件中:

    1. const { name: projectName } = await readPackageJson(targetDirectory);
    2. const readmeContents = `# ${projectName}
    3. ...
    4. `;
    5. await fs.writeFile(`${targetDirectory}/README.md`, readmeContents);

    在上面的函数中,我们正在使用fs-extra暴露的fs.writeFile的promise变量。

    提交项目骨架

    最后,是时候提交我们用git创建的项目骨架了:

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

    然后我们将显示一条消息,确认我们的新项目已经成功启动:

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

    启动新项目

    现在我们可以使用我们创建的工具来启动一个新的项目:

    1. mkdir new-project
    2. ./bootstrap-tool.mjs --directory new-project
  • 相关阅读:
    算法公司TikTok的黑科技: Seeing Like an algorithm
    基于JSP的图书销售管理系统
    React 19 带来了 JSX 运行时的重要更新
    java计算机毕业设计郑工校园二手交易平台网站源程序+mysql+系统+lw文档+远程调试
    Visual Studio 2019 导出DLL库
    uniapp实现表格冻结
    【洛谷题解/NOI2001】P2704/NOI2001炮兵阵地
    安道亮相深圳国际全触与显示展,展示最新商显研发成果!
    洛谷 P1306 斐波那契公约数 (矩阵快速幂, 辗转相除法)
    删除安装Google Chrome浏览器时捆绑安装的Google 文档、表格、幻灯片、Gmail、Google 云端硬盘、YouTube网址链接(Mac)
  • 原文地址:https://blog.csdn.net/jh035/article/details/128030183