5

精读《捕获所有异步 error》

 3 years ago
source link: https://segmentfault.com/a/1190000040634549
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

精读《捕获所有异步 error》

成熟的产品都有较高的稳定性要求,仅前端就要做大量监控、错误上报,后端更是如此,一个未考虑的异常可能导致数据错误、服务雪崩、内存溢出等等问题,轻则每天焦头烂额的处理异常,重则引发线上故障。

假设代码逻辑没有错误,那么剩下的就是异常错误了。

由于任何服务、代码都可能存在外部调用,只要外部调用存在不确定性,代码就可能出现异常,所以捕获异常是一个非常重要的基本功。

所以本周就精读 How to avoid uncaught async errors in Javascript 这篇文章,看看 JS 如何捕获异步异常错误。

之所以要关注异步异常,是因为捕获同步异常非常简单:

try {
  ;(() => {
    throw new Error('err')
  })()
} catch (e) {
  console.log(e) // caught
}

但异步错误却无法被直接捕获,这不太直观:

try {
  ;(async () => {
    throw new Error('err') // uncaught
  })()
} catch (e) {
  console.log(e)
}

原因是异步代码并不在 try catch 上下文中执行,唯一的同步逻辑只有创建一个异步函数,所以异步函数内的错误无法被捕获。

要捕获 async 函数内的异常,可以调用 .catch,因为 async 函数返回一个 Promise:

;(async () => {
  throw new Error('err')
})().catch((e) => {
  console.log(e) // caught
})

当然也可以在函数体内直接用 try catch

;(async () => {
  try {
    throw new Error('err')
  } catch (e) {
    console.log(e) // caught
  }
})()

类似的,如果在循环体里捕获异常,则要使用 Promise.all

try {
  await Promise.all(
    [1, 2, 3].map(async () => {
      throw new Error('err')
    })
  )
} catch (e) {
  console.log(e) // caught
}

也就是说 await 修饰的 Promise 内抛出的异常,可以被 try catch 捕获。

但不是说写了 await 就一定能捕获到异常,一种情况是 Promise 内再包含一个异步:

new Promise(() => {
  setTimeout(() => {
    throw new Error('err') // uncaught
  }, 0)
}).catch((e) => {
  console.log(e)
})

这个情况要用 reject 方式抛出异常才能被捕获:

new Promise((res, rej) => {
  setTimeout(() => {
    rej('err') // caught
  }, 0)
}).catch((e) => {
  console.log(e)
})

另一种情况是,这个 await 没有被执行到:

const wait = (ms) => new Promise((res) => setTimeout(res, ms))

;(async () => {
  try {
    const p1 = wait(3000).then(() => {
      throw new Error('err')
    }) // uncaught
    await wait(2000).then(() => {
      throw new Error('err2')
    }) // caught
    await p1
  } catch (e) {
    console.log(e)
  }
})()

p1 等待 3s 后抛出异常,但因为 2s 后抛出了 err2 异常,中断了代码执行,所以 await p1 不会被执行到,导致这个异常不会被 catch 住。

而且有意思的是,如果换一个场景,提前执行了 p1,等 1s 后再 await p1,那异常就从无法捕获变成可以捕获了,这样浏览器会怎么处理?

const wait = (ms) => new Promise((res) => setTimeout(res, ms))

;(async () => {
  try {
    const p1 = wait(1000).then(() => {
      throw new Error('err')
    })
    await wait(2000)
    await p1
  } catch (e) {
    console.log(e)
  }
})()

结论是浏览器 1s 后会抛出一个未捕获异常,但再过 1s 这个未捕获异常就消失了,变成了捕获的异常。

这个行为很奇怪,当程序复杂时很难排查,因为并行的 Promise 建议用 Promise.all 处理:

await Promise.all([
  wait(1000).then(() => {
    throw new Error('err')
  }), // p1
  wait(2000),
])

另外 Promise 的错误会随着 Promise 链传递,因此建议把 Promise 内多次异步行为改写为多条链的模式,在最后 catch 住错误。

还是之前的例子,Promise 无法捕获内部的异步错误:

new Promise((res, rej) => {
  setTimeout(() => {
    throw Error('err')
  }, 1000) // 1
}).catch((error) => {
  console.log(error)
})

但如果写成 Promise Chain,就可以捕获了:

new Promise((res, rej) => {
  setTimeout(res, 1000) // 1
})
  .then((res, rej) => {
    throw Error('err')
  })
  .catch((error) => {
    console.log(error)
  })

原因是,用 Promise Chain 代替了内部多次异步嵌套,这样多个异步行为会被拆解为对应 Promise Chain 的同步行为,Promise 就可以捕获啦。

最后,DOM 事件监听内抛出的错误都无法被捕获:

document.querySelector('button').addEventListener('click', async () => {
  throw new Error('err') // uncaught
})

同步也一样:

document.querySelector('button').addEventListener('click', () => {
  throw new Error('err') // uncaught
})

只能通过函数体内 try catch 来捕获。

我们开篇提到了要监控所有异常,仅通过 try catchthen 捕获同步、异步错误还是不够的,因为这些是局部错误捕获手段,当我们无法保证所有代码都处理了异常时,需要进行全局异常监控,一般有两种方法:

  • window.addEventListener('error')
  • window.addEventListener('unhandledrejection')

error 可以监听所有同步、异步的运行时错误,但无法监听语法、接口、资源加载错误。而 unhandledrejection 可以监听到 Promise 中抛出的,未被 .catch 捕获的错误。

在具体的前端框架中,也可以通过框架提供的错误监听方案解决部分问题,比如 React 的 Error Boundaries、Vue 的 error handler,一个是 UI 组件级别的,一个是全局的。

回过头来看,本身 js 提供的 try catch 错误捕获是非常有效的,之所以会遇到无法捕获错误的经常,大多是因为异步导致的。

然而大部分异步错误,都可以通过 await 的方式解决,我们唯一要注意的是,await 仅支持一层,或者说一条链的错误监听,比如这个例子是可以监听到错误的:

try {
  await func1()
} catch (err) {
  // caught
}

async function func1() {
  await func2()
}

async function func2() {
  throw Error('error')
}

也就是说,只要这一条链内都被 await 住了,那么最外层的 try catch 就能捕获异步错误。但如果有一层异步又脱离了 await,那么就无法捕获了:

async function func2() {
  setTimeout(() => {
    throw Error('error') // uncaught
  })
}

针对这个问题,原文也提供了例如 Promise.all、链式 Promise、.catch 等方法解决,因此只要编写代码时注意对异步的处理,就可以用 try catch 捕获这些异步错误。

关于异步错误的处理,如果还有其它未考虑到的情况,欢迎留言补充。

讨论地址是:精读《捕获所有异步 error》· Issue #350 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK