Google zx 源码浅析

5,535 阅读3分钟

“Bash很好,所以我选择JavaScript”

前端日常开发中我们大部分时间都是使用Javascript,在编写shell脚本时,写bash没有像js那般熟悉,如果能选择一种更方便的编程语言来写它,何乐而不为呢? Google zx 工具实现了这种可能,官方用给base发“好人卡”的方式,成功介绍了它,作为一个更方便、更友好帮助开发者写脚本的工具 ,短短时间就在GitHub上火爆起来了,那我们一起来瞧一瞧它的庐山真面目。

它怎么使用呢?

安装初始化( npm i -g zx ),node版本限制( >= 14.8.0 ) 先看一个简单的例子,创建一个 demo.mjs 文件,内容如下:

// demo.mjs
await Promise.all([
  $`sleep 1; echo 3`,
  $`sleep 2; echo 2`,
  $`sleep 3; echo 1`,
])
let tName = 'google/zx'
await $`echo '/hello/${tName}'`

命令行执行 zx demo.mjs 伴随着3秒倒计时,可以看到 /hello/google/zx

以上就是一个简单的使用例子,接下去我们进入正题

来看看提供了哪些能力?

三种使用方式

zx << 'EOF'
if (typeof firstArg === 'undefined' || firstArg === '-') { ... }

判断输入命令没有firstArg则会通过 scriptFromStdin 函数处理逻辑:

  • 通过 process.stdin.isTTY 判断是否在终端条件下

  • 如果不是的话设置utf8字符编码

  • 根据输入的逐行内容循环并拼接

  • 在临时文件夹创建一个随机命名的mjs文件

Math.random().toString(36).substr(2) + '.mjs' 举个例子:

zx <<'EOF'
await $`pwd`
EOF
从远端拉取脚本执行
if (firstArg.startsWith('http://') || firstArg.startsWith('https://')) { ... }

拉取数据主要是通过 scriptFromHttp 实现,主要有以下几步:

  • 使用node-fetch包拉取对应url的数据

  • 拉取完数据后会通过url的pathname来命名

  • 在临时文件夹中创建文件

本地脚本文件执行

如果没有进入到上面两个判断,则会执行下方逻辑,以 / 开头则直接使用该链接,file开头则调用url.fileURLToPath获取绝对路径字符串,其他逻辑的通过path.resolve获取路径

if (firstArg.startsWith('/')) {
  filepath = firstArg
} else if (firstArg.startsWith('file:///')) {
  filepath = url.fileURLToPath(firstArg)
} else {
  filepath = resolve(firstArg)
}

支持多种文件格式 importPath

空后缀

zx 遇到这样的情况会直接readFile读取文件信息,然后获取当前文件所在目录以及文件名,重新写一个mjs文件,再次进入 importPath 方法并import执行

Markdown文件

zx 对于markdow编写的脚本同样可以执行,只有code包裹的部分会被解析,通过 transformMarkdown 方法用 \n 切割为数组后逐行扫描并用正则做匹配,匹配命中的信息会push到另一个数组,最后将数据通过换行符\n进行拼接转化成字符串作为script数据,生成文件的方式同上 Markdown格式举例:

# Markdown Scripts

It's possible to write scripts using markdown. Only code blocks will be executed
by zx. 

> You can run this markdown file:
>
> ```
> zx docs/markdown.md
> ```

```js
await $`whoami`
await $`echo ${__dirname}`

The __filename will be pointed to markdown.md:

console.log(chalk.yellowBright(__filename))

We can use imports here as well:

await import('chalk')

A bash code (with bash or sh language tags) also will be executed:

VAR=$(date)
echo "$VAR" | wc -c

Other code blocks are ignored:

body .hero {
    margin: 42px;
}
##### Ts文件

核心部分是执行 `compile` 方法进行tsc编译出一个js,然后对js进行重命名为mjs
```javascript
let tsc = $`npm_config_yes=true npx -p typescript tsc --target esnext --lib esnext --module esnext --moduleResolution node ${input}`
await tsc

这里有个loading,用的是setInterval实现

let i = 0
let interval = setInterval(() => {
process.stdout.write('  '
  + ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][i++ % 10]
  + '\r'
)
}, 100)

...

clearInterval(interval)

$函数

首先是使用了标签函数的能力处理args

如果传入了数组,会对这个数组进行拼接,例如:

let flags = [
  '--oneline',
  '--decorate',
  '--color',
]
await $`git log ${flags}`

// zx 处理逻辑,先格式化,再用一个空格拼接
if (Array.isArray(args[i])) {
  s = args[i].map(x => $.quote(substitute(x))).join(' ')
}

cmd与将转译后的args拼接,然后创建一个ProcessPromise类的实例 调用then的时候会触发_fun方法

 then(onfulfilled, onrejected) {
    if (this._run) this._run()
    return super.then(onfulfilled, onrejected)
 }

_run方法里核心是使用child_process.spawn

简单介绍一下child_process的其它能够执行bash命令的api spawn:启动一个子进程来执行命令 exec:启动一个子进程来执行命令,与spawn不同的是,它有一个回调函数能知道子进程的情况 execFile:启动一子进程来执行可执行文件 fork:与spawn类似,不同点是它需要指定子进程需要需执行的模块

