如何给所有的 async 函数添加 try/catch?
Category(分类): JavaScript Status: 已整理
前言
阿里三面时被问到“如何给所有的 async 函数添加 try/catch”。这个问题既可以考察 Promise 错误传播,也可以考察 AST 和 Babel 插件开发。
本文保留原文的 AST 学习主线,但先给出一个重要边界:不存在一个只靠给 async 函数包一层 try/catch 就能捕获所有异步错误的方案。
- async 函数调用总是返回 Promise;函数体中未捕获的同步异常和 await 到的 rejection 会使这个 Promise rejected。
- 没有
await的throw或return Promise.reject(error)同样会 rejected,不能只遍历AwaitExpression来判断“所有错误”。 - 没有被
await/return/.catch()接住的 detached Promise,外层try/catch不会自动捕获。 setTimeout、事件监听器等稍后执行的回调也不在当前try的同步范围内。- 自动生成的
catch如果只打印而不重新抛出,会把 rejected Promise 变成 fulfilledundefined,改变调用方的错误契约。
因此,本文实现将目标描述为:用 Babel 为符合条件的 async 函数建立可配置的错误边界。默认记录错误后重新抛出;如果业务确实要恢复,必须明确设置 rethrow: false,而不是静默吞错。
async 不加 try/catch 会发生什么?
async function fn() {
await new Promise((_, reject) => {
reject(new Error('failure'))
})
console.log('do something...')
}
void fn().catch((error) => {
console.error('handled at the call boundary:', error)
})
如果调用方没有添加 rejection handler,浏览器通常会在控制台报告未处理 rejection,并可能触发 unhandledrejection;Node.js 会根据自身版本和配置触发 process.on('unhandledRejection') 等宿主机制。宿主的展示和退出策略不完全相同,不能简单写成“浏览器一定报一个未捕获错误”。
生产代码可以在边界处处理:
async function loadPage() {
const response = await fetch('/api/page')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
async function main() {
try {
return await loadPage()
} catch (error) {
console.error('loadPage failed', error)
throw error
}
}
void main().catch((error) => {
// 最外层负责展示、上报或设置失败状态
console.error('main failed', error)
})
catch 不重新抛出并不只是“少了一行代码”,它会改变 Promise 的状态:
async function swallow() {
try {
throw new Error('failure')
} catch (error) {
console.error(error)
}
}
swallow().then((value) => {
console.log(value) // undefined:错误已被恢复为 fulfilled
})
Babel 插件的最终效果
原始代码:
async function fn() {
await new Promise((_, reject) => reject(new Error('报错')))
await new Promise((resolve) => resolve(1))
console.log('do something...')
}
本文的教学插件可以生成类似下面的代码:
async function fn() {
try {
await new Promise((_, reject) => reject(new Error('报错')))
await new Promise((resolve) => resolve(1))
console.log('do something...')
} catch (error) {
console.error('Error: fn', error)
throw error
}
}
这里的 throw error 很重要:调用方仍然可以通过 .catch()、重试、事务回滚或错误边界处理失败。若只想记录并恢复,应把它设计成显式选项,并在文档中说明返回值和错误契约。
Babel 插件的实现思路
- 使用 Babel parser 将源代码转换为 AST;
- 访问 async 函数节点,而不是只访问
AwaitExpression; - 找到函数体,并处理
async () => expression这种表达式箭头函数; - 判断当前函数是否已经存在本插件生成的完整包装,保证重复编译不会不断套娃;
- 创建
try/catchAST,把原函数体放入try; - catch 中记录稳定的函数名和可选位置,默认重新抛出原错误;
- 使用 Babel 的 NodePath API 替换节点,并对函数声明、函数表达式、箭头函数、对象方法、类方法和私有方法进行测试。
插件只负责静态转换。它无法捕获运行时动态 eval 中未转换的代码,也不能把 detached Promise、定时器和事件回调神奇地变成当前函数的同步异常。
Babel 的核心:AST
AST(Abstract Syntax Tree,抽象语法树)是代码结构的树形表示。常见流程可以概括为:
- 词法分析:把字符串拆成 token;
- 语法分析:根据语法规则建立节点和父子关系;
- 转换:访问、创建、替换 AST 节点;
- 生成:把 AST 输出为 JavaScript 代码。
例如:
function demo(n) {
return n * n
}
在 Babel AST 中大致会出现 Program、FunctionDeclaration、Identifier、BlockStatement、ReturnStatement 和 BinaryExpression 等节点。具体字段由 Babel 版本和 parser 选项决定,Babel AST 与 ESTree 并不完全相同。

常用 AST 节点类型
| Babel 节点 | 中文名称 | 示例 |
|---|---|---|
Program | 程序主体 | 整段代码 |
VariableDeclaration | 变量声明 | const value = 1 |
FunctionDeclaration | 函数声明 | async function fn() {} |
FunctionExpression | 函数表达式 | const fn = async function () {} |
ArrowFunctionExpression | 箭头函数 | const fn = async () => {} |
ObjectMethod | 对象方法 | { async fn() {} } |
ClassMethod | 类公有方法 | class A { async fn() {} } |
ClassPrivateMethod | 类私有方法 | class A { async #fn() {} } |
AwaitExpression | await 表达式 | await request() |
BlockStatement | 块语句 | { ... } |
TryStatement | try 语句 | try {} catch {} |
CatchClause | catch 分支 | catch (error) {} |
ThrowStatement | throw 语句 | throw error |
CallExpression | 调用表达式 | console.error(error) |
MemberExpression | 成员表达式 | console.error |
StringLiteral | 字符串字面量 | 'Error' |
NumericLiteral | 数字字面量 | 100 |
Literal 是 ESTree 中常见的总称;在 Babel AST 中通常拆成 StringLiteral、NumericLiteral、BooleanLiteral 等,不能把两种 AST 格式的节点名混写。
AST 解析示例
const parser = require('@babel/parser')
const ast = parser.parse('async function fn() { await request() }', {
sourceType: 'unambiguous',
plugins: []
})
console.log(ast.program.body[0].type) // FunctionDeclaration
console.log(ast.program.body[0].async) // true
sourceType 可按目标代码选择 script、commonjs、module 或 unambiguous;顶层 await、TypeScript、JSX 等语法还需要对应 parser 插件或配置。
await 节点对应的 AST
原始代码:
async function fn() {
await request()
}
其中 await request() 是 AwaitExpression,其 argument 是 CallExpression。包装后:
async function fn() {
try {
await request()
} catch (error) {
console.error(error)
throw error
}
}


不过,插件不能只寻找 AwaitExpression:
async function throwsWithoutAwait() {
throw new Error('no await, but still rejected')
}
async function returnsRejectedPromise() {
return Promise.reject(new Error('also rejected'))
}
如果目标是给 async 函数建立错误边界,应访问 Function 节点并检查 node.async。如果目标只是“包含 await 的函数教学转换”,可以额外设置 onlyWithAwait: true,但不要把它称作所有 async 函数。
Babel 插件开发
插件的基本格式
本文选择 Babel 7 CommonJS 写法作为示例;当前项目是 ESM/Nuxt 项目,不能把下面的 module.exports 文件直接当作 Nuxt 应用代码使用。若实际接入,应在独立的 Babel 插件包中锁定 Babel 版本和测试环境。
// Babel 7 CJS 插件示例
module.exports = function asyncErrorBoundaryPlugin({ types: t }) {
return {
name: 'async-error-boundary-teaching',
visitor: {
AwaitExpression(path) {
if (!path.node.argument) return
console.log(t.isCallExpression(path.node.argument))
}
}
}
}
注意:
- 使用
babel.types,不是不存在的babel.type; CallExpression拼写正确,不能写成CallExression;- visitor 回调的第二个参数
state用于读取state.opts; - 现代包名使用
@babel/parser、@babel/types、@babel/template等,不再使用旧的babel-template作为新项目依赖。
寻找 async 函数
原文按四种节点手工判断,遗漏了类方法、私有方法和表达式箭头。Babel 提供 Function alias,可以覆盖多种函数节点;也可以使用 path.getFunctionParent() 查找最近函数,但必须先判断是否存在:
module.exports = function ({ types: t }) {
return {
name: 'find-async-functions',
visitor: {
Function(path) {
if (!path.node.async || path.node.generator) return
console.log('async function:', path.node.type)
console.log('is block body:', t.isBlockStatement(path.node.body))
}
}
}
}
async generator(async function*)返回的是 AsyncGenerator,不应在没有单独设计语义的情况下用普通 Promise 错误边界包装,因此本示例明确排除 node.generator。

表达式箭头函数的特殊情况
下面的函数体是表达式,不是 BlockStatement:
const fn = async () => await request()
在加入 try 之前,需要把它转换成等价的块体并保留返回值:
const fn = async () => {
try {
return await request()
} catch (error) {
console.error(error)
throw error
}
}
如果直接访问 info.body,会把 AwaitExpression 当作块体数组,最终导致插件崩溃或生成错误 AST。
使用 Babel builders 创建 try/catch
使用 @babel/types builders 可以减少模板字符串和版本差异:
const t = require('@babel/types')
const errorId = t.identifier('error')
const log = t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('console'), t.identifier('error')),
[t.stringLiteral('async error'), t.cloneNode(errorId)]
)
)
const tryStatement = t.tryStatement(
t.blockStatement([]),
t.catchClause(
errorId,
t.blockStatement([
log,
t.throwStatement(t.cloneNode(errorId))
])
),
null
)
console.log(tryStatement.type) // TryStatement
如果确实需要模板,可以使用 @babel/template:
const template = require('@babel/template').default
const buildLog = template.statement(
'console.error(%%MESSAGE%%, %%ERROR%%)'
)
const statement = buildLog({
MESSAGE: require('@babel/types').stringLiteral('async error'),
ERROR: require('@babel/types').identifier('error')
})
console.log(statement.type) // ExpressionStatement
模板占位符和数组替换要锁定 Babel 版本并配套测试;教学实现使用 builders 更容易说明节点结构。
一个可运行思路的 Babel 7 CJS 核心实现
下面的代码是教学版,默认保持 rejection 语义。它使用 Function visitor,一次处理一个 async 函数;支持函数声明、函数表达式、块体箭头、表达式箭头、对象方法和类方法。生产插件还需要完善 source map、注释、文件 glob、幂等 marker 和测试。
'use strict'
const DEFAULT_OPTIONS = Object.freeze({
customLog: 'Error',
exclude: ['node_modules'],
include: [],
rethrow: true,
includeLocation: false,
onlyWithAwait: false
})
function toArray(value, fallback = []) {
if (value == null) return [...fallback]
return Array.isArray(value) ? value.filter(Boolean) : [value]
}
function normalizeOptions(raw) {
const source = raw && typeof raw === 'object' && !Array.isArray(raw)
? raw
: {}
return {
...DEFAULT_OPTIONS,
...source,
exclude: toArray(source.exclude, DEFAULT_OPTIONS.exclude),
include: toArray(source.include, DEFAULT_OPTIONS.include)
}
}
function matchesFile(patterns, filename) {
const file = String(filename || '').replaceAll('\\', '/')
return patterns.some((pattern) => {
const normalized = String(pattern).replaceAll('\\', '/')
return normalized && file.includes(normalized)
})
}
function getFunctionName(path, t) {
const { node } = path
if ((t.isFunctionDeclaration(node) || t.isFunctionExpression(node)) && node.id?.name) {
return node.id.name
}
if (t.isObjectMethod(node) || t.isClassMethod(node)) {
if (!node.computed && t.isIdentifier(node.key)) return node.key.name
if (t.isStringLiteral(node.key) || t.isNumericLiteral(node.key)) {
return String(node.key.value)
}
return '<computed>'
}
if (t.isClassPrivateMethod(node)) {
return `#${node.key?.id?.name || 'private'}`
}
const parent = path.parentPath
if (parent?.isVariableDeclarator() && t.isIdentifier(parent.node.id)) {
return parent.node.id.name
}
if (parent?.isAssignmentExpression() && t.isIdentifier(parent.node.left)) {
return parent.node.left.name
}
return '<anonymous>'
}
function containsAwait(node, t) {
let found = false
function visit(current) {
if (!current || found) return
if (t.isAwaitExpression(current)) {
found = true
return
}
for (const key of Object.keys(current)) {
if (key === 'loc' || key === 'start' || key === 'end') continue
const value = current[key]
if (Array.isArray(value)) value.forEach(visit)
else if (value && typeof value === 'object' && value.type) visit(value)
}
}
visit(node)
return found
}
function isCompleteWrapper(bodyNode, t) {
if (!t.isBlockStatement(bodyNode) || bodyNode.body.length !== 1) return false
const statement = bodyNode.body[0]
return t.isTryStatement(statement) && Boolean(statement.handler)
}
module.exports = function asyncErrorBoundaryPlugin({ types: t }) {
return {
name: 'babel-plugin-async-error-boundary-modern',
visitor: {
Function(path, state) {
const { node } = path
if (!node.async || node.generator || !node.body) return
const options = normalizeOptions(state.opts)
const filename = state.filename || state.file?.opts?.filename || ''
if (matchesFile(options.exclude, filename)) return
if (options.include.length && !matchesFile(options.include, filename)) return
const bodyPath = path.get('body')
if (bodyPath.isExpression()) {
const expression = bodyPath.node
bodyPath.replaceWith(t.blockStatement([
t.returnStatement(expression)
]))
node.expression = false
}
if (!bodyPath.isBlockStatement()) return
if (options.onlyWithAwait && !containsAwait(node.body, t)) return
if (isCompleteWrapper(bodyPath.node, t)) return
const errorId = path.scope.generateUidIdentifier('error')
const functionName = getFunctionName(path, t)
const location = options.includeLocation && node.loc
? `:${node.loc.start.line}:${node.loc.start.column}`
: ''
const message = `${options.customLog}: ${functionName}${location}`
const catchBody = [
t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('console'), t.identifier('error')),
[t.stringLiteral(message), t.cloneNode(errorId)]
)
)
]
if (options.rethrow !== false) {
catchBody.push(t.throwStatement(t.cloneNode(errorId)))
}
const oldBody = bodyPath.node
const tryStatement = t.tryStatement(
t.blockStatement(oldBody.body, oldBody.directives),
t.catchClause(errorId, t.blockStatement(catchBody)),
null
)
bodyPath.replaceWith(t.blockStatement([tryStatement], oldBody.directives))
}
}
}
}
这里有几个特意修正的点:
- 使用
state.opts,不使用 visitor 内部不稳定的this.opts; !typeof options === 'object'这类运算符优先级错误已避免;getFunctionName不再使用错误的getSibling('id');- 对表达式箭头函数先补
return; - 处理最近的函数节点,不用任意
TryStatementancestor 跨越函数边界; - 默认
rethrow: true,保留调用方的 rejection 语义; onlyWithAwait是明确的可选策略,不再把AwaitExpression当作所有 async 错误的入口。
上面的 containsAwait 只是演示思路,生产代码更适合使用 Babel path 遍历并排除嵌套函数边界。完整插件还应使用稳定的 extra marker 或其他方式实现幂等性,测试嵌套 async、已有 try/finally、注释和 source map。
已有 try/catch 时如何处理?
不能简单写成“只要父路径上有 TryStatement 就跳过”:
- 外层函数的 try 不能替代内层 async 函数自己的错误边界;
try/finally可能根本没有 catch;- 一个函数中有局部 try,并不代表其他语句都已处理;
- 插件二次运行时需要识别自己生成的 wrapper,而不是识别任意 try。
教学版可以只跳过“函数体恰好是一个带 catch 的 try”结构;生产版应加入稳定的插件 marker,并针对幂等性编写测试。
获取文件路径和方法名
原文把绝对 Windows 路径直接拼进日志,可能泄漏用户名、仓库目录或 CI 路径。更安全的策略是:
- 默认只记录函数名和源码位置;
- 路径需要时使用相对路径或经过脱敏的标识;
- 不要默认把任意 rejection reason 写入公开日志;
- 使用结构化 logger,并对 token、用户数据和请求信息脱敏。
函数名也不是总能静态得到:匿名箭头、计算属性、导出表达式、绑定后的函数都可能只有 <anonymous> 或 <computed> 这样的降级名称。源码位置只能辅助诊断,不能当作运行时函数名的绝对保证。
用户选项
可以设计如下选项:
| 选项 | 含义 |
|---|---|
include | 只处理匹配的文件;应明确 glob/路径匹配规则 |
exclude | 排除依赖目录或指定文件 |
customLog | 稳定的日志前缀 |
rethrow | 默认 true;是否在记录后重新抛出 |
includeLocation | 是否把源码行列加入日志 |
onlyWithAwait | 是否仅转换含 await 的函数,默认 false |
选项归一化不应修改调用方传入的对象;匹配文件时也不要只用过宽的 filename.includes() 代替经过测试的 glob 规则。
Babel 版本与安装
原文使用的 babel-template、babel.type 等写法属于旧 Babel 资料。现代 Babel 7 插件通常使用:
pnpm add -D @babel/core @babel/parser @babel/types @babel/generator
插件包可以使用 Babel 7 CommonJS:
module.exports = function (babel) {
const { types: t } = babel
return { visitor: {} }
}
也可以在独立的 Babel 8 ESM 包中使用 import/export,但需要按 Babel 8 的 Node 版本和迁移指南配置。当前 Nuxt 项目是 ESM,且没有把 Babel 插件作为项目依赖;本文不修改项目依赖,也不建议为了阅读文章直接把插件接入 Nuxt 构建。
原文提到的 deepmerge 也不是这个教学插件的必需依赖,选项归一化可以用无副作用的浅合并完成。若发布 npm 包,应声明 @babel/core peer dependency、锁定 Babel 主版本并补充 transform、AST、运行时和幂等性测试。
最小验证矩阵
正式使用前至少验证:
- 函数声明、函数表达式、块体箭头、表达式箭头;
- 对象公有方法、类公有/私有方法;
- 无 await 但
throw或返回 rejected Promise; - 顶层 await、async generator 的明确排除策略;
- 嵌套 async 与外层 try;
- 已有 try/catch、try/finally 和多次运行幂等性;
rethrow开关、非 Error rejection、source map 和日志脱敏;- detached Promise、定时器回调和事件回调不会被错误宣称为当前函数已捕获。
总结
AST 插件可以把重复的错误边界自动加到一组静态 async 函数上,但它不是全局异步异常捕获器。理解 Promise rejection 的传播规则比机械包裹 try/catch 更重要;默认保留错误传播、明确处理边界、补充测试,才是可维护的实现。




配图说明:图片按原文顺序从掘金 CDN 下载到 images/92-image-*。图片是历史文章截图,不是 Babel 官方规范图,原始授权未从页面确认;公开发布前应核实授权或替换为自制图。
参考资料
- MDN:async function
- MDN:await
- MDN:Promise.catch
- MDN:Using promises
- MDN:unhandledrejection
- Babel Parser
- Babel Types
- Babel Template
- Babel Generator
- Babel Plugin Handbook
- Babel 插件仓库(历史参考)
- npm Registry:babel-plugin-await-add-trycatch(历史包)
- Promises/A+ 与 Promise 文章的历史来源
作者:海阔_天空 原文链接:https://juejin.cn/post/7155434131831128094 来源:稀土掘金。