发布时间:2024 年 11 月 18 日
Node.js 内置的 spawn 是为 Unix 系统设计的,在 Windows 下会有大量「反直觉」的问题,cross-spawn 就是为修复这些问题而生。
spawn
node-cross-spawn-7.0.6/index.js
spawn 函数的主要作用是跨平台兼容地创建子进程执行命令,解决不同操作系统(如 Windows、macOS、Linux)下命令执行的兼容性问题。
function spawn(command, args, options) {
// Parse the arguments
// 调用 parse 函数处理输入的 command、args 和 options 参数
const parsed = parse(command, args, options);
// Spawn the child process
// 创建子进程
const spawned = cp.spawn(parsed.command, parsed.args, parsed.options);
// 错误处理
enoent.hookChildProcess(spawned, parsed);
return spawned;
}
const cp = require('child_process');
const parse = require('./lib/parse');
const enoent = require('./lib/enoent');
spawnSync
node-cross-spawn-7.0.6/index.js
spawnSync 函数的核心作用是在跨平台环境下同步执行命令,同时解决原生 API 的兼容性问题和错误信息模糊的痛点。
function spawnSync(command, args, options) {
// Parse the arguments
const parsed = parse(command, args, options);
// Spawn the child process
// 同步创建子进程
const result = cp.spawnSync(parsed.command, parsed.args, parsed.options);
// 错误增强
result.error = result.error || enoent.verifyENOENTSync(result.status, parsed);
return result;
}
spawn._parse
node-cross-spawn-7.0.6/lib/parse.js
function parse(command, args, options) {
// Normalize arguments, similar to nodejs
// 1、参数格式归一化:处理用户输入的灵活格式
if (args && !Array.isArray(args)) {
// 若 args 不是数组(实际是 options),则将其赋值给 options
options = args;
args = null; // 重置 args 为 null
}
// 2、参数副本创建:避免修改用户原始数据
// 通过 args.slice(0) 创建参数数组的副本,防止意外修改原始数组
args = args ? args.slice(0) : [];
// 通过 Object.assign({}, options) 创建选项对象的副本,保护原始配置对象不被修改
options = Object.assign({}, options);
// Build our parsed object
// 3、构建基础解析对象 parsed
const parsed = {
command,// 用户传入的原始命令(如 'npm'、'ls')
args, // 归一化后的参数数组(空数组或用户传入的副本)
options, // 归一化后的配置对象(空对象或用户传入的副本)
file: undefined, // 预留字段:后续解析后存储“实际要执行的文件路径”(如 Windows 下的 'npm.cmd')
// 存储用户原始输入,便于后续调试或回溯
original: {
command,
args,
},
};
// Delegate further parsing to shell or non-shell
// 4、 区分 shell / 非 shell 场景,分发后续解析逻辑
return options.shell ? parsed : parseNonShell(parsed);
}
parseNonShell
function parseNonShell(parsed) {
// 非Windows系统:无需适配,直接返回原解析结果
if (!isWin) {
return parsed;
}
// 2. 检测 Shebang 并获取命令文件路径
// 检测命令文件的shebang(如#!/usr/bin/env node),返回真正的命令文件路径
const commandFile = detectShebang(parsed);
// 判断是否需要通过shell(cmd.exe)执行:如果命令文件不是可执行文件,则需要
// isExecutableRegExp:匹配Windows可执行文件扩展名(.exe/.com/.bat/.cmd/.ps1等)
const needsShell = !isExecutableRegExp.test(commandFile);
// 3. 判定是否需要启用 cmd.exe 执行
// 触发条件:1) 强制开启shell 2) 命令文件非可执行文件
if (parsed.options.forceShell || needsShell) {
// 检测是否是cmd shim文件(如npm的.cmd脚本),这类文件需要双重转义元字符(&/|/空格等)
const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(commandFile);
// 规范化Windows路径(如把/换成\,处理路径分隔符不一致问题)
parsed.command = path.normalize(parsed.command);
// 转义命令本身(处理命令名中的特殊字符,如空格、&)
parsed.command = escape.command(parsed.command);
// 转义命令参数:根据是否是shim文件,决定是否双重转义
parsed.args =
parsed.args.map((arg) => escape.argument(arg, needsDoubleEscapeMetaChars));
// 拼接命令+参数为完整的shell命令字符串
const shellCommand = [parsed.command].concat(parsed.args).join(' ');
// 重构parsed:改为执行cmd.exe,参数适配cmd的执行规则
parsed.args = ['/d', '/s', '/c', `"${shellCommand}"`];
parsed.command = process.env.comspec || 'cmd.exe';
// 关键:禁用Node.js对参数的二次转义,让cmd直接解析参数
parsed.options.windowsVerbatimArguments = true;
}
return parsed;
}
const isWin = process.platform === 'win32';
const isExecutableRegExp = /\.(?:com|exe)$/i;
const isCmdShimRegExp = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;
detectShebang
node-cross-spawn-7.0.6/lib/parse.js
function detectShebang(parsed) {
// 第一步:解析原始命令的绝对路径,赋值给 parsed.file
parsed.file = resolveCommand(parsed);
// 第二步:读取文件的 Shebang 行(仅当 parsed.file 存在时)
const shebang = parsed.file && readShebang(parsed.file);
// Shebang 存在时的重构逻辑
if (shebang) {
// 1. 把原脚本文件路径(parsed.file)添加到参数数组的最前面
// 原因:解释器执行脚本的语法是「解释器 脚本路径 参数」(如 node app.js --port 3000)
parsed.args.unshift(parsed.file);
// 2. 把命令替换为 Shebang 指定的解释器(如 "/usr/bin/env node")
parsed.command = shebang;
// 3. 重新解析解释器的绝对路径
// 比如 "/usr/bin/env node" → 解析为 node 的绝对路径 /usr/bin/node
return resolveCommand(parsed);
}
// 3. Shebang 不存在时的返回逻辑
// 无 Shebang(如 .exe 可执行文件、无 #! 开头的普通文件),直接返回原命令文件路径
return parsed.file;
}
resolveCommand
function resolveCommand(parsed) {
// 步骤1:withoutPathExt 默认为 false(保留扩展名)
// 步骤2:withoutPathExt=true(忽略扩展名)
// 步骤3:返回首次成功的解析结果,两次都失败则返回 undefined
return (
resolveCommandAttempt(parsed) ||
resolveCommandAttempt(parsed, true)
);
}
resolveCommandAttempt
node-cross-spawn-7.0.6/lib/util/resolveCommand.js
function resolveCommandAttempt(
parsed,
withoutPathExt // 是否忽略系统的路径扩展名(如 Windows 下的 .exe/.cmd
) {
// 优先使用自定义环境变量,否则用进程默认环境变量(process.env)
const env = parsed.options.env || process.env;
const cwd = process.cwd(); // 保存当前进程的工作目录
// 判断是否传入了自定义工作目录(cwd)
const hasCustomCwd = parsed.options.cwd != null;
// 判断是否需要临时切换工作目录:
// 条件1:有自定义cwd;条件2:process.chdir 存在且未禁用(Worker 线程无 chdir 方法)
const shouldSwitchCwd =
hasCustomCwd && process.chdir !== undefined && !process.chdir.disabled;
if (shouldSwitchCwd) {
try {
process.chdir(parsed.options.cwd); // 切换到自定义工作目录
} catch (err) {
/* Empty */
}
}
let resolved;
try {
// which.sync:同步查找系统 PATH 中指定命令的可执行文件路径(依赖 which 库)
resolved = which.sync(parsed.command, {
path: env[getPathKey({ env })],
pathExt: withoutPathExt ? path.delimiter : undefined,
});
} catch (e) {
/* Empty */
} finally {
// 无论查找成功/失败,都恢复原工作目录(finally 保证必执行)
if (shouldSwitchCwd) {
process.chdir(cwd);
}
}
if (resolved) {
// 拼接自定义 cwd(如果有),生成最终的绝对路径
resolved = path.resolve(hasCustomCwd ? parsed.options.cwd : '', resolved);
}
return resolved;
}
const path = require('path');
const which = require('which');
const getPathKey = require('path-key');
readShebang
node-cross-spawn-7.0.6/lib/util/readShebang.js
function readShebang(command) {
// command:目标文件的绝对路径(如 "/path/to/app.js"、"/usr/local/bin/script.sh")
// 核心设计:仅读取文件前 150 字节(Shebang 仅在第一行,足够覆盖)
const size = 150;
// 分配 150 字节的 Buffer 缓冲区,用于存储读取的文件内容
const buffer = Buffer.alloc(size);
let fd;
try {
// 同步以「只读模式('r')」打开文件,返回文件描述符 fd
fd = fs.openSync(command, 'r');
// 同步读取文件内容
// 从 fd 读取,写入 buffer
// 从 buffer 0 位置开始,读取 size 字节
// 从文件 0 偏移量开始
fs.readSync(fd, buffer, 0, size, 0);
// 同步关闭文件,释放文件描述符(避免句柄泄漏)
fs.closeSync(fd);
} catch (e) { /* Empty */ }
// 将 Buffer 转为字符串(默认 UTF-8 编码),传给 shebangCommand 解析 Shebang
// 匹配以 ^#! 开头的行,提取行内的解释器路径,无则返回 null
return shebangCommand(buffer.toString());
}
const fs = require('fs');
const shebangCommand = require('shebang-command');
node 原生 spawn 有哪些存在问题?
- 不识别无扩展名的命令(如
node实际是node.exe) - 不识别 Shebang(
#!),无法执行.js/.sh脚本 - 命令参数含特殊字符时解析错误
- 执行不存在的命令时,仅触发
exit不触发error - 无法直接执行
npm等.cmd垫片文件 - 路径分隔符不一致(
/vs\)
shebangCommand@2.0.0
发布时间 2019 年 9 月 6 日
'use strict';
const shebangRegex = require('shebang-regex');
module.exports = (string = '') => {
// 用正则匹配Shebang行
const match = string.match(shebangRegex);
// 无匹配结果(无Shebang)
if (!match) {
return null;
}
// 步骤1:移除Shebang标识(#! + 可选空格),按空格分割为「路径」和「参数」
const [path, argument] = match[0].replace(/#! ?/, '').split(' ');
// 步骤2:提取路径的最后一段(二进制文件名)
const binary = path.split('/').pop();
if (binary === 'env') {
// 场景1:Shebang是/usr/bin/env xxx(最常见,如#!/usr/bin/env node)
// env的作用是从PATH中找xxx,核心命令是xxx(如node)
return argument;
}
// 场景2:直接指定解释器路径(如#!/bin/bash -x、#!/usr/bin/python3)
// 有参数则返回「二进制名 + 参数」,无参数则只返回二进制名
return argument ? `${binary} ${argument}` : binary;
};
shebang-regex@3.0.0
发布日期 2019 年 4 月 27 日
'use strict';
// .:匹配任意单个字符(默认不包含换行符 \n)
// *:贪婪量词,匹配前面的 . 0 次或多次(即 #! 后可以是任意内容,甚至空)
module.exports = /^#!(.*)/;
path-key@3.1.0
import process from 'node:process';
export default function pathKey(options = {}) {
const {
env = process.env,
platform = process.platform,
} = options;
// 若目标平台不是 Windows,直接返回全大写的 PATH:
if (platform !== 'win32') {
return 'PATH';
}
// 获取环境变量所有键并反转顺序
// 查找首个不区分大小写匹配 PATH 的键名
return Object.keys(env).reverse().find(key => key.toUpperCase() === 'PATH') || 'Path';
}
在不同操作系统中,环境变量里控制 “可执行文件搜索路径” 的键名存在差异:
- 类 Unix 系统(macOS、Linux) :固定使用大写的
PATH作为键名(如PATH=/usr/bin:/bin)。 - Windows 系统:键名不固定,可能是
Path(首字母大写,Windows 默认)、PATH(全大写)或path(全小写,用户自定义),且环境变量键名在 Windows 中不区分大小写(如Path和PATH指向同一个环境变量)。