解决异步编程的痛点:to错误边界方案(await-to-js诞生原因)

to() 错误边界方案 · 学习文档

别名:Go 风格错误处理 / await-to-js / Result 模式
适用阶段:阶段 3.2(错误捕获)的进阶补充


一、问题起源:为什么需要它

1. 三代错误处理的演进

// ① 回调时代:error-first(Node 约定)
fs.readFile('./a.txt', (err, data) => {
  if (err) return handle(err)
  use(data)
})

// ② Promise 时代:.catch
fetchList().then(use).catch(handle)

// ③ async/await 时代:try/catch
try {
  const data = await fetchList()
  use(data)
} catch (e) {
  handle(e)
}

2. try/catch 在多步 await 下的三个痛点

async function loadPage(id) {
  try {
    const user   = await getUser(id)         // ①
    const orders = await getOrders(user.id)  // ②
    const detail = await getDetail(orders)   // ③
    render(detail)
  } catch (e) {
    message.error('加载失败')                 // 😖 三个问题:
  }
}
痛点 说明
① 分不清是谁错 ①②③ 的异常都进同一个 catch,无法区分来源
② 无法部分降级 ①失败就直接短路,②③ 不执行;想"订单拉不到就显示空"做不到
③ 缩进与割裂 每层套 try 就缩进爆炸;或者用 let 在外层声明变量再赋值,代码被扯散
// 痛点 ③ 的真实写法:变量被提到外层,逻辑被切断
let user, orders
try { user = await getUser(id) } catch (e) { user = null }
try { orders = await getOrders(user?.id) } catch (e) { orders = [] }

二、to() 是什么

把 Promise 的"抛异常"转换成"返回值":[error, data] 二元组(Go 语言 v, err := f() 的思路)。

// 实现(TS 版)
export type SafeResult<T> = [Error, null] | [null, T]

export function to<T>(promise: Promise<T>): Promise<SafeResult<T>> {
  return promise
    .then((data): SafeResult<T> => [null, data])
    .catch((err): SafeResult<T> => [err instanceof Error ? err : new Error(String(err)), null])
}

用法:不抛错了,改成让你"必须看见"错误。

const [err, user] = await to(getUser(id))
if (err) {
  // 处理 / 降级 / 返回
  return
}
// 这里 user 一定有值,且没有缩进

三、用法示例

基础:单步

const [err, res] = await to(findPage(params))
if (err) return message.error('查询失败')
tableData.value = res.data.records

多步:每步可独立降级 ⭐

async function loadPage(id: string) {
  const [uErr, user] = await to(getUser(id))
  if (uErr) return message.error('用户信息加载失败')   // 致命错误:中止

  const [oErr, orders] = await to(getOrders(user.id))
  const list = oErr ? [] : orders                      // 非致命:降级为空

  const [dErr, detail] = await to(getDetail(list))
  if (dErr) return message.error('详情加载失败')

  render({ user, list, detail })
}
  • 没有一层缩进
  • 每个错误都能单独决定:中止 / 降级 / 忽略

并行:配合 Promise.all

const [err, [user, orders]] = await to(Promise.all([getUser(id), getOrders(id)]))
if (err) { /* 任一失败都会到这里 */ }

// 或者逐个兜底(永不失败)
const [u, o] = await Promise.all([
  to(getUser(id)),
  to(getOrders(id)),
])
const user = u[1] ?? defaultUser
const orders = o[1] ?? []

四、四种方案横向对比

方案 代码形态 能区分错误来源 能部分降级 缩进 适用场景
一个大 try try { a; b; c } catch {} ❌ ❌ 1 层 失败即整体失败
分别 try/catch 每段一个 try ✅ ✅ N 层 / 变量外提 步骤少
to() 二元组 const [e, d] = await to(p) ✅ ✅ 0 层 多步、需降级 ⭐
.catch() 兜底值 p.catch(() => 默认值) ❌ ✅ 0 层 只要默认值,不关心错误

五、优点(为什么要用)

  1. 消灭缩进地狱:await 全部平铺,不用层层 try。
  2. 错误可区分:每个 err 对应一个具体步骤,定位快。
  3. 支持部分降级:某一步失败可以继续往下走(订单失败仍渲染用户)。
  4. 强制你处理错误:解构出 err 就在眼前,不像 catch 可以被遗忘。
  5. TS 类型友好:if (err) return 之后 data 收窄为非空(配合 [Error, null] | [null, T] 判别联合)。
  6. 不吞异常:错误以返回值形式存在,可以选择继续抛、上报或忽略。
  7. 易组合:可以包在重试、loading、埋点里(await to(retry(fn, 3)))。
  8. 与 Go / Rust 生态一致:[err, data] ≈ Go 的 v, err,Result ≈ Rust 的 Result<T, E>,心智负担低。

六、缺点与代价(必须知道)

