K 的一隅

JavaScript JavaScript 核心机制

try 包住了,为什么控制台还在报错:错误与异常处理

从客服工单升级说起,理清 throw、调用栈展开、async/await 中的 rejection,以及 unhandledrejection 与 Worker 错误。

12 分钟阅读 更新于 2026-07-28
本文目录

一线客服发现订单异常,可以当场挂起当前处理,把问题逐级报给主管;主管决定是退款、改派,还是记录后继续。若没人接单,用户端才会弹出“未处理异常”——那就是未捕获的错误。

上一章在 Proxy 的 trap 里可以 throw;本章把整条错误通路说清楚:同步时沿调用栈向上找 try...catch,异步时变成 Promise rejection 或宿主事件,不再经过原来的栈帧。

第一章已介绍 throwstack;第十章的 await 会把 rejection 转成可 catch 的异常;第十三章说 Worker 错误走 onerror。这一章把整条错误通路说清楚:同步时沿调用栈向上找 try...catch,异步时变成 Promise rejection 或宿主事件,不再经过原来的栈帧。下一章再把这些机制收成调试地图。

读的时候不妨打开浏览器控制台,把下面每个示例亲自跑一遍:同步错误看 Call Stack 面板,Promise 相关错误看是否出现 unhandledrejection。亲手对照一次,比记住“async 要用 try”这类口诀牢靠得多。

先猜输出:catch 之后,外层还会不会报错

js
function inner() {
  throw new Error('库存不足')
}

function outer() {
  try {
    inner()
  } catch (err) {
    console.log('主管接管', err.message)
  }
}

outer()
console.log('继续处理下一单')

输出:

text
主管接管 库存不足
继续处理下一单

throw 中断的是 inner 的正常返回路径,运行时沿调用栈向外找 handler。outercatch 接手后,错误被视为已处理,不会继续冒到全局。outer 之后的代码照常执行。

若删掉 try...catch,同一 throw 会变成未捕获异常,由宿主(浏览器控制台、Node 进程)报告,并可能中断当前任务中的后续脚本。

Error 对象与 stack:同步路径的上报单

js
function fetchUpstream() {
  throw new Error('上游超时')
}

function handleOrder() {
  fetchUpstream()
}

try {
  handleOrder()
} catch (err) {
  console.log(err instanceof Error) // true
  console.log(err.message)          // 上游超时
  console.log(typeof err.stack)     // string
}

Error 及其子类(TypeErrorRangeErrorReferenceError 等)携带 message;多数环境还提供 stack,列出异常抛出时仍活跃的调用路径,与第一章的调用栈面板一致。

catch (err) 绑定的可以是任意抛出值,不一定是 Error 实例:

js
throw '字面量错误'
throw { code: 'E_STOCK', detail: '缺货' }

但用 Error(或扩展子类)更利于统一日志、监控与 instanceof 分支。生产代码里避免 throw '字符串' 这种难以追溯的写法。

try / catch / finally:谁负责收拾现场

js
function serveTable(id) {
  let locked = true
  try {
    if (id === 0) throw new Error('桌号无效')
    return `开台 ${id}`
  } catch (err) {
    return `登记失败:${err.message}`
  } finally {
    locked = false
    console.log('锁已释放')
  }
}

console.log(serveTable(3))
console.log(serveTable(0))

finally 无论 try 里是正常返回还是 catch 接手,都会执行——常用于释放锁、关闭句柄。注意:若 try 里已经 returnfinally 仍运行,且 finally 里的 return 会覆盖 try/catch 的返回值,这类写法应尽量避免。

catch 只捕获同步执行路径上抛出的异常。把异步回调塞进 try 而不 await,回调内部的 throw 不会自动被外层 catch 接到。

async / await:rejection 与同步 throw 的汇合

js
async function fetchMenu() {
  throw new Error('网络断开')
}

async function openShop() {
  try {
    await fetchMenu()
  } catch (err) {
    console.log('async 捕获', err.message)
  }
}

