首页
/ zx API Reference 深度解析:核心 API、配置预设与底层实现原理

zx API Reference 深度解析:核心 API、配置预设与底层实现原理

2026-09-05 09:40:22作者:乔或婵

本文基于 zx 仓库的官方 API 参考文档 docs/api.md,完整覆盖 $ 模板命令、可链式配置预设、cd()/within() 等上下文 API、fetch()/retry() 等实用工具函数,以及 quote 系列与 shell 切换函数。同时结合 src/core.tssrc/goods.tssrc/util.ts 的源码实现,解释每个 API 的调用链、默认值来源与适用限制,帮助你在编写跨平台脚本时既能“照抄即用”,又能理解每个行为背后的机制。

一、$ 模板字符串与 $.sync:同步/异步两种执行模式

zx 的核心入口是 $ 对象。它既是一个模板标签函数,又承载全部默认配置,因此文档将其称为“the zx”。执行一条命令的两种基本方式是:

const list = await $`ls -la`   // 异步,返回 ProcessPromise
const dir = $.sync`pwd`        // 同步,返回 ProcessOutput

从源码结构看,这一行为的实现在 src/core.ts 中:$ 是一个由 sync$ 包装的 Proxy(src/core.tsfunction sync$(fn, makeSync)),当访问 $ 上不存在的属性时,优先从 getStore()(当前 AsyncLocalStorage 上下文或 defaults)中取值;而 sync 是一个特例 getter,访问它时直接返回 () => $({ sync: true }) 生成的同步版 $。也就是说,$.synccmd 等价于 `$({sync: true})`cmd

两者返回不同类型的对象:

  • 异步模式返回 ProcessPromise——一个 Promise<ProcessOutput>,在其上可直接链式调用 pipenothrow()timeout() 等;
  • 同步模式返回 ProcessOutput——一个继承自 Error 的结果对象,带有 stdoutstderrstdallexitCodeokduration 等字段(见 src/core.tsclass ProcessOutput)。

一个容易踩坑的细节:同步模式“不能等待异步解析的命令”。ProcessPromise.build() 中明确抛出 sync mode does not allow async command resolutionsrc/core.tsbuild() 方法),因此 $.sync 适合简单命令,复杂脚本建议用异步。

二、$({...}):配置工厂与可链式预设

$ 对象持有所有执行的默认配置(详见 Configuration 文档)。要把一套自定义配置复用到多条命令上,可以把 $ 当工厂调用:

const $$ = $({
  verbose: false,
  env: {NODE_ENV: 'production'},
})

const env = await $$`node -e 'console.log(process.env.NODE_ENV)'`
const pwd = $$.sync`pwd`
const hello = $({quiet: true})`echo "Hello!"`

实现上,$ 收到非模板参数时,返回一个新的代理函数,其内部执行 within(() => Object.assign($, opts, pieces))src/core.tsexport const $ 的定义)。Object.assign 将新选项叠加在“当前上下文”之上,within 则保证叠加的选项只在回调内的 $ 调用中生效——这就是预设能“链式”的根本原因:

const $1 = $({ nothrow: true })
assert.equal((await $1`exit 1`).exitCode, 1)

const $2 = $1({ sync: true }) // 同时应用 {nothrow: true, sync: true}
assert.equal($2`exit 2`.exitCode, 2)

const $3 = $({ sync: true })({ nothrow: true })
assert.equal($3`exit 3`.exitCode, 3)

每次 $({...}) 都是“在当前上下文快照上再叠一层”,多层预设自然合并。

$({input}):向命令注入 stdin

input 选项把给定数据写入命令的标准输入,支持字符串、BufferReadable 流,甚至其他 ProcessOutput/ProcessPromise(把另一个进程的输出作为输入):

const p1 = $({ input: 'foo' })`cat`
const p2 = $({ input: Readable.from('bar') })`cat`
const p3 = $({ input: Buffer.from('baz') })`cat`
const p4 = $({ input: p3 })`cat`
const p5 = $({ input: await p3 })`cat`

src/core.tsrun() 方法中可以看到,input 若为 ProcessPromise | ProcessOutput 会被解包为 .stdout 后再交给底层 zurk 执行器:input: ($.input as ProcessPromise | ProcessOutput)?.stdout ?? $.input,这解释了为什么“进程传进程”是类型安全且可直接运行的。

$({signal}):可中断的进程

signal 让进程可以随 AbortController 一起取消:

