google/zx 源码分析,如何用js来写bash

1,679 阅读4分钟

前言

image.png

Google/zx为谷歌推出的一个开源项目,到今天上线仅仅8个月,就有27k的star,可见是非常的火,近期拜读了一下源码,发现代码量不大,总共两个核心文件加起来不到500行的代码,非常适合新手阅读的源码

1. 什么是zx

摘自官方: Bash is great, but when it comes to writing scripts, people usually choose a more convenient programming language. JavaScript is a perfect choice, but standard Node.js library requires additional hassle before using. The zx package provides useful wrappers around child_process, escapes arguments and gives sensible defaults.

github地址:github.com/google/zx

简单来说就是用前端熟悉的javascript的语法来写shell脚本。

举个官方的🌰

await $`cat package.json | grep name`

let branch = await $`git branch --show-current`
await $`dep deploy --branch=${branch}`

await Promise.all([
  $`sleep 1; echo 1`,
  $`sleep 2; echo 2`,
  $`sleep 3; echo 3`,
])

let name = 'foo bar'
await $`mkdir /tmp/${name}`

不难发现,其实就是在我们可以使用熟悉的js代码中插入了shell。

我们可以用熟悉的js语法如 await Promise.all融合shell脚本语言,形成了一种新的“脚本语言”。

它的优点不言而喻:

  • 对比普通的shell语法,我们可以用我们最熟悉的方式来实现“循环”、“分支控制”等操作
  • 对比标准的js脚本,zx注入了很多全局变量,例如 sleep fetch fs等,可以使代码中省去了大量的import。让你可以抛开依赖声明和node_modules,像执行sh文件一样直接执行脚本。

1.1 如何使用

安装:npm install -g zx

使用:

  1. 可以直接命令行使用 zx
  2. 可以直接执行脚本(mjs, js, ts, md) zx ./target.mjs 修改文件属性,也可以直接执行脚本

1.2 解析

其实它的实现逻辑并不难理解,zx会在执行我们的脚本前,在全局注册一些常用的库(例如node-fetch``fs-extra)以及核心函数$

$函数其实就是spawn的语法糖,做了一层封装,使得写法得到最大程度的简化。

2. 源码解读

2.1 入口文件 zx.mjs

我们先看一下当我们在命令行敲下 zx target.mjs之后发生什么。

当我们在命令行执行了zx指令,实际上是执行了 zx.mjs文件。

package.json中的"bin"证明了这一点。

# pacakge.json

{
 "bin": {
    "zx": "zx.mjs"
  }
}

接下来我们看看这个神秘的 zx.mjs 做了什么事。

# 源码: zx.mjs

import {$, fetch, ProcessOutput, argv} from './index.mjs'


try {
  // 获取命令行中 zx 后的第一个参数
  let firstArg = process.argv.slice(2).find(a => !a.startsWith('--'))
  if (firstArg.startsWith('http://') || firstArg.startsWith('https://')) {
    // 如果第一个参数为网络上资源,则下载下来执行,下载后和执行本地一致,这里略
    await scriptFromHttp(firstArg)
  } else {
    // 那么第一个参数为本地文件时
    let filepath
    if (firstArg.startsWith('/')) {
      filepath = firstArg
    } else if (firstArg.startsWith('file:///')) {
      filepath = url.fileURLToPath(firstArg)
    } else {
      filepath = resolve(firstArg)
    }
    
    // 跳转到importPath函数,这个函数是真正执行我们脚本的函数
    await importPath(filepath)
  }

} catch (p) {
  // 异常处理  
  throw p
}