openShop()

async 函数里同步 throw 等价于返回一个 rejected 的 Promise。await 一个 rejected Promise 时,会把 rejection 在 async 函数体内重新以同步 throw 的形式抛出,因此可以被同一个函数里的 try...catch 捕获。

对比 Promise 链:

js
fetchMenu().catch((err) => console.log('链式捕获', err.message))

语义相同,只是错误边界画在不同位置。第十章强调:await 后的代码通过微任务恢复;catch 块也在那次恢复之后执行,不会阻塞事件循环里的其他任务。

若要在多个 await 步骤中共享同一套处理逻辑,可以抽成辅助函数:

js
async function toUserMessage(err) {
  if (err?.code === 'E_NETWORK') return '网络异常,请稍后重试'
  if (err instanceof TypeError) return '数据格式有误'
  return '操作失败,请联系管理员'
}

async function loadDashboard() {
  try {
    const user = await fetchUser()
    const stats = await fetchStats(user.id)
    return { user, stats }
  } catch (err) {
    throw new Error(await toUserMessage(err), { cause: err })
  }
}

Errorcause 选项(ES2022)把原始错误挂在包装错误上,日志里既能展示用户文案,又能在 console 展开底层 stack

Promise.all 与 AggregateError

并行 await 时,第一个失败会让整个 Promise.all rejected:

js
const [a, b] = await Promise.all([
  fetchA(),
  fetchB(),
])

若希望“收集所有失败再汇报”,用 Promise.allSettled,或 Node / 现代浏览器里的 AggregateErrorPromise.any 全失败时也会抛出)。

js
try {
  await Promise.any([fetchMirror1(), fetchMirror2()])
} catch (err) {
  if (err instanceof AggregateError) {
    console.log('全部镜像失败', err.errors.length)
  }
}

设计 API 层时要想清楚:调用方是需要快速失败,还是尽量凑齐部分结果

生成器里的 throw:第八章的另一半

第八章说过 generator.throw(err) 会在暂停点注入异常。若生成器内部有 try/catch,可以恢复迭代;否则异常会离开生成器,变成调用方的同步 throw。

js
function* steps() {
  try {
  yield 1
  yield 2
  } catch (e) {
    console.log('生成器内捕获', e.message)
    yield 'recovered'
  }
}

const g = steps()
console.log(g.next())
console.log(g.throw(new Error('中断')))
console.log(g.next())

async 生成器(第九章)把同样语义搬到 for await...of 场景。读流式消费代码时,看到 throw 要分清:是同步栈、生成器协议,还是 Promise rejection。

事件处理器里的错误:try 包不住 addEventListener

js
button.addEventListener('click', () => {
  throw new Error('点击处理失败')
})

回调里的 throw 不会冒泡到注册监听器的外层 try...catch,因为注册时 try 早已执行完。浏览器会对该次任务报告未捕获异常;若需统一处理,在回调内部 try/catch,或使用 window.onerror / error 事件做最后一道网。

这与“async 函数返回后 rejection 无人接”是同一类问题:错误边界必须画在真正执行 throw 的那次调用附近

未处理的 Promise rejection:unhandledrejection

js
Promise.reject(new Error('没人接的订单'))

window.addEventListener('unhandledrejection', (event) => {
  console.log('全局兜底', event.reason?.message)
  event.preventDefault() // 部分环境可阻止默认的控制台报错
})

若 Promise 在 rejection 后始终没有 .catchawait 包裹,浏览器会派发 unhandledrejection;Node 会打印 UnhandledPromiseRejection 并可能以非零退出码结束进程(取决于版本与配置)。

常见疏漏:

js
async function broken() {
  throw new Error('漏网')
}

broken() // 没有 await,没有 .catch

调用 broken() 会立刻得到一个 rejected Promise;错误在 async 函数内部抛出,但外层没有消费者,于是变成未处理 rejection。修复方式:在调用处 awaittry/catch,或 broken().catch(handler)

