如果你在某个 Node.js 项目里见到run-node.mjs这个文件,它大概率不是随手写的入口,而是一个负责按统一方式拉起 Node 服务的启动脚本。我第一次认真读这类文件,是在排查一个“生产环境偶尔没起来”的问题时。当时我以为启动脚本不就是node app.js么,结果翻了实际文件之后才发现,里面涉及的 ESM 模块机制、子进程管理、信号转发和退出码处理,每一处都是可以单独展开讲的知识点。
这篇文章就借run-node.mjs这个文件,把启动脚本这件事拆开讲透。你会知道它解决什么问题、为什么写成了.mjs、内部核心逻辑怎么设计、如何手写一个可靠的版本,以及实际运行中容易踩哪些坑。无论你是刚接触 Node.js 的前端,还是需要维护部署脚本的后端,这篇文章都值得花几分钟读完。
1. run-node.mjs 是什么,为什么值得研究
1.1 一个启动脚本要解决的三个问题
run-node.mjs这类文件,说到底是充当“应用启动器”的角色。它做的事情通常可以归纳为三件事:首先要找到一个需要执行的 JS 脚本路径,可能来自命令行参数,也可能来自项目配置;其次要用正确的环境变量和运行参数,在一个独立进程中拉起这个脚本;最后要在程序退出时把退出码、退出信号准确地传递到外层,让 CI、容器编排系统或 PM2 这类进程管理工具能拿到正确的状态。
这三件事看似简单,真正落地时全是细节。比如退出码如果不透传,应用明明报错了,外层看起来却是成功退出,监控告警就不会触发。再比如信号不处理,你向父进程发 SIGTERM 做优雅下线,子进程却一无所知,继续跑自己的逻辑,这在容器环境里就会造成实例无法按期停止。run-node.mjs存在的意义,就是把这些脏活累活统一接过去,让你在业务代码里不用关心这些通用逻辑。
这类文件在实际仓库中经常出现。有的是自己手写的,有的是从某个脚手架模板拷贝来的,甚至很多开源 CLI 工具内部也隐藏着类似机制,只是起名不同:start.mjs、run-dev.mjs、entry.mjs,职责都大同小异。理解了run-node.mjs,你就能读懂一票启动类脚本的处理套路。
1.2 为什么偏偏是 .mjs 而不是 .js 或 .cjs
看到.mjs后缀,第一反应通常是“ESM 模块”。Node.js 决定用哪种模块系统,主要看两点:文件后缀和 package.json 里的 type 字段。.cjs强制按 CommonJS 解析,.mjs强制按 ES Module 解析,最普通的.js则看 package.json 里"type"的值,不写就默认 CommonJS。
run-node.mjs选择 ESM,不是没有理由的。ESM 天然支持顶层await,这写启动脚本的时候特别顺手。举个例子,启动前需要读取配置文件,或者动态校验目标脚本是否存在,用 CommonJS 你只能包一层async function main() { ... }再调用,而 ESM 可以直接在顶层写await loadConfig(),代码结构明显清爽。再加上import.meta能拿到当前文件 URL 和目录信息,做路径解析比 CommonJS 的__dirname方案更符合现代规范。
另外一个现实原因是兼容性。现在 npm 上的新包大量采用 ESM-only 发布策略,如果你的启动脚本本身用 CommonJS 写,想require()一个纯 ESM 包会直接报ERR_REQUIRE_ESM。反过来,启动器用 ESM 写,就可以自由地用import()动态引入各类模块,不用看对方脸色。我见过不少团队专门为了启动脚本能动态加载新依赖,把入口切换成.mjs。
2. run-node.mjs 的核心设计拆解
2.1 用 ESM 做动态加载的真实价值
写启动脚本时,动态加载能力很关键。你经常需要根据环境变量或命令行参数决定加载哪个模块,比如--config production就加载生产配置,--mode test就预置测试桩数据。CommonJS 的require是同步的,处理动态路径只能用拼接字符串兜底;ESM 里的import()是异步的,天然支持动态路径和条件分支,配合顶层await,写起来非常直观。
举个例子,脚本里需要判断当前平台来决定加载哪个目标文件:
const isWindows = process.platform === 'win32'; const { run } = await import(isWindows ? './runner-win.mjs' : './runner.mjs'); await run();这种写法在 CommonJS 里也能模拟,但会出现各种模块缓存和__dirname路径错位的坑。ESM 的import()每次都走标准解析器,路径规则清晰,出问题概率低很多。对于run-node.mjs这种要“面向变化”的启动器,动态 import 可以说是刚需,这也是主进程脚本优先选择 ESM 的核心原因。
不过要提醒一句:import()动态加载会受 ES Module 的静态分析限制,虽然路径可以是变量,但如果你在代码里写了import('./config/' + name + '.js')这样的表达式,实际打包工具(比如 webpack、Rollup)做依赖分析时可能束手无策。对纯 Node.js 运行时来说问题不大,但如果某个启动脚本未来要接进前端构建链路,得留意这个差异。
2.2 spawn 与信号转发的原理
run-node.mjs通常是先作为父进程跑起来,再用子进程承载真正的目标脚本。这里用到的核心技术就是child_process.spawn()。为什么要多加一层进程而不是直接写业务逻辑?答案是可观测性和可替换性。有了这层包裹,父进程可以在启动前统一注入环境变量,启动后统一收集退出状态,甚至在目标脚本崩溃时做重启策略。
spawn()和exec()、fork()的区别值得说清楚。exec()默认会起一个 shell,方便执行带管道的命令,但多了 shell 层就意味着信号处理、转义规则更复杂,容易埋坑;spawn()则是直接执行指定命令,不经过 shell,适合启动 Node 进程;fork()是 spawn 的专用变体,它额外创建了 IPC 通信通道,适合需要父子进程频繁消息交互的场景,但如果你只是想“拉起一个业务进程然后不管它”,用fork()反而多了一层约束。
关于信号,有一个非常微妙但常被忽略的点:当你开着终端,用node run-node.mjs app.js这种方式启动时,按 Ctrl+C,终端通常会把 SIGINT 同时发给前台进程组里的所有进程,父进程和子进程都会收到。但如果你通过 systemd、Docker 的docker stop来停服务,实际发送的是 SIGTERM,而且默认只发给父进程,子进程根本收不到。如果父进程没有写信号转发逻辑,就会出现“父进程退了、子进程还活着”的孤儿进程场景,这在容器里会导致服务停止超时,甚至实例变僵尸。
所以一个合格的run-node.mjs,必须监听 SIGINT、SIGTERM 这类终止信号,在收到后转发给子进程,再等待子进程退出,最后自己才退出。这一步是整个启动脚本设计中的灵魂,也是网上很多“简化版启动脚本”最常漏掉的部分。
2.3 参数与环境变量的流转路径
启动脚本里,参数传递其实是两层:一层是命令行参数,一层是环境变量。命令行参数通过process.argv获得,需要注意process.argv的前两个固定元素分别是 Node 可执行文件路径和当前脚本路径,真正业务参数要从argv[2]开始取。如果你用run-node.mjs app.js --port 3000启动,那么argv[2]是app.js,--port和3000都在后面。
把哪一个参数给父进程、哪一个给子进程,要有十分明确的约定。常见设计是:第一个位置参数作为目标脚本路径,后续参数原样透传给子进程;额外的启动器控制项(比如--inspect调试端口、--max-old-space-size内存上限)则用独立参数名区分。如果约定不清晰,很容易出现“参数被父进程吃掉了”或者“子进程收到一堆无关参数”的混乱情况。
环境变量的流转也有讲究。默认情况下,子进程会继承父进程当前的环境变量,所以你在 shell 里export NODE_ENV=production再启动,子进程里自然能读到。如果你想给子进程额外注入或覆盖一些变量,就要在spawn的env选项里合并传入:
const child = spawn('node', [script], { env: { ...process.env, RUN_NODE_ENTRY: '1', APP_ENV: 'production' } });这里有个常见误区:如果你直接把env设成一个全新的对象,比如{ APP_ENV: 'production' },子进程会丢失系统原有的PATH等关键变量,导致一些依赖系统命令找不到可执行文件。正确做法永远是先展开process.env,再覆盖或新增字段。这不是小问题,我见过启动脚本把NODE_OPTIONS弄丢之后整个服务莫名变慢的真实案例。
3. 完整实现:从零写一个可靠的 run-node.mjs
3.1 模块骨架与参数解析
废话不多说,我直接给出一个我认为足够可靠的run-node.mjs参考实现。你可以在自己的项目里直接改着用。先看整体骨架:
#!/usr/bin/env node import { spawn } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; const __dirname = dirname(fileURLToPath(import.meta.url)); function parseArgs(argv) { const result = { script: null, passthrough: [], inspect: false }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === '--inspect') { result.inspect = true; } else if (result.script === null) { result.script = arg; } else { result.passthrough.push(arg); } } return result; } const options = parseArgs(process.argv); if (!options.script) { console.error('Usage: node run-node.mjs <script> [args...]'); process.exit(1); }这里有几个细节值得展开。#!/usr/bin/env node是 shebang,让文件在 Unix 系列系统下可以直接以脚本方式执行,但它在node run-node.mjs这种显式调用模式下完全被忽略,不影响逻辑。在 ESM 中取当前目录,需要先用fileURLToPath(import.meta.url)把模块 URL 转成文件系统路径,再取dirname,这比 CommonJS 的__dirname更标准,但容易写漏。
另一个设计点是参数解析。我把第一个非控制参数当作目标脚本,后续的都直接透传;以--开头的控制参数目前只处理了--inspect。实际项目中你可能还需要--watch、--max-old-space-size=2048这类参数,逻辑就是多几个分支判断。重点在于,启动脚本本身应该尽量“无知”,不要想着帮子进程解析所有业务参数,明确边界才能减少维护成本。
3.2 拉起子进程与优雅退出
参数解析之后,进入核心的进程拉起和安全退出部分。继续往下写:
const targetScript = resolve(__dirname, options.script); const nodeArgs = []; if (options.inspect) { nodeArgs.push('--inspect'); } nodeArgs.push(targetScript, ...options.passthrough); const child = spawn(process.execPath, nodeArgs, { stdio: 'inherit', env: { ...process.env, RUN_NODE_ENTRY: '1' } });这里有几个重要选择要解释清楚。process.execPath指的是当前正在运行的 Node 可执行文件路径,用它来启动子进程,能保证子进程和父进程用同一个 Node 版本。这在多版本并存的环境里尤其重要,如果你直接写死'node',有可能触发 PATH 里另一个版本,调试起来非常痛苦。targetScript先经过resolve处理,是防止传入相对路径时父子进程工作目录不一致导致找不到文件。
stdio: 'inherit'是一个容易忽视但极其关键的选择。它意味着子进程直接复用父进程的标准输入、输出和错误输出,这样你在终端里看到的应用日志、报错堆栈都原样打到当前终端,Ctrl+C 也能正确传递到终端进程组。如果这里用了默认的pipe,子进程的 stdout 会被父进程截获但不处理,你会在屏幕上看不到任何日志,那排错基本只能靠猜。
然后是退出与信号处理。这是启动脚本最容易出问题的地方,我单独拆开说明:
child.on('error', (err) => { console.error('[run-node] failed to start child:', err); process.exit(1); }); child.on('exit', (code, signal) => { if (signal) { // 子进程是被信号杀掉的,按惯例用 1 作为兜底退出码 console.error(`[run-node] child exited by signal ${signal}`); process.exit(1); } process.exit(code ?? 0); }); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { process.on(signal, () => { if (child.exitCode === null && child.signalCode === null) { child.kill(signal); } }); }信号这块,思考路径是这样的:如果父进程收到 SIGTERM 但不处理,Node 默认行为是直接退出,自己都退了,子进程自然存活成孤儿。所以我们要注册监听器,收到信号后把同样的信号转发给子进程,让子进程有机会做优雅退出,比如关闭数据库连接、清理临时文件。等子进程退出后,exit事件里的回调再决定父进程的最终退出码,这样可以保证外层看到的状态是准确的。
为什么没在处理完信号后立刻process.exit?因为要克制。很多新手写到这里会顺手加一句process.exit(),想着马上退掉父进程。但一旦你process.exit,子进程的优雅退出逻辑可能还没跑完,日志也没打全,整个时机就全乱了。正确的做法是“只管转发,别急着自杀”,让子进程先走完自己的流程。
3.3 扩展方向与边界条件
拿到上面的基础版本,run-node.mjs已经能应对大部分场景。但在真实部署里,你很可能还想做几件事。第一件是加日志前缀。现在的stdio: 'inherit'虽然把日志都透传了,但你没法区分哪些是父进程打的、哪些是子进程打的。如果想要统一格式或加时间戳,就得把子进程的 stdout 改回pipe,再手动在父进程里child.stdout.pipe(process.stdout)并拦一层加前缀。代价是代码量增加,但运维时看日志的幸福感直接拉满。
第二件是崩溃重启。通过监听exit事件,你可以判断退出码非 0 且不是因为信号导致的退出,再计数重启次数。但这里要有边界条件:必须设置最大重启次数,并且做指数退避,否则目标脚本因为配置文件错误反复崩溃重启,整个系统的资源就会被白白消耗。比如重启延迟可以设计成1000 * 2 ** retryCount毫秒,连续重启 5 次就放弃并告警。
第三件是支持.env文件加载。很多业务项目会用到dotenv风格的配置,启动脚本如果可以直接解析.env里的键值对,再合并到env里传给子进程,开发体验好很多。不过这只是锦上添花,核心机制还是前面这些,顺序千万别搞反:先搞懂模块加载和进程管理,再去折腾花哨功能。
4. 常见问题速查与避坑实录
4.1 高频问题排查速查表
实际用了run-node.mjs之后,你可能会遇到下面这些典型问题。我按症状、原因、解决方案整理成一张表,排查时对着看比较顺手。
| 症状 | 原因 | 解决方案 |
|---|---|---|
| 启动后没有任何日志输出 | stdio用了默认的pipe,且父进程没有消费子进程输出 | 显式设置stdio: 'inherit',或手动child.stdout.pipe(process.stdout) |
报require is not defined | 脚本按 ESM 解析,但代码里用了 CommonJS 的require | 把require换成 ESM 的import,或改用.cjs文件 |
报__dirname is not defined | ESM 环境中没有 CommonJS 全局变量 | 用dirname(fileURLToPath(import.meta.url))替代 |
| 按 Ctrl+C 后进程还是杀不死 | 没有监听并转发 SIGINT,或子进程自己忽略了信号 | 父进程注册 SIGINT 监听并child.kill('SIGINT') |
| 子进程崩溃了但外层显示成功 | 父进程没有把子进程退出码透传出去 | 在exit事件中process.exit(code),不要自己固定返回 0 |
| 相对路径找不到目标脚本 | 直接拿argv[2]路径去 spawn | 先用resolve()转为绝对路径,或基于import.meta.url计算 |
node-package提示ERR_REQUIRE_ESM | CommonJS 脚本尝试同步加载纯 ESM 包 | 主入口切换为.mjs并使用动态import()加载该包 |
其中require is not defined是新手上路时最高频的问题。记住.mjs文件里,模块系统是 ESM,它没有全局require、module、exports这些 CommonJS 文物。如果你确实需要一个require函数做兼容,可以用node:module提供的createRequire手动创造一份,指向当前文件的 URL,但这属于“兼容旧包”的退路,不是主路,新代码一律优先写import。
另一个不太起眼却常见的坑是,如果使用resolve(__dirname, options.script)把脚本路径结合当前文件目录解析,而run-node.mjs又放在scripts/子目录下,目标脚本在项目根目录,这时候路径解析出来的结果就是错的。实际项目里,建议先明确约束run-node.mjs放在项目根目录,或者从process.cwd()去解析相对路径。两种方案各有利弊,关键是全组约定一致。
4.2 我第一次跑挂后的复盘
刚写完第一版run-node.mjs的时候,我直接犯了一个错误:在child.on('exit')里用了process.exit(0),以为父进程跟着退掉就行。结果子进程因为运行时报错退出,退出码是 1,但父进程却以 0 退出,外层 CI 完全没报红,问题藏在日志里两天才被发现。之后就长记性了:启动脚本的退出码必须严格等于或传递自子进程的真实退出码。
第二个栽过跟头的地方是信号处理。早期版本只监听了SIGINT,没有监听SIGTERM。本地跑一切正常,毕竟 Ctrl+C 触发 SIGINT 也能正常退出;一上容器环境就卡在优雅停机那里,docker stop默认发 SIGTERM,父进程根本没接招,直接默认终止,子进程成了孤儿继续活着。排查体系内停机超时问题时,才发现是启动脚本少监听了一个信号。后来我把SIGTERM、SIGHUP、SIGINT一起放进数组统一处理,再没出过这问题。
第三个体会是,stdio: 'inherit'虽然有日志直通的便利,但如果你需要在子进程日志里加时间戳或者统一 JSON 格式,最好还是用pipe后在父进程里转发。我之前图省事一直用inherit,后来需求要求日志全部结构化,只能回头改造,反而浪费了更多时间。
最后再分享一点个人经验:不要为了“统一”把所有启动逻辑都塞进run-node.mjs。有人喜欢把环境变量白名单、端口检测、磁盘剩余空间检查全堆进启动器,最后脚本越写越长,调试一次要翻好几百行。我的习惯是让启动器保持单一职责,只做“解析参数、拉起子进程、透传退出状态”这三件事,其余逻辑通过环境变量和外部配置文件交给业务代码自己处理。这样换一个项目部署,run-node.mjs基本原封不动就能复用,真正实现“一套启动脚本,到处可靠运行”。