在這篇文章中,我們將學習谷歌的zx庫提供了什麼,以及我們如何使用它來用Node.js編寫shell
指令碼。然後,我們將學習如何通過構建一個命令列工具來使用zx的功能,幫助我們為新的Node.js專案引導設定。
建立一個由Bash或者zsh執行的shell指令碼,是自動化重複任務的好方法。Node.js似乎是編寫shell指令碼的理想選擇,因為它為我們提供了許多核心模組,並允許我們匯入任何我們選擇的庫。它還允許我們存取JavaScript提供的語言特性和內建函數。
如果你嘗試編寫執行在Node.js中的shell指令碼,你會發現這沒有你想象中的那麼順利。你需要為子程序編寫特殊的處理程式,注意跳脫命令列引數,然後最終與stdout
(標準輸出)和stderr
(標準錯誤)打交道。這不是特別直觀,而且會使shell
指令碼變得相當笨拙。
Bash shell指令碼語言是編寫shell指令碼的普遍選擇。不需要編寫程式碼來處理子程序,而且它有內建的語言特性來處理stdout
和stderr
。但是用Bash編寫shell指令碼也不是那麼容易。語法可能相當混亂,使得它實現邏輯,或者處理諸如提示使用者輸入的事情非常困難。
谷歌的zx庫有助於讓使用Node.js編寫的shell指令碼變得高效和舒適。
往下閱讀之前,有幾個前置條件需要遵循:
Node.js >= v14.13.1
。本文中的所有程式碼都可以從GitHub上獲得。
Google的zx提供了建立子程序的函數,以及處理這些程序的stdout
和stderr
的函數。我們將使用的主要函數是$
函數。下面是它的一個實際例子:
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提供的實用功能外,它還為我們提供了幾個流行的庫,比如:
argv
物件下被暴露出來。fs
模組的庫,以及一些額外的方法,使其更容易與檔案系統一起工作。現在我們知道了zx給了我們什麼,讓我們用它建立第一個shell指令碼。
首先,我們先建立一個新專案:
mkdir zx-shell-scripts
cd zx-shell-scripts
npm init --yes
然後安裝zx庫:
npm install --save-dev zx
注意:zx的檔案建議用npm全域性安裝該庫。通過將其安裝為我們專案的本地依賴,我們可以確保zx總是被安裝,並控制shell指令碼使用的版本。
為了在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中編寫使用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 是否有它需要的設定。我們要確保提交會被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 倉庫:
await $`git init`;
每個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支援兩種模組型別:
module.exports
來匯出函數和物件,在另一個模組中使用require()
載入它們。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
檔案。
為了生成我們的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-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上找到。
以上就是本文的所有內容。如果對你有所幫助,歡迎點贊、收藏、轉發~