缺点 说明 / 规避
每个都要 if (err) 比 try/catch 多几行样板;简单场景反而啰嗦
可能忘记判断 不解构 err 直接就用 data → 拿到 null;建议 ESLint 约束或改成 Result 对象风格
异常语义被弱化 错误变成普通值,容易"忘记向上抛";团队要约定哪些错必须抛
丢失 throw 的位置语义 不像 throw 那样天然有调用栈语义(原始 err.stack 仍在,可用 cause 补充上下文)
需要额外函数/依赖 自己实现 10 行,或用 await-to-js
默认值陷阱 失败返回 null 时,const [e, data = []] = ... 不会生效(默认值只对 undefined 生效)⚠️
// ⚠️ 陷阱演示
const [err, data = []] = await to(getList())
// 失败时 data === null(不是 undefined),默认值不生效 → 后续 data.map 报错

// ✅ 正确
const list = data ?? []

七、什么时候用 / 不用

需要多步 await?
├─ 否(单个请求,失败即中断)
│   └─ 用 try/catch 或 .catch(),最直接
└─ 是
    ├─ 任一步失败就整体失败?
    │   └─ 一个大 try/catch 就够
    ├─ 需要区分是哪一步失败 / 需要部分降级?
    │   └─ ✅ 用 to()
    └─ 只要默认值,完全不关心错误?
        └─ 用 p.catch(() => 默认值)

八、TypeScript 增强版

1. 标准二元组(推荐)

export type SafeResult<T> = [Error, null] | [null, T]

export function to<T>(promise: Promise<T>): Promise<SafeResult<T>> {
  return promise.then(
    (data): SafeResult<T> => [null, data],
    (err): SafeResult<T> => [err instanceof Error ? err : new Error(String(err)), null],
  )
}

2. 带上下文包装(保留 cause)

export async function toWith<T>(promise: Promise<T>, context: string): Promise<SafeResult<T>> {
  try {
    return [null, await promise]
  } catch (e) {
    return [new Error(`${context}失败`, { cause: e }), null]
  }
}

const [err, user] = await toWith(getUser(id), '加载用户')
// err.message = '加载用户失败',err.cause = 原始错误

3. Result 对象风格(更不容易漏判)

export type Result<T> = { ok: true; data: T } | { ok: false; error: Error }

export async function safe<T>(p: Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, data: await p }
  } catch (error) {
    return { ok: false, error: error as Error }
  }
}

const r = await safe(getUser(id))
if (!r.ok) return handle(r.error)
use(r.data)      // TS 自动收窄,r.data 一定存在

二元组 [err, data] 可能"忘了判断";Result 对象 {ok} 必须判断才能取 data,更安全。

4. 批量版

export function toAll<T extends readonly unknown[]>(
  promises: T,
): Promise<{ [K in keyof T]: SafeResult<Awaited<T[K]>> }> {
  return Promise.all(promises.map(p => to(p as Promise<any>))) as any
}

const [u, o] = await toAll([getUser(id), getOrders(id)] as const)

九、与重试 / loading 组合

async function loadWithRetry() {
  loading.value = true
  const [err, data] = await to(retry(() => findPage(params), 3))
  loading.value = false

  if (err) return message.error('查询失败,已重试 3 次')
  tableData.value = data.data.records
}

十、常见坑清单

  1. 默认值陷阱:失败返回 null,解构默认值 不生效 → 用 ?? 默认值。
  2. 忘记 if (err):拿到 null 继续用 → 建议用 Result 对象风格或加 ESLint 规则。
  3. 吞掉错误上报:if (err) return 之前记得 report(err)。
  4. to() 自身也要 await:const r = to(p) 得到的是 Promise,必须 await to(p)。
  5. 不要混用:同一段逻辑里别一会儿 to() 一会儿 try/catch,团队统一风格。
  6. Promise.all 里用 to 要逐个包:Promise.all([to(a), to(b)]),而不是 to(Promise.all([a, b]))(后者任一失败整体失败)。

十一、自检 / 练习

Q1:为什么 to() 能避免缩进地狱?

因为它把"控制流式的异常"转成"数据流式的返回值",不再需要嵌套块级作用域。

Q2:const [err, data = []] = await to(getList()),请求失败时 data 是什么?

null(默认值只对 undefined 生效)→ 后续要用 data ?? []。

Q3:什么时候不该用 to()?

单请求且失败即中断、或完全不关心错误只要默认值时,用 try/catch / .catch() 更简洁。

练习:

  1. 把下面代码改成 to() 写法,要求:用户失败则中止,订单失败降级为 [],详情失败只上报不阻断。
    try {
      const user = await getUser(id)
      const orders = await getOrders(user.id)
      const detail = await getDetail(orders)
      render({ user, orders, detail })
    } catch (e) { message.error('失败') }
    
  2. 实现 toWith(promise, context),失败时用 { cause } 保留原始错误。
  3. 用 Result 对象风格重写练习 1,体会"不判断就拿不到 data"的类型约束。

参考

  • await-to-js(npm,7 行实现的 [err, data])
  • neverthrow、effect(函数式 Result<T, E>)
  • Go 错误处理 v, err := f();Rust Result<T, E>
posted @ 2026-10-14 09:51  寅信  阅读(5)  评论(0)    收藏  举报