前言
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注入了很多全局变量,例如
sleepfetchfs等,可以使代码中省去了大量的import。让你可以抛开依赖声明和node_modules,像执行sh文件一样直接执行脚本。
1.1 如何使用
安装:npm install -g zx
使用:
- 可以直接命令行使用
zx - 可以直接执行脚本(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
}
解读这段代码,其实就是做了一些前置处理,真正起到实际作用的代码其实就如下两行
Object.assign(global, {__filename, __dirname, require})await import(url.pathToFileURL(filepath))
第一行:往全局global里注入了 __filename, __dirname, require 。这意味着可以在你的脚本中直接使用这些变量,require方法可以帮助你读取json文件。
第二行:众所周知,import一个文件,会执行该文件的所有代码,我们编写的脚本也在此处被逐行被执行。
但是 global 中,预置了远远不止这三个变量(方法)。我们观察在上述源码的第三行引入的./index.mjs。这才是zx的核心文件,在这个文件中,实现了核心方法 $ 并且export 出了很多常用的库。
2.2 核心文件 index.mjs
这个核心文件主要做了两件事
- 核心函数
$的实现 - 常用库的暴露
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的感觉。