const {signal} = new AbortController()
const p = $({ signal })`sleep 9999`

setTimeout(() => signal.abort('reason'), 1000)

源码中每个命令都会自动拥有一个 AbortControllergetSnapshotac: opts.ac || new AbortController()),ProcessPromise 还暴露 signal getter 与 abort(reason?) 方法。需要注意源码里的约束:若传入的 signal 与内部 ac 不是同一个,abort() 会抛出 The signal is controlled by another process.,即“谁提供 signal,谁负责取消”。

$({timeout}):超时自动杀进程

timeout 选项按 Duration 类型指定自动终止时限:

const p = $({timeout: '1s'})`sleep 999`

Durationsrc/util.ts 中定义为 number | \{number}m\` | \`{number}s` | `{number}ms\``,并由 `parseDuration()` 统一换算为毫秒(数字直接视为毫秒,`'1s'`→1000,`'2m'`→120000)。在 `ProcessPromise` 中,`timeout()` 方法会注册一个 `setTimeout`,到期后以 `timeoutSignal`(默认 `SIGTERM`,可配 `.timeoutSignal`)杀掉进程,命令结束(无论成败)时定时器被清理。

$({nothrow}):吞掉失败,返回结果对象

默认情况下,非 0 退出码会让 Promise reject。nothrow 选项则抑制抛错,直接返回带完整细节的 ProcessOutput

const o1 = await $({nothrow: true})`exit 1`
o1.ok       // false
o1.exitCode // 1
o1.message  // exit code: 1 ...

const o2 = await $({nothrow: true, spawn() { throw new Error('BrokenSpawn') }})`echo foo`
o2.ok       // false
o2.exitCode // null
o2.message  // BrokenSpawn ...

第二个例子很能说明实现细节:连自定义 spawn 抛出的异常(这里 BrokenSpawn)也会被包进 ProcessOutputexitCodenull,因为进程根本没生出来)。这在 src/core.ts 中对应 finalize() 的逻辑:output.ok || this.isNothrow() 为真时走 resolve 分支,而不是 reject。

完整 Options 接口与默认值

文档给出的完整选项列表如下:

interface Options {
  cwd:            string
  ac:             AbortController
  signal:         AbortSignal
  input:          string | Buffer | Readable | ProcessOutput | ProcessPromise
  timeout:        Duration
  timeoutSignal:  NodeJS.Signals
  stdio:          StdioOptions
  verbose:        boolean
  sync:           boolean
  env:            NodeJS.ProcessEnv
  shell:          string | true
  nothrow:        boolean
  prefix:         string
  postfix:        string
  quote:          typeof quote
  quiet:          boolean
  detached:       boolean
  preferLocal:    boolean | string | string[]
  spawn:          typeof spawn
  spawnSync:      typeof spawnSync
  store:          TSpawnStore
  log:            typeof log
  kill:           typeof kill
  killSignal:     NodeJS.Signals
  halt:           boolean
  delimiter:      string | RegExp
}

各选项的默认值可以在 src/core.tsdefaults 常量中一一对上:

选项 默认值 说明
cwd process.cwd() 所有子进程的工作目录
env process.env 子进程环境变量
shell true true 表示自动探测(默认 which bash
stdio 'pipe' 等效于 ['pipe','pipe','pipe']
verbose / quiet false / false 控制命令与输出的打印
nothrow false 非 0 退出码是否抛错
sync false 同步执行
detached false 是否独立进程组
preferLocal false 是否优先 node_modules/.bin
spawn / spawnSync child_process.spawn / spawnSync 可整体替换执行器
killSignal / timeoutSignal SIGTERM kill/超时时使用的信号
log / kill 内置实现 日志与进程树终止函数,可覆写

此外还有一个值得知道的事实:源码中的 resolveDefaults() 会扫描环境变量,凡是以 ZX_ 前缀且命中允许清单(cwdpreferLocaldetachedverbosequiettimeouttimeoutSignalkillSignalprefixpostfixshell)的变量,都会用来覆盖对应默认值。例如设置 ZX_VERBOSE=1 环境变量,即可在不改代码的情况下打开 verbose 模式——这一机制在 Configuration 文档$.defaults 一节也可对照。

三、cd()syncProcessCwd():目录上下文

cd() 改变当前工作目录:

cd('/tmp')
await $`pwd` // => /tmp

与 shell 的 echo 类似,cd 除了接受 string,还接受 ProcessOutput 并自动 trim 掉尾随换行,从而支持常见惯用法:

cd(await $`mktemp -d`)

源码中 cd() 的实现是 process.chdir(dir) 之后把内部符号 CWD 同步为新的 process.cwd()src/core.tsexport function cd),所以它对全局上下文生效。