/** 脚本执行的函数 */
async function importPath(filepath, origin = filepath) {
  let ext = extname(filepath) // 获得文件后缀
  if (ext === '') {
    // 如果未指定文件后缀,则加上.mjs生成临时文件写到本地后,再次执行本函数
    // 下方可见 writeAndImport 实现
    return await writeAndImport(
      await fs.readFile(filepath),
      join(dirname(filepath), basename(filepath) + '.mjs'),
      origin,
    )
  }
  if (ext === '.md') {
    // 如果目标为markdown文件,将md转换为mjs文件写入本地后,再次执行本函数
    return await writeAndImport(
      transformMarkdown((await fs.readFile(filepath)).toString()),
      join(dirname(filepath), basename(filepath) + '.mjs'),
      origin,
    )
  }
  if (ext === '.ts') {
    // 如果为ts文件 会先用tsc命令编译该文件,后写到本地再执行本函数
    let {dir, name} = parse(filepath)
    let outFile = join(dir, name + '.cjs')
    await compile(filepath)
    await fs.rename(join(dir, name + '.js'), outFile)
    let wait = importPath(outFile, filepath)
    await fs.rm(outFile)
    return wait
  }
  let __filename = resolve(origin)
  let __dirname = dirname(__filename)
  let require = createRequire(origin)
  
 // !真正执行脚本的代码在下面两行
  Object.assign(global, {__filename, __dirname, require})
  await import(url.pathToFileURL(filepath))
}

// 该函数其实就是执行了 1.写在本地、2.执行回importPath函数 、3. 执行完删除
async function writeAndImport(script, filepath, origin = filepath) {
  await fs.writeFile(filepath, script) // 写
  let wait = importPath(filepath, origin) // 执行
  await fs.rm(filepath) // 删
  await wait
}

解读这段代码,其实就是做了一些前置处理,真正起到实际作用的代码其实就如下两行

  1. Object.assign(global, {__filename, __dirname, require})
  2. await import(url.pathToFileURL(filepath))

第一行:往全局global里注入了 __filename, __dirname, require 。这意味着可以在你的脚本中直接使用这些变量,require方法可以帮助你读取json文件。

第二行:众所周知,import一个文件,会执行该文件的所有代码,我们编写的脚本也在此处被逐行被执行。

但是 global 中,预置了远远不止这三个变量(方法)。我们观察在上述源码的第三行引入的./index.mjs。这才是zx的核心文件,在这个文件中,实现了核心方法 $ 并且export 出了很多常用的库。

2.2 核心文件 index.mjs

这个核心文件主要做了两件事

  1. 核心函数 $ 的实现
  2. 常用库的暴露

2.2.1 registerGlobals 方法

顾名思义,这个函数在global里注册了常用的库以及核心方法

也就是说,如果我们使用 zx 命令行来执行脚本,下面的变量或方法都可以直接使用,不需要额外import。

如果我们想用 node 或者ts-node 命令来执行脚本,也是可以的,在脚本第一行加上import 'zx/globals'

如果想在vscode中获得更人性化的代码提示,也可以加上这行引入。

源码来自: index.mjs

export function registerGlobals() {
  Object.assign(global, {
    $, // 核心方法 见 2.2.3 详解
    
    argv, // const argv = minimist(process.argv.slice(2)) => minimist库,对命令行参数进行处理
    
    cd, // 等效于 process.chdir(path), 更改当前bash路径
    
    chalk, // chalk库,打印出好看的log,只是搬运了一下,下同
    
    fetch, // node-fetch 库
    
    fs, // fs-extra库
    
    glob,
    globby, // globby库 
    
    os, // os 库
    path, // path 库
    
    question, // 借助readline库简单实现终端交互 见2.2.2
    
    sleep, // const sleep = promisify(setTimeout) 由util库的promisify方法实现
  })
}

2.2.2 终端交互 question方法

代码比较直白,使用了readline库

源码来自: index.mjs

import {createInterface} from 'readline'

export async function question(query, options) {
  let completer = undefined
  if (Array.isArray(options?.choices)) {
    // 根据传入的options.choices 在用户输入时自动补全
    completer = function completer(line) {
      const completions = options.choices
      const hits = completions.filter((c) => c.startsWith(line))
      return [hits.length ? hits : completions, line]
    }
  }
  const rl = createInterface({
    input: process.stdin,
    output: process.stdout,
    completer,
  })
  const question = (q) => new Promise((resolve) => rl.question(q ?? '', resolve))
  let answer = await question(query)
  rl.close()
  return answer
}

2.2.3 核心函数 $

补充知识点:

这里插播一条知识点:func`hello, ${'wor' + 'ld'}.`

