技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

如何给所有的 async 函数添加 try/catch?

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/92-如何给所有的async函数添加try catch?
本文目录15 个章节
  1. 前言
  2. async 不加 try/catch 会发生什么?
  3. Babel 插件的最终效果
  4. Babel 插件的实现思路
  5. Babel 的核心:AST
  6. await 节点对应的 AST
  7. Babel 插件开发
  8. 一个可运行思路的 Babel 7 CJS 核心实现
  9. 已有 try/catch 时如何处理?
  10. 获取文件路径和方法名
  11. 用户选项
  12. Babel 版本与安装
  13. 最小验证矩阵
  14. 总结
  15. 参考资料

如何给所有的 async 函数添加 try/catch

Category(分类): JavaScript Status: 已整理

前言

阿里三面时被问到“如何给所有的 async 函数添加 try/catch”。这个问题既可以考察 Promise 错误传播,也可以考察 AST 和 Babel 插件开发。

本文保留原文的 AST 学习主线,但先给出一个重要边界:不存在一个只靠给 async 函数包一层 try/catch 就能捕获所有异步错误的方案。

  • async 函数调用总是返回 Promise;函数体中未捕获的同步异常和 await 到的 rejection 会使这个 Promise rejected。
  • 没有 awaitthrowreturn Promise.reject(error) 同样会 rejected,不能只遍历 AwaitExpression 来判断“所有错误”。
  • 没有被 await/return/.catch() 接住的 detached Promise,外层 try/catch 不会自动捕获。
  • setTimeout、事件监听器等稍后执行的回调也不在当前 try 的同步范围内。
  • 自动生成的 catch 如果只打印而不重新抛出,会把 rejected Promise 变成 fulfilled undefined,改变调用方的错误契约。

因此,本文实现将目标描述为:用 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 插件的实现思路

  1. 使用 Babel parser 将源代码转换为 AST;
  2. 访问 async 函数节点,而不是只访问 AwaitExpression
  3. 找到函数体,并处理 async () => expression 这种表达式箭头函数;
  4. 判断当前函数是否已经存在本插件生成的完整包装,保证重复编译不会不断套娃;
  5. 创建 try/catch AST,把原函数体放入 try
  6. catch 中记录稳定的函数名和可选位置,默认重新抛出原错误;
  7. 使用 Babel 的 NodePath API 替换节点,并对函数声明、函数表达式、箭头函数、对象方法、类方法和私有方法进行测试。

插件只负责静态转换。它无法捕获运行时动态 eval 中未转换的代码,也不能把 detached Promise、定时器和事件回调神奇地变成当前函数的同步异常。

Babel 的核心:AST

AST(Abstract Syntax Tree,抽象语法树)是代码结构的树形表示。常见流程可以概括为:

  1. 词法分析:把字符串拆成 token;
  2. 语法分析:根据语法规则建立节点和父子关系;
  3. 转换:访问、创建、替换 AST 节点;
  4. 生成:把 AST 输出为 JavaScript 代码。

例如:

function demo(n) {
  return n * n
}

在 Babel AST 中大致会出现 ProgramFunctionDeclarationIdentifierBlockStatementReturnStatementBinaryExpression 等节点。具体字段由 Babel 版本和 parser 选项决定,Babel AST 与 ESTree 并不完全相同。

原文配图:AST 结构示意图

常用 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() {} }
AwaitExpressionawait 表达式await request()
BlockStatement块语句{ ... }
TryStatementtry 语句try {} catch {}
CatchClausecatch 分支catch (error) {}
ThrowStatementthrow 语句throw error
CallExpression调用表达式console.error(error)
MemberExpression成员表达式console.error
StringLiteral字符串字面量'Error'
NumericLiteral数字字面量100

Literal 是 ESTree 中常见的总称;在 Babel AST 中通常拆成 StringLiteralNumericLiteralBooleanLiteral 等,不能把两种 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 可按目标代码选择 scriptcommonjsmoduleunambiguous;顶层 await、TypeScript、JSX 等语法还需要对应 parser 插件或配置。

await 节点对应的 AST

原始代码:

async function fn() {
  await request()
}

其中 await request()AwaitExpression,其 argumentCallExpression。包装后:

async function fn() {
  try {
    await request()
  } catch (error) {
    console.error(error)
    throw error
  }
}

原文配图:await AST 示意图

原文配图:try/catch AST 示意图

不过,插件不能只寻找 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

原文配图:async 函数父路径示意图

表达式箭头函数的特殊情况

下面的函数体是表达式,不是 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
  • 处理最近的函数节点,不用任意 TryStatement ancestor 跨越函数边界;
  • 默认 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-templatebabel.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 更重要;默认保留错误传播、明确处理边界、补充测试,才是可维护的实现。

原文配图:Babel 错误输出示例

原文配图:转换结果示例

原文配图:AST 结构历史截图

原文配图:插件安装历史截图

配图说明:图片按原文顺序从掘金 CDN 下载到 images/92-image-*。图片是历史文章截图,不是 Babel 官方规范图,原始授权未从页面确认;公开发布前应核实授权或替换为自制图。

参考资料

作者:海阔_天空 原文链接:https://juejin.cn/post/7155434131831128094 来源:稀土掘金。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS