写脚本的时候最烦的其实不是业务逻辑而是跨进程调用外部命令。以前用 Node 自带的 child_process回调套回调不说还得自己拼参数、处理超时、盯着 stderr 发呆。后来切到 npm 的 execa 包整个体验完全不同Promise 化、默认收集 stdout/stderr、参数数组传递不经过 shell、超时和错误信息都给你分类好。这篇文章我不打算做成文档翻译而是用实际项目里经常遇到的场景把 execa 的安装、基础调用、参数解析、高级用法和踩坑记录完整过一遍。适合写 CLI 工具、脚手架、构建脚本和批量处理程序的开发者参考Node 新手也能跟着跑通。execa 本质上是对 child_process 的再次封装它让“执行命令”这件事变得跟调普通函数一样自然。你只需要 await 一下就能拿到退出码、标准输出和标准错误。不用再去监听 close、error、exit 这些事件。而且它在保持 API 简洁的同时把进程控制、流式输出、超时取消这些细节都做得很扎实。下面从它解决了什么问题讲起。1. 先搞清楚execa 到底解决了什么1.1 原生 child_process 的痛点在没有 execa 之前最常见的写法是这样的const { exec } require(child_process); exec(git status --porcelain, (error, stdout, stderr) { if (error) { console.error(执行失败, error); return; } console.log(stdout); });exec有一个最坑的地方它是把整条命令丢给 shell 去解释的。你的命令里如果带了路径空格、引号、环境变量很容易在字符串拼接阶段就出错而且报错信息往往是些含含糊糊的 shell 语法错误排查起来非常难受。比如想传一个带空格的文件名你得人为地再去转义一层写出来的代码既难读又难维护。spawn好一些参数可以数组传递但它默认不经过 shell又有另一套麻烦事你要等 stream 结束、手动监听 error、拼装 stdout 缓冲、处理退出码代码一多全是样板。更麻烦的是spawn不会自动帮你在 Windows 上处理.cmd和.bat的执行差异跨平台脚本往往在这里翻车。1.2 execa 的解决思路execa 的设计思路很直接把spawn的底层能力保留下来同时给出一套更现代的表层 API。它默认走 Promise执行完返回一个 result 对象里面直接挂着stdout、stderr、exitCode、failed、timedOut这些字段。你不用再手动去缓冲输出也不用再写一堆事件监听一个await全部搞定。它默认情况下不需要 shell命令和参数分开传从根本上避免了转义和注入问题。只有当确实需要 shell 特性比如管道、通配符时才通过shell: true或者execaCommand显式开启。这种“安全默认 显式放开”的设计非常符合现代工具链的习惯。另外 execa 还自动处理了跨平台问题。它内部使用了一套兼容层在 Windows 上能正确识别常见命令的.cmd包装避免很多 ENOENT 错误。这一点在使用 git、npm、ffmpeg 这类命令时特别明显不用每个命令都去判断平台。2. 装好包跑通第一段代码2.1 安装方式与版本坑安装就一条命令npm install execa但这里有个很现实的坑execa 从 v6 开始是纯 ESM 包不再支持 CommonJS 的require(execa)。也就是说如果你的项目还在用module.exportsrequire那一套直接装最新版会得到ERR_REQUIRE_ESM报错。我建议分两种情况处理项目本来就是 ESM 或者用 Vite、Next.js 这类工具链直接装新版import { execa } from execa即可。项目还是 CommonJS要么锁版本用npm install execa5要么在 CJS 文件里用动态import()// CJS 环境下的稳妥写法 const { execa } await import(execa);动态import()在 async 函数里用并不麻烦但要注意它返回的是 Promise不能直接在顶层require式地同步调用。如果项目里大量代码都是同步的锁execa5可能是更省心的方案。2.2 最简单的调用先看一段最基础的代码import { execa } from execa; const result await execa(echo, [hello world]); console.log(result.stdout); // hello world这里echo是命令名[hello world]是参数数组。注意我完全没有写shell: trueexeca 会直接在当前进程下以子进程方式运行echo参数原样传递。返回值result里的字段非常直观字段含义stdout标准输出内容默认按 utf8 解码成字符串stderr标准错误内容exitCode进程退出码成功一般是 0failed进程是否失败退出码非 0 或信号中断command实际执行的完整命令timedOut是否因超时被终止isCanceled是否被取消最舒服的一点是默认情况下命令退出码非 0 时execa 会直接抛异常你不用每次手动检查exitCode。异常对象上同样挂载了stdout、stderr、exitCode这些字段方便在 catch 里拿到失败现场。2.3 常用参数cwd、env、timeout、reject实际使用中这几个参数出现频率最高。cwd指定工作目录几乎所有封装命令的函数都会用到const result await execa(git, [status, --porcelain], { cwd: /path/to/project });env可以附加环境变量注意它会和process.env合并await execa(node, [build.js], { env: { ...process.env, NODE_ENV: production, MAX_WORKERS: 4 } });timeout是超时时间单位毫秒。一旦超过设定时间execa 会终止子进程并抛异常异常对象的timedOut属性是truetry { await execa(npm, [install], { timeout: 60_000 }); } catch (error) { if (error.timedOut) { console.error(npm install 超时了); } }reject这个参数值得单独说。默认情况下false进程失败时抛异常如果你希望失败时不抛、而是拿到一个带failed: true的对象可以传{ reject: false }。这在你只想执行命令、不关心结果是否成功或者想把成败当成可预期流程的时候特别有用比如批量清理临时文件。3. 实战一把高频命令封装成函数3.1 获取 Git 仓库的改动文件做 CLI 工具时最典型的需求判断一个仓库当前有哪些改动。我用git status --porcelain加 execa 封装实现起来非常干净import { execa } from execa; async function getGitChanges(cwd process.cwd()) { const result await execa(git, [status, --porcelain], { cwd }); if (result.failed) { throw new Error(git status 失败${result.stderr}); } return result.stdout .split(\n) .filter(Boolean) .map((line) ({ status: line.slice(0, 2).trim(), file: line.slice(3).trim() })); } const changes await getGitChanges(./my-project); console.log(changes);这里注意execa会一下子把stdout全部读进内存。对于git status这种输出不会特别大的命令完全没问题但如果命令输出几百 MB就别用这种全缓冲模式第八章的流式处理会讲替代方案。3.2 执行打包命令并捕获输出构建工具里经常要调用其他打包命令。用 execa 处理的时候最好把stdout和stderr分开看避免错误信息被忽略async function runBuild() { try { const result await execa(npm, [run, build], { cwd: ./frontend, timeout: 120_000 }); console.log(result.stdout); } catch (error) { console.error(构建失败); console.error(error.stderr || error.message); process.exit(1); } }如果不小心把stdout和stderr混在一起或者只想统一打印可以用all: true。开启后result.all会按时间顺序合并两个流出错时定位日志会轻松很多。3.3 超时与主动取消进程超时不只是设置一个数字那么简单。execa 在超时后默认发SIGTERM信号终止进程。但有些进程收到SIGTERM不一定会立刻退出比如某些长时间运行的构建工具、交互式命令。这时候可以指定更硬的信号await execa(npm, [run, watch], { timeout: 10_000, killSignal: SIGKILL });SIGKILL是强制终止进程无法捕获适合确认要立刻结束的场景。不过平时我建议优先用默认的SIGTERM给进程一个做清理工作的机会如果发现超时后进程还挂在那再改成SIGKILL。手动取消也支持。execa返回的对象本身是一个子进程引用你可以对它调用.kill()const subprocess execa(npm, [install]); setTimeout(() { subprocess.kill(SIGTERM); }, 5_000); try { await subprocess; console.log(安装完成); } catch (error) { if (error.isCanceled) { console.log(安装被手动取消了); } }注意error.isCanceled和error.timedOut是不同概念前者是手动 kill后者是超时触发。手动 kill 不会把timedOut设成 true排查问题时别搞混。3.4 模板字符串调用方式execa 提供了一种类似 shell 的模板写法看起来非常直观const branch feature/login; await execagit checkout ${branch};这种写法会帮你自动转义branch里的特殊字符保障安全。但它也有自己的适用边界适合参数不多、可读性优先的场景。一旦参数复杂我反而更推荐数组形式因为模板形式中空白、引号的处理还是有一些隐蔽细节不如数组传参直白可控。4. 进阶同步执行、流式输出与 stdin 输入4.1 用 execaSync 做同步执行在命令行工具里有时候不想引入 async/await或者处理流程就是线性执行可以用同步版本import { execaSync } from execa; const result execaSync(ls, [-la], { cwd: ./src }); console.log(result.stdout);同步版本会阻塞当前进程所以只适合短命令。像我上面这种ls当然没问题但如果在里面跑npm install整个进程会卡到命令结束体验很差。我的原则是交互式 CLI 用异步版本构建脚本里需要严格顺序且命令简单时才考虑同步版本。execaSync同样支持timeout、cwd、env这些参数也会在失败时抛异常。4.2 让子进程输出直接打到终端有时你希望命令的输出实时显示在终端上而不是等全部执行完再一次性打印尤其是npm run dev、jest --watch这种长时间运行命令。可以这样const subprocess execa(npm, [run, dev], { stdout: inherit, stderr: inherit }); await subprocess;stdout: inherit意味着子进程的输出直接继承父进程的 stdout/stderr。这样能实时看到日志但要注意开启 inherit 后result.stdout和result.stderr会是undefined因为数据没有被收集到缓冲区。如果你既想实时显示又想事后拿完整日志可以用流监听方式或者把输出重定向到文件再配合all: true看合并结果。4.3 向子进程写入标准输入不是所有命令都是“只读”的。有些命令需要从 stdin 读数据比如sort、grep、各类转换工具。execa 提供了input选项const result await execa(sort, [], { input: pear\napple\nbanana\n }); console.log(result.stdout); // apple // banana // pearinput可以是字符串、Buffer 或者一个可读流。这个特性在自动化处理文本时很好用不用真的去启动一个交互式终端再往里面灌数据。结合input和stdout你就可以把 execa 当成一个“函数式管道”来用给一个命令喂入内容、拿到输出再喂给下一个命令。4.4 处理超大输出的流式方案全缓冲模式方便但内存有上限。如果命令输出几十甚至上百 MB比如读取一个大日志文件、导出数据库 dump我更推荐走流式import { execa } from execa; import { createWriteStream } from node:fs; const subprocess execa(mysqldump, [-u, root, mydb], { stdout: pipe }); subprocess.stdout.pipe(createWriteStream(./backup.sql)); await subprocess;这里stdout: pipe其实是不写也默认的行为但写出来可以明确表达意图我想拿到子进程的 stdout 流去处理。流式方案的好处是边产出边消费内存占用稳定还方便继续做管道组合。代价是你要自己处理流的错误和结束时机代码量会多不少。5. 常见问题与排查实录5.1 中文输出乱码Windows 下跑命令明明控制台显示正常中文用 execa 拿到的结果却是乱码。本质上是编码问题execa 默认按utf8解码 stdout但某些命令输出的是 gbk 或者系统本地编码。解决思路有三个如果命令本身支持指定编码参数优先在命令参数里解决比如iconv -f gbk -t utf8这种管道处理。拿原始 Buffer自己转码。execa 支持encoding: buffer拿到 Buffer 后再用iconv-lite之类的库转换。shell 层配合编码设置但不推荐跨平台容易顾此失彼。import { execa } from execa; import iconv from iconv-lite; const result await execa(some-command, [], { encoding: buffer }); const text iconv.decode(result.stdout, gbk);这个方法能覆盖绝大多数乱码场景代价是默认不可读字符串需要你的逻辑跟着调整。5.2 Windows 上命令找不到或 ENOENTexeca 在 Windows 上已经处理了很多坑但仍然会有ENOENT报错。常见原因是没有写命令的完整扩展名。比如直接execa(npm, [run, build])在某些环境下可能失败因为系统找到的是npm.cmd。建议遇到这类问题时明确使用完整命令名await execa(npm.cmd, [run, build]);或者用execaCommand(npm run build)配合shell: true让系统自己去解析。要注意execaCommand由于走 shell会有转义和注入风险只适用于你完全信任的静态命令。5.3 stdout 和 stderr 的顺序错乱在控制台日志里有时候会看到错误信息出现在输出前面时间顺序对不上。这是因为 stdout 和 stderr 是两个独立流缓冲策略不同。想严格还原子进程内部输出顺序必须用all: trueconst result await execa(npm, [run, test], { all: true }); console.log(result.all);result.all是两条流的合并顺序基本接近真实执行顺序排查问题时强烈建议开启。特别是在 CI 上分析构建日志宁可多占一点内存也不要看错乱顺序的日志。5.4 timeout 触发了但进程没退出遇到最无语的情况明明设置了timeout: 50005 秒后异常也抛了但后台进程还在跑端口还被占着。这是因为 execa 超时默认发的SIGTERM没被目标进程处理。这时有两个选择改成SIGKILL强制终结await execa(npm, [run, server], { timeout: 5000, killSignal: SIGKILL });在父进程退出时彻底清理execa 有个cleanup选项默认是开启的它会保证父进程结束前杀掉子进程但如果父进程是被SIGKILL杀掉这个机制也可能失效所以别完全依赖它。我的经验是对于知道自己会长时间驻留的服务类命令超时不要随便设SIGKILL先给SIGTERM再观察如果确认进程不响应任何优雅退出就上SIGKILL。5.5 CommonJS 项目导入报错这个前面提过再强调一次。新项目肯定是 ESM 没问题老项目如果突然报ERR_REQUIRE_ESM别慌。最简单方案是降级到execa5或者统一用await import(execa)。我见过不少团队因为版本升级整个工具链直接挂掉最后锁版本解决的。工具稳定比新功能重要不是每个项目都值得为 ESM 重构。6. 几个容易被忽略的小经验最后再分享几条实际项目里验证过的小经验。第一不要过度依赖shell: true。有些开发者觉得写execa(git status --porcelain, { shell: true })很省事但一旦命令里需要拼接变量你就等于亲手打开注入风险的大门。文件路径里带个空格、用户名里带个;脚本说不定就崩了。数组传参多写几个元素而已收益却大得多。第二日志保留command字段。execa 的 result 对象里有command方便打印实际执行的命令排查参数传错时特别有用。出问题时把command、stdout、stderr、exitCode一起打出来问题能定位快很多。第三尽量把 execa 封装到自己的公共函数里不要在业务代码里到处直接await execa。我一般会在项目里做一个runCommand函数统一处理 cwd、日志、超时、错误包装。这样后续如果要全局改超时时间、加日志前缀、统一错误格式只需要改一个文件维护成本低得多。第四如果你写的是要被别的工具调用的库记得考虑cleanup和timeout的组合。库的生命周期可能和宿主进程不一致子进程没人管就变孤儿进程端口冲突、文件锁问题都是这么来的。给所有长命令设置合理 timeout并且把 kill 逻辑暴露给调用方是工程上比较成熟的做法。execa 不是多神秘的东西它就是让 Node 调外部命令这件事从“能用”变成“好用”。把它的参数模型、输出模型、错误模型理解透了你写的脚本会稳定一大截。
阅读完成 · 觉得有帮助?