函数如果使用模版字符串的方式调用,那么该函数的入参的一个参数是一个数组,为被“坑”split过后的字符串数组,而从第二个参数开始就是对应的“坑”。

就如上述的 func 这个函数有两个入参,分别为:["hello, ", "."] world

源码来自: index.mjs

import {spawn} from 'child_process'


export function $(pieces, ...args) {
  
  // 抛出Error时 __from 的 stack将指向本行
  let __from = (new Error().stack.split(/^\s*at\s/m)[2]).trim()

  // 将模版字符串中的“坑”填好, cmd 为完整的bash指令
  let cmd = pieces[0], i = 0
  while (i < args.length) {
    let s
    if (Array.isArray(args[i])) {
      // ${} 内部支持传数组的”奥秘“
      // quote方法作用是将必要字符串进行转译 例如 "\" => "\\"
      s = args[i].map(x => $.quote(substitute(x))).join(' ')
    } else {
      s = $.quote(substitute(args[i]))
    }
    cmd += s + pieces[++i]
  }

  let resolve, reject
  // ProcessPromise为继承了Promise的子类,额外保存了进程的一些状态值, 实现见下文
  let promise = new ProcessPromise((...args) => [resolve, reject] = args)

  // promise 真正执行体
  promise._run = () => {
    // 如果promise实例已经挂载了一个子进程了,那就不再执行了
    // 这里说明每条$`shell` 语句保证只被 spawn 执行一次
		if (promise.child) return 
    
    // 这里看出,其实 $ 就是 spawn的语法糖,shell代码真正还是通过spawn来执行的
    let child = spawn(cmd, {
      cwd: process.cwd(),
      shell: true,
      stdio: [promise._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
      windowsHide: true,
    })

    child.on('exit', code => {
      child.on('close', () => {
        let output = new ProcessOutput({
          code, stdout, stderr, combined,
          message: `${stderr || '\n'}    at ${__from}\n    exit code: ${code}` + (exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : '')
        });
        // 根据子进程的状态码将结果返回出去
        (code === 0 ? resolve : reject)(output)
        // 标记状态
        promise._resolved = true
      })
    })

    // 记录进程运行日志
    let stdout = '', stderr = '', combined = ''
    let onStdout = data => {
      process.stdout.write(data)
      stdout += data
      combined += data
    }
    let onStderr = data => {
      process.stderr.write(data)
      stderr += data
      combined += data
    }
    child.stderr.on('data', onStderr)
    promise.child = child
  }
  setTimeout(promise._run, 0) // Make sure all subprocesses started.
  return promise
}


// ProcessPromise 类的实现
export class ProcessPromise extends Promise {
  child = undefined // 执行spawn的进程
  _resolved = false // 记录该进程是否已经执行完毕,在上面我们看到在child.on('exit')中标记为true
  _inheritStdin = true // 控制日志输出chanel为'inihret'还是'pipe'

  get stdin() {
    this._inheritStdin = false
    this._run()
    return this.child.stdin
  }

  get stdout() {
    this._inheritStdin = false
    this._run()
    return this.child.stdout
  }

  get stderr() {
    this._inheritStdin = false
    this._run()
    return this.child.stderr
  }

  get exitCode() {
    return this
      .then(p => p.exitCode)
      .catch(p => p.exitCode)
  }

  // 执行主体
  then(onfulfilled, onrejected) {
    // 真正执行spawn的地方
    if (this._run) this._run()
    return super.then(onfulfilled, onrejected)
  }

  async kill(signal = 'SIGTERM') {
    // 根据进程树逐一终止子进程
    this.catch(_ => _)
    let children = await psTree(this.child.pid)
    for (const p of children) {
      try {
        process.kill(p.PID, signal)
      } catch (e) {
      }
    }
    try {
      process.kill(this.child.pid, signal)
    } catch (e) {
    }
  }
}

ps:

pipe 方法为比较独立的方法,为了便于理解,省略了pipe的实现,感兴趣可以阅览源码。

3. 总结:

阅读完zx的源码发现,原理非常好理解,在执行脚本本体前多执行了一段脚本,将环境、方法注入。

有种preload.js的感觉。