⚠️ cd 内部调用 process.chdir(),会影响整个 Node 进程。若希望 process.cwd() 始终跟随 $ 内部的目录变化,需要启用 syncProcessCwd() 钩子:

import {syncProcessCwd} from 'zx'

syncProcessCwd()
syncProcessCwd(false) // 传 false 关闭钩子

从实现看,syncProcessCwd() 基于 node:async_hookscreateHook,在 init/before/promiseResolve/after/destroy 各阶段检查 $[CWD] != process.cwd()process.chdir() 跟上。文档也明确说明:该功能默认关闭,因为它有性能开销。

四、fetch():面向管线的网络请求封装

fetchnode-fetch-native 的包装(仓库通过 src/vendor.ts 导出为 nodeFetch),基本用法与原生 fetch 一致:

const r1 = await fetch('https://example.com')
const json = await r1.json()

const r2 = await fetch('https://example.com', {
  signal: AbortSignal.timeout(5000),
})

zx 为它追加了一个“管线友好”的 pipe 方法,用于规避 text()/json() 在超大响应下可能超过字符串大小限制的问题——流式传输正是为此而生:

const p1 = fetch('https://example.com').pipe($`cat`)
const p2 = fetch('https://example.com').pipe`cat`

src/goods.tsfetch 的实现可以看到:pipeResponse.body 通过 getReader() 转成一个 Readable,再 pipe 到目标(模板字符串形式会先构造一个 halt 模式的 $ 子进程),并同步传递了 AbortSignal。也就是说,fetch().pipe 让 HTTP 流与 $ 子进程流走的是同一套管道机制。

五、交互式与轻量工具:question()sleep()echo()stdin()

question()

readline API 的包装,支持 choices 补全:

const bear = await question('What kind of bear is best? ')
const selected = await question('Select an option:', {
  choices: ['A', 'B', 'C'],
})

源码中(src/goods.tsquestion),当传入 choices 时会为 readline 接口挂一个 completer,按键前缀过滤候选项,这就是 choices 带来“补全”体验的原因。

sleep()

setTimeout 的 Promise 化,时长支持 Duration 类型(数字毫秒或 '1s'/'100ms'/'2m' 字符串):

await sleep(1000)

echo()

console.log() 的替代,特别之处是它理解 ProcessOutput:输出时会自动调用 toString() 并去掉尾部换行(src/goods.tsstringify()ProcessOutputtrimEnd()),因此拼接日志时不会出现多余空行:

const branch = await $`git branch --show-current`

echo`Current branch is ${branch}.`
// 或
echo('Current branch is', branch)

stdin()

把标准输入整体读成字符串返回,常用来接收上游管道传入的 JSON:

const content = JSON.parse(await stdin())

实现上就是对 process.stdin 按 UTF-8 逐 chunk 累加(src/goods.tsstdin),也允许传入自定义 Readable

六、within():异步作用域上下文

within(callback) 创建一个“配置作用域”:回调内对 $ 属性(如 $.cwd$.prefix 或任意自定义字段)的修改只影响该异步上下文,退出后自动恢复:

await $`pwd` // => /home/path
$.foo = 'bar'

within(async () => {
  $.cwd = '/tmp'
  $.foo = 'baz'

  setTimeout(async () => {
    await $`pwd` // => /tmp
    $.foo // baz
  }, 1000)
})

await $`pwd` // => /home/path
$.foo // still 'bar'

典型场景是在一个上下文中临时切换 Node 版本再执行命令:

await $`node --version` // => v20.2.0

const version = await within(async () => {
  $.prefix += 'export NVM_DIR=$HOME/.nvm; source $NVM_DIR/nvm.sh; nvm use 16;'

  return $`node --version`
})

echo(version) // => v16.20.0

实现非常简洁:within 就是 storage.run({ ...getStore() }, callback)src/core.ts),底层是 node:async_hooksAsyncLocalStorage。这解释了两点:一是作用域“跟随异步执行链”而非按调用栈严格恢复;二是 spinner() 内部也用 within 在旋转期间临时置 $.verbose = false,从而不干扰正常日志。

七、健壮性工具:retry()spinner()