child_process监听exit和close事件,然后输出一个output,code为0即成功,失败则会通过code码在 exitCodeInfo 方法中返回对应的错误信息


function exitCodeInfo(exitCode) {
  return {
    2: 'Misuse of shell builtins',
    126: 'Invoked command cannot execute',
    127: 'Command not found',
    128: 'Invalid exit argument',
    129: 'Hangup',
    130: 'Interrupt',
    131: 'Quit and dump core',
    132: 'Illegal instruction',
    133: 'Trace/breakpoint trap',
    134: 'Process aborted',
    135: 'Bus error: "access to undefined portion of memory object"',
    136: 'Floating point exception: "erroneous arithmetic operation"',
    137: 'Kill (terminate immediately)',
    138: 'User-defined 1',
    139: 'Segmentation violation',
    140: 'User-defined 2',
    141: 'Write to pipe with no one reading',
    142: 'Signal raised by alarm',
    143: 'Termination (request to terminate)',
    145: 'Child process terminated, stopped (or continued*)',
    146: 'Continue if stopped',
    147: 'Stop executing temporarily',
    148: 'Terminal stop signal',
    149: 'Background process attempting to read from tty ("in")',
    150: 'Background process attempting to write to tty ("out")',
    151: 'Urgent data available on socket',
    152: 'CPU time limit exceeded',
    153: 'File size limit exceeded',
    154: 'Signal raised by timer counting virtual time: "virtual timer expired"',
    155: 'Profiling timer expired',
    157: 'Pollable event',
    159: 'Bad syscall',
  }[exitCode]
}

其他Funtions

cd()

更改当前工作目录,同时会设置$.cwd

if (!fs.existsSync(path)) {
    let __from = (new Error().stack.split(/^\s*at\s/m)[2]).trim()
    console.error(`cd: ${path}: No such directory`)
    console.error(`    at ${__from}`)
    process.exit(1)
  }
fetch()

实际上是使用node-fetch拉取数据

nodeFetch(url: RequestInfo, init?: RequestInit): Promise<Response>
question()

readline的语法糖 通过设置options.choices传入一个数组可以支持select模式

sleep()

使用 util.promisify 对setTimeout包了一层,返回一个promise,例如await sleep(1000) 这样的回调风格更优美一些

nothrow()

设置_nothrow的值为true,child process监听close事件,返回的code !== 0 也能 resolve

使用举例:

await nothrow($`grep something from-file`)

可以直接使用的Packages

chalk package
console.log(chalk.blue('Hello world!'))
fs package ( fs-extra )
let content = await fs.readFile('./package.json')
globby package

这个包提供了一些方法,用于遍历文件系统,并根据UnixBashshell使用的规则返回与已定义的指定模式集匹配的路径名,同时以任意顺序返回结果,使用起来快速、简单、有效。

let packages = await globby(['package.json', 'packages/*/package.json'])
let pictures = globby.globbySync('content/*.(jpg|png)')
// Also, globby available via the glob shortcut:
await $`svgo ${await glob('*.svg')}`
os package

提供了与操作系统相关的方法和属性

await $`cd ${os.homedir()} && mkdir example`
minimist package
// 使用举例
$ node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
{ _: [ 'foo', 'bar', 'baz' ],
  x: 3,
  y: 4,
  n: 5,
  a: true,
  b: true,
  c: true,
  beep: 'boop' }

参数配置

$.quote

这里主要是实现了一个quote方法,主要用于特殊字符转译

function quote(arg) {
  if (/^[a-z0-9/_.-]+$/i.test(arg) || arg === '') {
    return arg
  }
  return `$'`
    + arg
      .replace(/\\/g, '\\\\')
      .replace(/'/g, '\\\'')
      .replace(/\f/g, '\\f')
      .replace(/\n/g, '\\n')
      .replace(/\r/g, '\\r')
      .replace(/\t/g, '\\t')
      .replace(/\v/g, '\\v')
      .replace(/\0/g, '\\0')
    + `'`
}
$.verbose(--quiet)

设置静默模式,默认为true会打印出log,通过参数 --quiet可以设置$.verbose = false 目前会打印的位置有:

  • cd命令,提示当前路径

  • 远程拉取脚本,提示当前fetch的url

$.cwd

默认为undefined,$.cwd可以通过cd方法来设置,拿到当前的工作目录

$.shell(--shell)

指定使用的shell 例如:

$.shell = '/usr/bin/bash'
$.prefix

设置所有运行命令的前缀的命令 默认设置为-euo pipefail 或者使用 --prefix 来设置,例如:--prefix='set -e;'

Polyfills

Nodejs 在 ESM 中不提供 __dirname 和 __filename,以及没有定义require()函数,所以zx在global挂了这几个变量,方便使用者自取

  let __filename = resolve(origin)
  let __dirname = dirname(__filename)
  let require = createRequire(origin)
  Object.assign(global, {__filename, __dirname, require})

这次的介绍就到这里了