这个异常为什么难查
在Node.js 14中,async/await已经成为主流异步写法,但项目里往往还残留不少回调风格的API调用,尤其是fs、child_process和部分第三方库。两套语法混用后,最典型的故障就是:进程直接退出,控制台只显示UnhandledPromiseRejection或uncaughtException,但代码里明明写了try/catch。这类问题通常不是某个语法错误,而是错误被抛到了Promise链之外的全局空间。下面用一个可重复的排查顺序来定位。
先判断异常是否丢失在异步边界
排查这类问题时,先确认异常是在异步边界上丢失的。具体操作为:把async函数中的所有await语句包上try/catch,然后观察控制台输出。如果catch块没有捕获到错误,而进程以uncaughtException退出,就说明异常来自回调函数内部而不是await表达式本身。一个典型场景是:在async函数中调用fs.readFile的回调形式,却没有用util.promisify包装,此时回调里抛出的错误不会进入async的Promise链,而是直接成为未捕获异常。
这种try/catch实验可以快速区分异常来自await表达式还是回调内部。如果错误来自回调,catch块中打印的日志永远不会出现,而uncaughtException的堆栈会指向具体回调函数。注意Node.js 14中unhandledRejection默认行为是警告而不是退出,因此如果看到的是uncaughtException,并不能说明Promise链一定被正确处理,反而说明异常可能根本没有进入Promise。
风险边界:回调中的错误为何逃逸
在async/await与回调混用的情况下,最容易遗漏的是回调中的同步错误。例如在回调里直接throw new Error('x'),这个错误不会被外部async函数的try/catch捕获,因为回调执行时async函数已经暂停在await上,错误会变成全局uncaughtException。另一个风险是回调可能被多次调用或零次调用,导致Promise状态不确定。排查时可以用一个小技巧:在回调入口处先console.trace(),确认调用栈是否经过async函数,如果调用栈中看不到当前async函数的层级,就说明错误永远不会回到Promise链。
这里的关键在于理解回调的执行时机。当await一个回调式API时,async函数会挂起,把执行权交回事件循环。回调被触发时,async函数已经不活跃,它的try/catch作用域早已离开执行栈。所以回调中的throw,其实等价于在一个新的调用栈中抛出异常,唯一能接住它的就是全局的uncaughtException处理器或域的uncaughtException事件。
操作建议:尽量统一为Promise形式
要避免这种混用,应当统一使用util.promisify把回调函数转为Promise,再用await调用。例如const readFile = util.promisify(fs.readFile); 然后在async函数中await readFile(path)。如果代码里已经存在大量回调写法,可以分两步迁移:先为每个回调函数的错误参数手动判空,把错误用reject抛出;再逐步替换成Promise版本。注意,promisify只能处理最后一个参数是回调函数的API,对于多回调或事件式API(如EventEmitter)并不适用,这类场景需要额外包装。
迁移时一定要先画清调用边界。凡是改用Promise的地方,调用者必须用await或.then串联,否则新的异常又会变成未处理的rejection。对于不适用promisify的API,可以自己写一个包装函数:返回一个Promise,在回调中根据err是否为空决定resolve或reject。例如fs.exists并不适合promisify,因为它回调参数只有一个布尔值,需要手动封装成fs.promises.access的形式。
检查手段:临时日志与全局监听
如果混用已经发生且难以定位,可以临时在进程入口处添加process.on('uncaughtException')监听器,只打印error.stack和当前调用栈,同时保留原有的process.on('unhandledRejection'),让Node.js把unhandled rejection细节也输出。注意这类全局监听器不应作为正式错误处理方式,只用于短期排查。
启动排查时,可以加这个参数:
node --unhandled-rejections=strict app.js它会让未处理的Promise rejection变成异常,而不是只输出警告。但纯回调抛错不完全受它控制,所以只作为辅助手段。
另一个有效做法是在async函数的每个入口、每个await语句后面都添加一行console.log标记,对比日志顺序。如果异常堆栈中指向某个回调,且该回调的调用栈里没有当前async函数的帧,就可以确认错误被抛到了全局。加日志时,尽量带上时间戳和文件路径,比如console.error(time, file, line)。
容易忽略的检查点
- 回调中直接使用数据前没有判断err参数,导致后续出现ReferenceError,这类错误常被误认为数据问题。
- 回调可能被异步触发多次,比如使用fs.watch的监听回调,如果错误在此抛出,即使包装了Promise也只resolved一次,第二次错误会变成未处理rejection。
- 在async函数内部同步调用一个callback风格函数,但没有用Promise封装,且该函数在同一个tick内执行回调,此时错误仍会被try/catch捕获,因为await后的上下文仍是同步的,这种情况容易被误判为“混用没问题”。
代码审查时,如果看到async函数里同时出现await和回调形式,先检查错误传递路径是否闭合:回调的err是否被分支处理,是否通过reject转交,是否会被多次调用。建议用一套固定包装工具统一处理回调API,不要临时手写不同结构的包装逻辑。
Node.js 14对异步错误处理有一些行为差异,unhandledRejection的默认级别是警告,升级到更高版本后可能会变为抛出。如果遇到的是进程直接退出而不是警告,多半是回调错误真的逃逸了。先按上面的判断条件定位边界,再统一迁移到Promise,比在全局监听器里兜底要可靠得多。