第五章的微任务检查点意味着:同一轮事件循环里,若你先创建 rejection、又在稍后同一轮里补上 .catch,有时来得及避免 unhandledrejection。不要依赖这种竞态;应在创建异步工作的瞬间就设计好消费者。

Worker 与 messageerror:另一条线程的错误边界

第十三章提到:Worker 内未捕获异常通过 worker.onerror 传到主线程,不会自动变成主线程某个 Promise 的 rejection。

js
const worker = new Worker('worker.js', { type: 'module' })

worker.onerror = (event) => {
  console.log('Worker 错误', event.message)
}

worker.postMessage({ type: 'run' })

若你的封装是“发消息、等回复 Promise”,应在协议里显式传 { error } 或在 onerrorreject 挂起的 Promise,否则主线程的 try/catch 接不到 Worker 内部的 throw

messageerror 则多出现在结构化克隆失败时,与业务逻辑抛错是两类问题,别混在同一套文案里。

同步错误类型:ReferenceError 与 TypeError 各指什么

第一章见过 ReferenceError:在 let 的暂时性死区访问绑定、或使用未声明变量。TypeError 则多是“值存在,但操作不合法”:对 null 调函数、只读属性赋值、Proxy invariant 失败等。

js
const obj = Object.freeze({ x: 1 })
obj.x = 2 // TypeError(严格模式下对不可写属性赋值)

读栈信息时,先看 namemessage,再展开 stack。监控上报可把 name 作为粗分类,把 message 做脱敏后入库。

模块加载失败:第十四章的静态 import

静态 import 在求值阶段失败(404、语法错误)会阻止整个模块图继续,错误通常出现在控制台为 uncaught 的模块错误,而不是你业务函数里的 try/catch——因为 try 包不住尚未执行完的顶层 import

动态 import() 返回 Promise,可以 .catch

js
const mod = await import('./maybe-missing.js').catch((err) => {
  console.log('模块加载失败', err)
  return null
})

设计按需加载时,把“模块不存在”当成可恢复路径,就要走动态导入并在 Promise 层处理,而不是假设静态依赖一定存在。

容易踩的坑

误解一:try...catch 能包住 setTimeout 里的 throw。 不能。定时器回调是另一次调用;除非在回调内部自己 try/catch,或把逻辑改成 Promise/async 并在调用方 await

js
try {
  setTimeout(() => { throw new Error('晚到的错误') }, 0)
} catch {
  console.log('接不到')
}

上面 catch 永远不会执行。第四章的宏任务队列意味着回调在之后的任务里才运行,外层 try 早已结束。

误解二:.catch(() => {}) 吞掉错误就万事大吉。 错误不再未处理,但业务可能静默失败;至少应打日志或上报,并区分“可恢复”与“应中断”。

误解三:throwreturn 可以互换表示失败。 return 是正常控制流;throw 会跳过中间栈帧。用返回值表示预期失败(如 { ok: false })与用异常表示非预期失败,团队应有一致约定。

误解四:所有红色控制台信息都是 Error 浏览器还会报告 CSP 违规、资源加载失败、console.error 等;要读消息类型,不要一律当 try/catch 漏写。

误解五:在全局 unhandledrejectionpreventDefault 就等于修复了 bug。 只是抑制默认报告;业务状态可能仍停在半失败。应记录、上报,并尽量让用户可重试或回滚。

统一错误边界:前端项目里常见三层

  1. 调用点附近try/catch.catch,处理可恢复错误(表单校验、单次请求重试)。
  2. 模块 / 路由级:封装 fetch 或 RPC 客户端,把 HTTP 状态与业务码映射成 Error 子类。
  3. 进程 / 页面级unhandledrejectionwindow.onerror、Worker onerror,上报监控并展示兜底 UI。
js
function installGlobalHandlers(report) {
  window.addEventListener('unhandledrejection', (event) => {
    report(event.reason)
  })
  window.addEventListener('error', (event) => {
    report(event.error ?? event.message)
  })
}