retry()

按次数重试一个回调,返回第一次成功结果;全部失败则抛出最后一次的错误。支持固定间隔与指数退避:

const p = await retry(10, () => $`curl https://medv.io`)

// 指定尝试间隔
const p = await retry(20, '1s', () => $`curl https://medv.io`)

// 指数退避
const p = await retry(30, expBackoff(), () => $`curl https://medv.io`)

src/goods.tsretry 的签名支持第二个参数为 Duration(经 parseDuration 换算)或“毫秒间隔生成器”。每次失败都会打一条 kind: 'retry' 的日志(含 attempt/total/delay),verbose 模式下可见 FAIL Attempt: x/y, next: ... 这样的进度信息。配套的 expBackoff(max, delay) 是一个生成器:从 delay(默认 100ms)起步按 delay * 2^n 递增,封顶 max(默认 60s),即 100ms → 200ms → 400ms …… 不超过 60 秒。

spinner()

给长时间命令加一个简单 CLI 旋转器:

await spinner(() => $`long-running command`)

// 带消息
await spinner('working...', () => $`sleep 99`)

实现细节(src/goods.tsspinner):每 100ms 往 $.log.output(默认 process.stderr)写入 Braille 字符 ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ 与标题;结束时用空格整行清除。注意源码中明确的限制:if ($.quiet || process.env.CI) return callback()——即 CI 环境下(或 $.quiet 时)spinner 会被禁用,直接执行回调,这正是文档所说“And it's disabled for CI by default”的出处。

八、目录与临时文件:tmpdir()tmpfile()

t1 = tmpdir()       // /os/based/tmp/zx-1ra1iofojgg/
t2 = tmpdir('foo')  // /os/based/tmp/zx-1ra1iofojgg/foo/
f1 = tmpfile()         // /os/based/tmp/zx-1ra1iofojgg
f2 = tmpfile('f2.txt')  // /os/based/tmp/zx-1ra1iofojgg/foo.txt
f3 = tmpfile('f3.txt', 'string or buffer')
f4 = tmpfile('f4.sh', 'echo "foo"', 0o744) // 可执行

对应源码为 src/goods.ts 中的 tempdir/tempfile(并以 tmpdir/tmpfile 别名导出):目录创建在 os.tmpdir() 下,默认带 zx- 加随机 id 的前缀,mode 参数透传给 fs.mkdirSync/fs.writeFileSync。典型用途是生成一次性可执行脚本后交给 $ 运行:

const f = tmpfile('run.sh', 'echo "foo"', 0o744)
await $`./${f}`

九、进程管理:pskill()

ps 是对 @webpod/ps 的再导出,提供跨平台的进程列表能力:

const all = await ps.lookup()
const nodejs = await ps.lookup({ command: 'node' })
const children = await ps.tree({ pid: 123 })
const fulltree = await ps.tree({ pid: 123, recursive: true })

kill() 是进程终结函数,$.kill 的默认实现就是它(可参考 Configuration 文档$.kill 一节将其替换为 tree-kill 等更复杂的逻辑):

await kill(123)
await kill(123, 'SIGKILL')

src/core.ts 的实现可以读出它的“半优雅”策略:先校验 pid 为纯数字,否则抛 Invalid pid;在 win32 平台优先尝试 taskkill /pid <pid> /t /f 整树强杀;其他平台则先用 ps.tree({pid, recursive: true}) 逐个 process.kill 子孙进程,再依次尝试 process.kill(-pid, signal)(进程组)与 process.kill(+pid, signal)(单进程),信号默认取 $.killSignal(默认 SIGTERM)。

十、第三方包再导出:一行 import 即可用

zx 把一批常用依赖统一从主入口 src/index.ts 再导出,脚本无需单独安装它们:

glob()

globby 包,异步与同步两种用法:

const packages = await glob(['package.json', 'packages/*/package.json'])
const markdowns = glob.sync('*.md') // 同步 API 快捷方式

which()

node-which 包:

const node = await which('node')

配合 nothrow 选项,未找到时返回 null 而不是抛错:

const pathOrNull = await which('node', { nothrow: true })

minimistargv

minimist 包直接可用:

const argv = minimist(process.argv.slice(2), {})

更推荐的是内置的 argv 常量——它已经是用 minimist 解析过的 process.argvsrc/goods.tsexport const argv = parseArgv()),脚本里直接:

if (argv.someFlag) {
  echo('yes')
}

