cross-spawn@7.0.6源码阅读

6 阅读4分钟

发布时间: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 有哪些存在问题?

  1. 不识别无扩展名的命令(如 node 实际是 node.exe
  2. 不识别 Shebang(#!),无法执行 .js/.sh 脚本
  3. 命令参数含特殊字符时解析错误
  4. 执行不存在的命令时,仅触发 exit不触发 error
  5. 无法直接执行 npm.cmd垫片文件
  6. 路径分隔符不一致(/ 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 中不区分大小写(如 PathPATH 指向同一个环境变量)。