资讯动态

zx:让Node.js脚本编写如Bash般流畅的现代工具

发布时间:2026/8/8 4:28:38 来源:尧图企业网站定制
1. 从“胶水脚本”的困境说起为什么我们需要 zx如果你和我一样经常需要写一些“胶水脚本”——比如自动部署、批量处理文件、拉取数据、或者把几个命令行工具串起来干活——那你肯定对 Node.js 的child_process模块又爱又恨。爱的是它让我们能用 JavaScript 这个熟悉的语言去调用系统命令恨的是写起来实在太啰嗦了。想象一下这个场景你需要写一个脚本先git pull拉取最新代码然后npm install安装依赖接着运行构建命令最后把构建产物打包上传。用原生的 Node.js 写代码大概长这样const { exec, spawn } require(child_process); const util require(util); const execPromise util.promisify(exec); async function deploy() { try { console.log(开始拉取代码...); const { stdout: pullStdout, stderr: pullStderr } await execPromise(git pull origin main); console.log(pullStdout); if (pullStderr) console.error(pullStderr); console.log(开始安装依赖...); const { stdout: installStdout, stderr: installStderr } await execPromise(npm install); console.log(installStdout); if (installStderr) console.error(installStderr); console.log(开始构建...); const buildProcess spawn(npm, [run, build], { stdio: inherit }); await new Promise((resolve, reject) { buildProcess.on(close, (code) { if (code 0) resolve(); else reject(new Error(构建失败退出码: ${code})); }); }); console.log(开始打包上传...); // ... 又是一堆 exec 或 spawn } catch (error) { console.error(部署过程出错:, error); process.exit(1); } } deploy();这段代码的问题太多了手动处理stdout和stderr、区分exec和spawn、处理 Promise、拼接命令参数……写一个简单的脚本大部分精力都花在了处理 Node.js 的 API 上而不是脚本本身的逻辑。这还没考虑命令执行失败时的错误处理、跨平台兼容性比如 Windows 和 Linux/macOS 命令差异等问题。这就是谷歌开源的zx项目要解决的痛点。它的核心目标非常明确让你能在 Node.js 环境中用写 Bash 脚本一样简单直接的方式去执行 Shell 命令同时又能享受 JavaScript/TypeScript 强大的编程能力。它不是要替代 Bash而是为熟悉 JavaScript 生态的开发者提供一种更现代、更强大的脚本编写体验。你可以把它理解为一个超级增强版的 Bash底层是 Node.js 的引擎语法是 JavaScript 的灵活但写出来的感觉却像是在写行云流水的 Shell 脚本。2. zx 的核心设计哲学像写 Bash 一样写 JavaScriptzx 的 API 设计极其简洁它通过几个全局函数和对象几乎重构了我们在 Node.js 中执行命令的体验。安装 zx 非常简单因为它本身就是一个 npm 包npm install -g zx # 或者作为项目依赖安装 npm install --save-dev zx安装后你就可以创建一个.mjs文件ES Module 格式zx 推荐使用或者在.js文件开头加上#!/usr/bin/env zx这个 shebang 来直接运行。我们先来看一个最简单的例子感受一下它的魔力// deploy.mjs #!/usr/bin/env zx console.log(开始自动化部署流程...); // 1. 拉取最新代码 - $command 是 zx 的核心语法 await $git pull origin main; // 2. 安装依赖 await $npm install; // 3. 运行构建这里假设构建命令会输出大量日志我们直接继承父进程的IO await $npm run build; // 4. 检查构建产物目录是否存在 const buildDir dist; if (await fs.exists(buildDir)) { console.log(构建成功产物目录 ${buildDir} 已生成。); // 5. 打包产物 const timestamp new Date().toISOString().slice(0, 19).replace(/:/g, -); const packageName release-${timestamp}.tar.gz; await $tar -czf ${packageName} -C ${buildDir} .; console.log(已打包为: ${packageName}); } else { console.error(错误构建产物目录不存在); process.exit(1); } console.log(部署流程结束。);运行这个脚本只需要zx deploy.mjs或者直接./deploy.mjs如果文件有可执行权限。对比之前原生 Node.js 的版本你会发现代码量减少了至少一半而且逻辑清晰得像在写文档注释。那么$这个神奇的标签模板函数背后做了什么命令执行与输出处理$会自动执行你传入的命令字符串。默认情况下它会将命令的stdout通过管道连接到当前进程所以你直接就能在控制台看到实时输出无需手动处理console.log(stdout)。如果命令执行失败返回非零退出码$会自动抛出一个异常这强制你进行错误处理避免了静默失败。Promise 化$返回的是一个 Promise所以你可以直接用await等待命令执行完成代码是线性的非常符合直觉。参数安全与灵活注入这是 zx 的一大亮点。使用模板字符串你可以安全、方便地将 JavaScript 变量嵌入命令中。zx 会自动处理参数转义防止 Shell 注入攻击。例如let branch feat/new-awesome; // 安全zx 会自动将 branch 变量的值作为参数传递而不是直接拼接字符串 await $git checkout ${branch}; // 等价于安全地执行了git checkout feat/new-awesome如果你想传递多个参数也可以直接使用数组let files [src/index.js, src/utils.js]; await $ls -la ${files};丰富的内置工具除了$zx 还默认提供了一系列常用的 Node.js 模块让你无需import即可使用比如cd(): 改变当前工作目录。fetch(): 与 Web API 中的fetch一致用于发起 HTTP 请求。question(): 在命令行中向用户提问并获取输入。sleep(): 让脚本暂停一段时间。os、fs、path等核心模块也被直接引入。这种设计让脚本的编写重心完全回归到了业务流程本身而不是与 Node.js 底层 API 搏斗。2.1 与原生 child_process 和 Shell.js 的对比为了更清楚 zx 的定位我们可以简单对比一下几种常见的 Node.js 执行命令的方案特性Node.jschild_processshelljszxAPI 简洁度非常冗长需手动处理 stdio、错误、Promise 化较简洁提供同步风格的命令调用极简使用$标签模板近乎 Bash 语法异步支持需要手动util.promisify或使用回调主要提供同步方法异步支持有限原生 Promise/async-await线性逻辑输出处理需手动监听stdout/stderr流或等待缓冲默认捕获输出到变量默认继承到终端也可轻松捕获到变量错误处理需检查error对象或退出码需检查命令返回值自动抛出异常强制进行错误处理参数安全需开发者自行注意拼接安全有一定安全性自动转义模板变量安全性高跨平台是但命令字符串本身可能不兼容较好提供一些跨平台命令抽象是但命令本身仍需考虑平台差异zx 提供which等辅助适用场景需要精细控制子进程的复杂场景需要同步执行、简单跨平台的脚本现代、复杂的胶水脚本和自动化任务从对比可以看出zx 在编写体验和安全性上取得了很好的平衡特别适合当下以异步编程为主的 JavaScript 生态。3. 深入 zx 实战从基础到高级用法了解了 zx 的基本理念后我们通过一个更复杂的实战例子来拆解它的各项核心功能。假设我们要写一个脚本用于初始化一个新的前端项目克隆模板仓库、安装依赖、根据用户输入修改配置、并启动开发服务器。3.1 项目初始化与交互// init-project.mjs #!/usr/bin/env zx // 引入 chalk 用于彩色输出需额外安装 npm install chalk import chalk from chalk; console.log(chalk.blue.bold(️ 前端项目初始化工具)); // 1. 询问用户项目名称 let projectName await question(请输入项目名称: , { choices: [], // 可以预设选项这里留空自由输入 }); projectName projectName.trim(); if (!projectName) { console.error(chalk.red(项目名称不能为空)); process.exit(1); } // 2. 询问使用的模板 const template await question(请选择项目模板 (1: React, 2: Vue, 3: Svelte): , { choices: [1, 2, 3], }); let templateRepo; switch (template) { case 1: templateRepo https://github.com/some-org/react-starter.git; break; case 2: templateRepo https://github.com/some-org/vue-starter.git; break; case 3: templateRepo https://github.com/some-org/svelte-starter.git; break; default: console.error(chalk.red(无效选择)); process.exit(1); } // 3. 克隆模板仓库 console.log(chalk.yellow(正在克隆模板到目录: ./${projectName}...)); try { await $git clone ${templateRepo} ${projectName}; } catch (error) { // $ 命令失败会自动抛出异常这里可以捕获并给出友好提示 console.error(chalk.red(克隆仓库失败: ${error.message})); console.log(chalk.gray(请检查网络或模板仓库地址是否正确。)); process.exit(1); } // 4. 进入项目目录 cd(projectName); // 5. 安装依赖 console.log(chalk.yellow(正在安装项目依赖 (这可能需要几分钟)...)); // 默认情况下$ 的输出会直接显示。如果我们想隐藏输出或者捕获它可以配置 stdio await $npm install; // 正常显示安装日志 // 6. 修改项目配置文件 (例如 package.json 中的 name 字段) console.log(chalk.yellow(正在更新项目配置...)); // 使用 fs 模块读取和修改 JSON let pkg await fs.readJson(package.json); pkg.name projectName; await fs.writeJson(package.json, pkg, { spaces: 2 }); // 保持缩进格式 // 7. 询问是否现在启动开发服务器 const shouldStart await question(是否立即启动开发服务器? (y/N): ); if (shouldStart.toLowerCase() y) { console.log(chalk.green(\n 项目 ${projectName} 初始化完成)); console.log(chalk.cyan( 项目目录: ${process.cwd()})); console.log(chalk.cyan( 启动命令: npm run dev\n)); // 注意这里启动 dev server 会阻塞当前进程 // 在后台启动可以使用 spawn 或 $ 的 detach 选项这里简单演示前台启动 await $npm run dev; } else { console.log(chalk.green(\n 项目 ${projectName} 初始化完成)); console.log(chalk.cyan( 进入目录: cd ${projectName})); console.log(chalk.cyan( 安装依赖: npm install (已完成))); console.log(chalk.cyan( 启动开发: npm run dev\n)); }这个脚本展示了 zx 的几个关键特性用户交互使用question()函数。错误处理使用try...catch捕获$抛出的异常。文件操作直接使用fs模块由 zx 提供。流程控制线性的async/await语法使得复杂的多步骤流程清晰易懂。3.2 高级特性输出捕获、并行执行与进程控制在实际脚本中我们常常需要根据上一个命令的输出结果来决定下一个动作或者需要并行执行多个命令以提高效率。捕获命令输出默认情况下$会将命令的输出stdout打印到终端。但你可以轻松地将其捕获到一个变量中// 捕获 stdout 到变量 output let output await $ls -la; console.log(输出行数: ${output.stdout.split(\n).length}); // output 是一个包含多个属性的对象 // - stdout: 标准输出字符串 // - stderr: 标准错误字符串 // - exitCode: 退出码 // - signal: 终止信号如果有 // 如果你只关心 stdout 的文本可以使用 .toString() 或者直接取 stdout let currentBranch (await $git branch --show-current).stdout.trim(); console.log(当前分支是: ${currentBranch}); // 静默执行命令不输出到终端 let { stdout: hiddenOutput } await $find . -name *.log; // 默认还是会输出 // 要完全静默需要配置 stdio let { stdout: realHiddenOutput } await $find . -name *.tmp.quiet(); // .quiet() 是 zx 提供的简便方法并行执行命令当多个命令之间没有依赖关系时并行执行可以大幅缩短脚本运行时间。zx 本身基于 Promise所以可以很方便地使用Promise.all// 同时从多个源拉取数据 let [gitStatus, diskUsage, processList] await Promise.all([ $git status --short, $df -h, $ps aux | head -20, ]); console.log(Git 状态:, gitStatus.stdout); console.log(磁盘使用:, diskUsage.stdout); console.log(进程快照:, processList.stdout);进程控制与超时对于可能挂起或运行时间过长的命令设置超时是很好的实践import { timeout } from zx/experimental; // 注意超时功能在 experimental 命名空间下 try { // 设置 30 秒超时 await timeout(30_000, $wget http://example.com/large-file.zip); } catch (error) { if (error.name TimeoutError) { console.error(下载超时); } else { console.error(下载出错:, error.message); } }你也可以手动终止一个正在运行的命令let p $long-running-process; setTimeout(() { p.kill(); // 发送 SIGTERM 信号终止进程 console.log(进程已终止); }, 5000); await p;3.3 跨平台兼容性处理虽然 zx 让写脚本变简单了但 Shell 命令本身的跨平台问题依然存在。一个在 Linux/macOS 下能跑的rm -rf dist在 Windows 的 CMD 或 PowerShell 里就会报错。zx 提供了一些辅助函数来改善这个问题但核心思路是要么写兼容性代码要么明确脚本的运行环境。使用which检查命令是否存在let command cp; if (process.platform win32) { command copy; // 或者更推荐使用 Node.js 的 fs 模块进行跨平台文件操作 } if (await which(command)) { await $${command} source.txt destination.txt; } else { console.error(命令 ${command} 不存在尝试使用 fs.copyFileSync...); await fs.copy(source.txt, destination.txt); }使用os平台判断import { os } from zx; if (os.platform() win32) { // Windows 特定的逻辑 await $dir; } else { // Unix-like (Linux, macOS) 特定的逻辑 await $ls -la; }最实用的建议对于复杂的跨平台脚本优先考虑使用 Node.js 自身的fs、path、child_process等模块完成核心功能将 Shell 命令作为补充。或者在 Windows 环境下确保脚本在 Git Bash、WSL 或 Cygwin 这类提供 Unix 命令兼容层的环境中运行这样可以直接使用 Linux 风格的命令。4. 真实场景下的踩坑与最佳实践在实际项目中使用 zx 一年多我积累了不少经验教训。下面分享几个最常见的“坑”以及对应的解决方案。4.1 路径与工作目录的“幽灵”问题问题描述在脚本中频繁使用cd()改变目录或者在命令中使用了相对路径导致后续命令在非预期的目录下执行产生“文件找不到”等令人困惑的错误。根因分析cd()函数改变的是 Node.js 进程的当前工作目录process.cwd()。这是一个全局状态。如果在函数或条件分支中调用了cd()可能会意外影响全局状态。此外$执行命令时命令中的相对路径是基于当前工作目录解析的而不是基于脚本文件所在目录。解决方案与最佳实践显式管理路径尽量使用绝对路径或者通过path.resolve(__dirname, ‘...’)来获取基于脚本文件位置的绝对路径。import { path } from zx; const scriptDir path.dirname(process.argv[1]); // 获取脚本所在目录 const configPath path.resolve(scriptDir, ‘config’, ‘settings.json’); await $cat ${configPath};隔离目录变更如果必须使用cd()最好将其作用范围限制在一个代码块内并在结束后恢复原目录。const originalCwd process.cwd(); try { cd(‘./sub-project’); await $npm install; // ... 其他操作 } finally { cd(originalCwd); // 确保无论如何都切回原目录 }使用within函数zx 7.x新版 zx 提供了within函数可以创建一个临时的工作目录上下文。import { within } from zx; await within(‘./sub-project’, async () { await $npm install; // 此命令在 ./sub-project 目录下执行 await $ls -la; // 同样在 ./sub-project 目录下 }); // 这里已经自动回到了之前的目录 await $pwd; // 输出原目录4.2 命令失败静默与错误处理粒度问题描述默认情况下$在命令退出码非零时会抛出异常这很好。但有时某些命令的“失败”是可接受的比如grep没找到匹配项会返回1我们不想让整个脚本因此停止。解决方案 使用.nothrow()方法可以阻止$在非零退出码时抛出异常让你可以手动检查结果。// 检查某个关键词是否在日志中出现 let result await $grep -i error app.log.nothrow(); if (result.exitCode 0) { console.log(‘发现错误日志:’, result.stdout); } else if (result.exitCode 1) { console.log(‘日志中未发现“error”关键词。’); } else { // exitCode 1, 表示 grep 命令本身出错如文件不存在 console.error(‘grep 命令执行失败:’, result.stderr); process.exit(result.exitCode); }最佳实践对于你明确知道可能“正常失败”的命令使用.nothrow()。对于核心的、必须成功的步骤如git clone,npm install则依赖默认的抛异常行为并在顶层用try…catch捕获进行统一的错误报告和清理工作。4.3 环境变量与命令上下文问题描述在 zx 脚本中执行命令时有时会发现环境变量与直接在终端中执行不同导致命令行为异常例如某些工具需要特定的PATH或NODE_ENV。根因分析Node.js 子进程默认会继承父进程即你的 zx 脚本进程的环境变量。如果你的脚本是通过npm script、PM2 或其他进程管理器启动的其环境可能与你的交互式 Shell 不同。解决方案显式设置环境变量可以在$命令前设置或者使用process.env。// 为单条命令设置环境变量 await $NODE_ENVproduction npm run build; // 或者通过扩展语法注意不同Shell的语法zx在Unix下通常支持 // 更可靠的方式是使用 env 对象 await $({ env: { ...process.env, NODE_ENV: ‘production’ } })npm run build;调试环境在脚本开头打印关键环境变量。console.log(‘PATH:’, process.env.PATH); console.log(‘PWD:’, process.env.PWD); await $echo $SHELL;使用 dotenv如果需要加载.env文件可以配合dotenv包。import ‘dotenv/config’; // 现在 process.env 中包含了 .env 文件里定义的环境变量 await $some-command-that-needs-api-key;4.4 大型输出与内存消耗问题描述当执行一个会产生海量输出比如find / -name “*.log”的命令时如果默认捕获输出到变量let output await $…可能会耗尽内存因为整个输出都会被缓冲在内存中。解决方案对于可能产生大量输出的命令应该避免直接捕获整个stdout而是采用流式处理。// 不好的做法可能内存溢出 // let hugeOutput await $find / -type f -name *.log 2/dev/null; // 好的做法使用 spawn 的流式接口或者将输出重定向到文件 import { spawn } from ‘child_process’; const findProcess spawn(‘find’, [‘/’, ‘-type’, ‘f’, ‘-name’, ‘*.log’], { stdio: [‘ignore’, ‘pipe’, ‘ignore’], // 忽略 stdin 和 stderr }); let fileCount 0; findProcess.stdout.on(‘data’, (chunk) { // 逐块处理数据 const lines chunk.toString().split(‘\n’); fileCount lines.length - 1; // 粗略计数 }); await new Promise((resolve, reject) { findProcess.on(‘close’, resolve); findProcess.on(‘error’, reject); }); console.log(找到大约 ${fileCount} 个日志文件。);最佳实践如果命令输出是你需要进一步处理的数据且数据量可能很大优先考虑流式处理。如果输出仅仅是给人看的日志那么让$默认输出到终端即可无需捕获。5. 将 zx 集成到现代开发工作流zx 不仅仅可以写独立的脚本文件它可以很好地融入到现有的 Node.js 项目和 DevOps 流程中。5.1 作为 npm scripts 的增强器在你的package.json中你可以将复杂的脚本逻辑用 zx 来实现使npm run命令更强大、更可读。// package.json { scripts: { dev: vite, build: vite build, deploy:staging: zx ./scripts/deploy-staging.mjs, deploy:prod: zx ./scripts/deploy-prod.mjs, release: zx ./scripts/release.mjs, clean: zx -e \await $rm -rf dist node_modules/.cache\ } }deploy-staging.mjs脚本可以包含环境检查、代码构建、静态文件上传、云服务部署、发送通知等一系列操作逻辑清晰且易于维护。5.2 构建复杂的 CLI 工具你可以用 zx 来快速构建一个功能丰富的命令行工具。结合像yargs或commander这样的参数解析库可以轻松处理命令行参数。// my-cli.mjs #!/usr/bin/env zx import yargs from ‘yargs’; import { hideBin } from ‘yargs/helpers’; const argv yargs(hideBin(process.argv)) .option(‘project’, { alias: ‘p’, type: ‘string’, description: ‘项目名称’, demandOption: true, }) .option(‘force’, { alias: ‘f’, type: ‘boolean’, description: ‘强制覆盖已存在的目录’, }) .parse(); console.log(开始处理项目: ${argv.project}); if (await fs.exists(argv.project) !argv.force) { const answer await question(目录 ${argv.project} 已存在是否覆盖 (y/N): ); if (answer.toLowerCase() ! ‘y’) { process.exit(0); } await $rm -rf ${argv.project}; } // 使用传入的参数继续执行克隆、初始化等操作... await $git clone ${templateRepo} ${argv.project}; cd(argv.project); // ...然后你可以在package.json中配置bin字段将这个脚本发布为一个全局可用的 npm 包。5.3 在 CI/CD 流水线中发挥作用在 GitHub Actions、GitLab CI 或 Jenkins 等持续集成环境中zx 脚本可以作为独立的步骤运行。由于它本质上是 Node.js 脚本所以只要环境中装有 Node.js 和 zx就能运行非常适合将一系列琐碎的 Shell 命令步骤整合成一个逻辑完整的、可维护的脚本文件。例如一个 GitHub Actions 的步骤可以这样写# .github/workflows/deploy.yml jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 with: node-version: ‘18’ - run: npm install -g zx - run: zx ./scripts/ci-deploy.mjs env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} AWS_REGION: ‘us-east-1’ci-deploy.mjs脚本里可以安全地使用DEPLOY_TOKEN等密钥执行从构建、测试到部署的全套动作。我个人在多个项目中实践下来的体会是zx 极大地降低了我编写和维护自动化脚本的心理负担。它把“如何执行命令”这个技术细节很好地封装了起来让我能更专注于“要执行什么命令”和“命令之间的逻辑是什么”这些业务核心问题。对于任何经常需要与命令行打交道的 JavaScript/TypeScript 开发者来说它都是一个值得投入时间学习和使用的生产力利器。刚开始你可能会觉得它不过是语法糖但用久了就会发现这种流畅的编写体验和更安全的默认行为能让你的脚本代码更健壮、更易读也更容易被团队其他成员理解和接手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价