层与层之间应用 cause 或自定义 code 传递上下文,避免最外层只剩一句 Error: 失败

第十一章流式 fetch 里若在 reader.read() 循环中 throw,外层 try/finally 仍应关闭 reader、释放锁——否则错误处理完了,资源却泄漏。错误路径与成功路径一样需要清理;finally 不是只给成功路径收尾的装饰。

js
async function readAll(stream) {
  const reader = stream.getReader()
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      consume(value)
    }
  } finally {
    reader.releaseLock()
  }
}

consume 抛错,栈会先到 catch(若有),finally 仍会执行。把“释放资源”放在 finally,比复制两份清理代码更稳。

重新抛出:何时包装,何时原样 throw

catch 里可以记录日志后 throw err 原样再抛,让上层决定 UI;也可以 throw new Error('友好文案', { cause: err }) 换一层。原则:

  • 可恢复、调用方已知如何处理的:在本地消化,不必上抛。
  • 调用方缺少上下文无法决策的:包装后上抛,附带 cause
  • 不应被吞掉的编程错误TypeError、断言失败):尽量原样抛出,方便开发环境断点。
js
try {
  await save()
} catch (err) {
  logger.error(err)
  if (err.code === 'E_VALIDATION') {
    showToast(err.message)
    return
  }
  throw err
}

避免 catch (err) { throw '失败了' } 这种丢掉栈的写法;若必须换类型,用 Errorcause 保留链条。

第十章里 Promise.allawait 组合时,只要其中一步 rejected,整个 try 块会进入 catch 一次。设计并行请求时,想清楚是“一步失败全失败”,还是逐步 try 并收集部分结果——两种策略对应的用户提示与重试逻辑完全不同。

试一试:画一张错误传播图

选你项目里的一段 async 请求代码,标出:

  1. 哪些 throwreject 会被本地 catch 接到;
  2. 哪些会变成 unhandledrejection
  3. 若抽到 Worker,错误从哪条边回到 UI。

再写一版最小修复:每个 async 调用点是否有明确的 catch 或向上抛出给统一错误处理器。

最后对照第一章:同步 throw 时打开调试器 Call Stack,再对照本章 async 示例里 rejection 的 stack 起点有何不同。把两条路径画在同一张纸上,以后看到控制台报错会先问“这是栈还是 Promise”。

还可以加一道小题:在 async function 最外层不写 try,却在内部 awaitthrow,观察未 await 该 async 函数时控制台出现的是未捕获异常还是 unhandledrejection。把观察结果写进团队 wiki 的“异步错误约定”里,新人 onboarding 会少踩很多坑。

排错时还可以对照一张最小清单:同步路径看 Call Stack 与 err.stack;异步路径先确认有没有人 await.catch;跨线程路径看 Worker 的 onerror / messageerror。三张入口对上以后,再决定是补本地处理、向上包装 cause,还是交给统一监控。把清单贴在显示器边,比死记“凡是 async 都要 try”更不容易漏。

若团队已有 Error Boundary 或全局 unhandledrejection 监听,试着在本地故意漏掉一次 catch,确认监控是否真能收到、收到的字段是否含 stack 与请求上下文。监控配置对了,生产上的“控制台还在报错”才会变成可检索的事件,而不是用户口头描述。

再补一条协作约定:谁引入了会吞掉错误的空 catch,谁就负责在评审里写清“为什么可以吞、吞了以后用户看到什么”。空 catch 不是语法错误,却是调试时最难追的坑——控制台安静了,业务状态却停在半路。

下一步:把机制收成调试地图

错误通路清楚之后,真正费时的往往不是“会不会写 try”,而是红字出现时第一眼点哪里。下一章专门练调试:给现象贴标签、读 Call Stack 与异步栈、用 Performance 找长任务,并把前十七章收成一张可展开的机制地图。

读第十八章时可以把本章的错误传播图放在旁边:先分清同步栈与 rejection,再决定开 Sources 还是去对整体系列的格子。