如何使用zx編寫shell指令碼

chuck發表於2022-11-22

前言

在這篇文章中,我們將學習谷歌的zx庫提供了什麼,以及我們如何使用它來用Node.js編寫shell指令碼。然後,我們將學習如何透過構建一個命令列工具來使用zx的功能,幫助我們為新的Node.js專案引導配置。

編寫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的函式。我們將使用的主要函式是$函式。下面是它的一個實際例子:

import { $ } from "zx";

await $`ls`;

下面是執行上述程式碼的輸出:

$ ls
bootstrap-tool
hello-world
node_modules
package.json
README.md
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如何使用

首先,我們先建立一個新專案:

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指令碼在各個地方使用這個輔助函式。

檢查依賴

我們要建立的工具需要使用三個不同程式來執行命令:gitnodenpx。我們可以使用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.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檔案:

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.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函式,詢問這個新專案應該使用哪種模組型別:

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-*配置設定

安裝所需專案依賴

為了使執行我們的啟動工具後能夠輕鬆地開始專案開發,我們將建立一個 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包,從GitHub的gitignore模板中拉取Node.js的.gitignore檔案。

為了生成我們的EditorConfigPrettierESLint配置檔案,我們將使用一個叫做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上找到。

以上就是本文的所有內容。如果對你有所幫助,歡迎點贊、收藏、轉發~

相關文章