需要自定义解析规则(布尔标志、别名等)时,用 minimist 的 options 重新解析即可:

const myCustomArgv = minimist(process.argv.slice(2), {
  boolean: ['force', 'help'],
  alias: { h: 'help' },
})

补充一点:源码中 parseArgv 还额外支持 camelCaseparseBoolean 两个选项(ArgvOpts 类型),可通过 updateArgv(args, opts) 重新解析并回填到 argv

chalkfsospath

分别是对应 chalkfs-extra、Node.js 内置 ospath 包的再导出:

console.log(chalk.blue('Hello world!'))

const {version} = await fs.readJson('./package.json')

await $`cd ${os.homedir()} && mkdir example`

await $`mkdir ${path.join(basedir, 'output')}`

YAMLMAML

yamlmaml 包,用于脚本中解析配置文件:

console.log(YAML.parse('foo: bar').foo)
const maml = `{
  project: "MAML"
  tags: [ "minimal", "readable" ]

  # 支持注释
  spec: {
    version: 1
    author: "Anton Medvedev"
  }

  notes: """
This is a raw multiline string.
Keeps formatting as‑is.
"""
}`
console.log(MAML.parse(maml).project) // MAML

dotenv

envapi 包,提供 dotenv 格式环境变量的解析/加载/注入 API:

// parse
const raw = 'FOO=BAR\nBAZ=QUX'
const data = dotenv.parse(raw) // {FOO: 'BAR', BAZ: 'QUX'}
await fs.writeFile('.env', raw)

// load
const env = dotenv.load('.env')
await $({ env })`echo $FOO`.stdout // BAR

// config
dotenv.config('.env')
process.env.FOO // BAR

注意 loadconfig 的区别:前者只返回解析结果(可传给 $({env}),不影响当前进程),后者会把变量写入 process.env

versions

导出 zx 各依赖的版本号(由 src/versions.ts 生成),便于排障时核对环境:

import { versions } from 'zx'

versions.zx     // 8.7.2
versions.chalk  // 5.4.1

十一、引号与 Shell 切换:quote()quotePowerShell()useBash()usePowerShell()usePwsh()

模板字符串中内插变量的安全转义由 $.quote 函数负责,zx 内置两种实现,均可在 src/util.ts 中查到源码:

quote("$FOO") // "$'$FOO'"

bash 版 quote() 的规则是:空串返回 $'';纯安全字符(\w/.-+@:=,% 等)原样输出;否则包成 ANSI-C 风格的 $'...',并对反斜杠、单引号、换行、制表符等逐一转义。

quotePowerShell("$FOO") // "'$FOO'"

PowerShell 版 quotePowerShell() 更简单:空串返回 '',安全字符原样输出,其余单引号包裹并把内部 ' 双写为 ''

三个 shell 切换函数(src/core.tsuseBash/usePwsh/usePowerShell,共享内部 setShell())一次性设置 $.shell$.prefix$.postfix$.quote

useBash()        // $.shell = which bash;$.quote = quote;$.prefix = 'set -euo pipefail;'
usePowerShell()  // powershell.exe + quotePowerShell;$.postfix = '; exit $LastExitCode'
usePwsh()        // pwsh (PowerShell v7+) 同上

这里有个源码层面的细节值得注意:useBash() 会先执行一次 which.sync('bash'),若系统找不到 bash,$.shell 会保持原值。此外 src/core.ts 底部有一段启动逻辑:模块加载时先 useBash(),再把加载前已设置的字符串形式的 $.shell/$.prefix/$.postfix 恢复回去——因此“先赋值 $.shell 再 import 顺序”不会影响用户自定义 shell。

$.prefix 默认值 set -euo pipefail;(bash 下让任何命令失败即中断)与 $.postfix 的 PowerShell 兼容写法 ; exit $LastExitCode,都可在 Configuration 文档$.prefix/$.postfix 两节找到完整说明。

十二、小结:从 API 文档到源码的阅读路径

需要说明的前提:本文以当前仓库(zx 8.9.0,package.jsonengines.node >= 12.17.0,且支持 Bun、Deno 等运行时,见 README.md)为准;within()/syncProcessCwd() 依赖 node:async_hooks,在 Node.js 环境中最可靠;kill() 的 Windows 分支依赖 taskkill,Linux/macOS 分支依赖进程组与 @webpod/ps 的进程树探测。

登录后查看全文
热门项目推荐
相关项目推荐