|
| 1 | +成熟的产品都有较高的稳定性要求,仅前端就要做大量监控、错误上报,后端更是如此,一个未考虑的异常可能导致数据错误、服务雪崩、内存溢出等等问题,轻则每天焦头烂额的处理异常,重则引发线上故障。 |
| 2 | + |
| 3 | +假设代码逻辑没有错误,那么剩下的就是异常错误了。 |
| 4 | + |
| 5 | +由于任何服务、代码都可能存在外部调用,只要外部调用存在不确定性,代码就可能出现异常,所以捕获异常是一个非常重要的基本功。 |
| 6 | + |
| 7 | +所以本周就精读 [How to avoid uncaught async errors in Javascript](https://advancedweb.hu/how-to-avoid-uncaught-async-errors-in-javascript/) 这篇文章,看看 JS 如何捕获异步异常错误。 |
| 8 | + |
| 9 | +## 概述 |
| 10 | + |
| 11 | +之所以要关注异步异常,是因为捕获同步异常非常简单: |
| 12 | + |
| 13 | +```typescript |
| 14 | +try { |
| 15 | + ;(() => { |
| 16 | + throw new Error('err') |
| 17 | + })() |
| 18 | +} catch (e) { |
| 19 | + console.log(e) // caught |
| 20 | +} |
| 21 | +``` |
| 22 | + |
| 23 | +但异步错误却无法被直接捕获,这不太直观: |
| 24 | + |
| 25 | +```typescript |
| 26 | +try { |
| 27 | + ;(async () => { |
| 28 | + throw new Error('err') // uncaught |
| 29 | + })() |
| 30 | +} catch (e) { |
| 31 | + console.log(e) |
| 32 | +} |
| 33 | +``` |
| 34 | + |
| 35 | +原因是异步代码并不在 `try catch` 上下文中执行,唯一的同步逻辑只有创建一个异步函数,所以异步函数内的错误无法被捕获。 |
| 36 | + |
| 37 | +要捕获 `async` 函数内的异常,可以调用 `.catch`,因为 `async` 函数返回一个 Promise: |
| 38 | + |
| 39 | +```typescript |
| 40 | +;(async () => { |
| 41 | + throw new Error('err') |
| 42 | +})().catch((e) => { |
| 43 | + console.log(e) // caught |
| 44 | +}) |
| 45 | +``` |
| 46 | + |
| 47 | +当然也可以在函数体内直接用 `try catch`: |
| 48 | + |
| 49 | +```typescript |
| 50 | +;(async () => { |
| 51 | + try { |
| 52 | + throw new Error('err') |
| 53 | + } catch (e) { |
| 54 | + console.log(e) // caught |
| 55 | + } |
| 56 | +})() |
| 57 | +``` |
| 58 | + |
| 59 | +类似的,如果在循环体里捕获异常,则要使用 `Promise.all`: |
| 60 | + |
| 61 | +```typescript |
| 62 | +try { |
| 63 | + await Promise.all( |
| 64 | + [1, 2, 3].map(async () => { |
| 65 | + throw new Error('err') |
| 66 | + }) |
| 67 | + ) |
| 68 | +} catch (e) { |
| 69 | + console.log(e) // caught |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +也就是说 `await` 修饰的 Promise 内抛出的异常,可以被 `try catch` 捕获。 |
| 74 | + |
| 75 | +但不是说写了 `await` 就一定能捕获到异常,一种情况是 Promise 内再包含一个异步: |
| 76 | + |
| 77 | +```typescript |
| 78 | +new Promise(() => { |
| 79 | + setTimeout(() => { |
| 80 | + throw new Error('err') // uncaught |
| 81 | + }, 0) |
| 82 | +}).catch((e) => { |
| 83 | + console.log(e) |
| 84 | +}) |
| 85 | +``` |
| 86 | + |
| 87 | +这个情况要用 `reject` 方式抛出异常才能被捕获: |
| 88 | + |
| 89 | +```typescript |
| 90 | +new Promise((res, rej) => { |
| 91 | + setTimeout(() => { |
| 92 | + rej('err') // caught |
| 93 | + }, 0) |
| 94 | +}).catch((e) => { |
| 95 | + console.log(e) |
| 96 | +}) |
| 97 | +``` |
| 98 | + |
| 99 | +另一种情况是,这个 `await` 没有被执行到: |
| 100 | + |
| 101 | +```typescript |
| 102 | +const wait = (ms) => new Promise((res) => setTimeout(res, ms)) |
| 103 | + |
| 104 | +;(async () => { |
| 105 | + try { |
| 106 | + const p1 = wait(3000).then(() => { |
| 107 | + throw new Error('err') |
| 108 | + }) // uncaught |
| 109 | + await wait(2000).then(() => { |
| 110 | + throw new Error('err2') |
| 111 | + }) // caught |
| 112 | + await p1 |
| 113 | + } catch (e) { |
| 114 | + console.log(e) |
| 115 | + } |
| 116 | +})() |
| 117 | +``` |
| 118 | + |
| 119 | +`p1` 等待 3s 后抛出异常,但因为 2s 后抛出了 `err2` 异常,中断了代码执行,所以 `await p1` 不会被执行到,导致这个异常不会被 catch 住。 |
| 120 | + |
| 121 | +而且有意思的是,如果换一个场景,提前执行了 `p1`,等 1s 后再 `await p1`,那异常就从无法捕获变成可以捕获了,这样浏览器会怎么处理? |
| 122 | + |
| 123 | +```typescript |
| 124 | +const wait = (ms) => new Promise((res) => setTimeout(res, ms)) |
| 125 | + |
| 126 | +;(async () => { |
| 127 | + try { |
| 128 | + const p1 = wait(1000).then(() => { |
| 129 | + throw new Error('err') |
| 130 | + }) |
| 131 | + await wait(2000) |
| 132 | + await p1 |
| 133 | + } catch (e) { |
| 134 | + console.log(e) |
| 135 | + } |
| 136 | +})() |
| 137 | +``` |
| 138 | + |
| 139 | +结论是浏览器 1s 后会抛出一个未捕获异常,但再过 1s 这个未捕获异常就消失了,变成了捕获的异常。 |
| 140 | + |
| 141 | +这个行为很奇怪,当程序复杂时很难排查,因为并行的 Promise 建议用 Promise.all 处理: |
| 142 | + |
| 143 | +```typescript |
| 144 | +await Promise.all([ |
| 145 | + wait(1000).then(() => { |
| 146 | + throw new Error('err') |
| 147 | + }), // p1 |
| 148 | + wait(2000), |
| 149 | +]) |
| 150 | +``` |
| 151 | + |
| 152 | +另外 Promise 的错误会随着 Promise 链传递,因此建议把 Promise 内多次异步行为改写为多条链的模式,在最后 `catch` 住错误。 |
| 153 | + |
| 154 | +还是之前的例子,Promise 无法捕获内部的异步错误: |
| 155 | + |
| 156 | +```typescript |
| 157 | +new Promise((res, rej) => { |
| 158 | + setTimeout(() => { |
| 159 | + throw Error('err') |
| 160 | + }, 1000) // 1 |
| 161 | +}).catch((error) => { |
| 162 | + console.log(error) |
| 163 | +}) |
| 164 | +``` |
| 165 | + |
| 166 | +但如果写成 Promise Chain,就可以捕获了: |
| 167 | + |
| 168 | +```typescript |
| 169 | +new Promise((res, rej) => { |
| 170 | + setTimeout(res, 1000) // 1 |
| 171 | +}) |
| 172 | + .then((res, rej) => { |
| 173 | + throw Error('err') |
| 174 | + }) |
| 175 | + .catch((error) => { |
| 176 | + console.log(error) |
| 177 | + }) |
| 178 | +``` |
| 179 | + |
| 180 | +原因是,用 Promise Chain 代替了内部多次异步嵌套,这样多个异步行为会被拆解为对应 Promise Chain 的同步行为,Promise 就可以捕获啦。 |
| 181 | + |
| 182 | +最后,DOM 事件监听内抛出的错误都无法被捕获: |
| 183 | + |
| 184 | +```typescript |
| 185 | +document.querySelector('button').addEventListener('click', async () => { |
| 186 | + throw new Error('err') // uncaught |
| 187 | +}) |
| 188 | +``` |
| 189 | + |
| 190 | +同步也一样: |
| 191 | + |
| 192 | +```typescript |
| 193 | +document.querySelector('button').addEventListener('click', () => { |
| 194 | + throw new Error('err') // uncaught |
| 195 | +}) |
| 196 | +``` |
| 197 | + |
| 198 | +只能通过函数体内 `try catch` 来捕获。 |
| 199 | + |
| 200 | +## 精读 |
| 201 | + |
| 202 | +我们开篇提到了要监控所有异常,仅通过 `try catch`、`then` 捕获同步、异步错误还是不够的,因为这些是局部错误捕获手段,当我们无法保证所有代码都处理了异常时,需要进行全局异常监控,一般有两种方法: |
| 203 | + |
| 204 | +- `window.addEventListener('error')` |
| 205 | +- `window.addEventListener('unhandledrejection')` |
| 206 | + |
| 207 | +`error` 可以监听所有同步、异步的运行时错误,但无法监听语法、接口、资源加载错误。而 `unhandledrejection` 可以监听到 Promise 中抛出的,未被 `.catch` 捕获的错误。 |
| 208 | + |
| 209 | +在具体的前端框架中,也可以通过框架提供的错误监听方案解决部分问题,比如 React 的 [Error Boundaries](https://reactjs.org/docs/error-boundaries.html)、Vue 的 [error handler](https://v3.vuejs.org/api/application-config.html#errorhandler),一个是 UI 组件级别的,一个是全局的。 |
| 210 | + |
| 211 | +回过头来看,本身 js 提供的 `try catch` 错误捕获是非常有效的,之所以会遇到无法捕获错误的经常,大多是因为异步导致的。 |
| 212 | + |
| 213 | +然而大部分异步错误,都可以通过 `await` 的方式解决,我们唯一要注意的是,`await` 仅支持一层,或者说一条链的错误监听,比如这个例子是可以监听到错误的: |
| 214 | + |
| 215 | +```typescript |
| 216 | +try { |
| 217 | + await func1() |
| 218 | +} catch (err) { |
| 219 | + // caught |
| 220 | +} |
| 221 | + |
| 222 | +async function func1() { |
| 223 | + await func2() |
| 224 | +} |
| 225 | + |
| 226 | +async function func2() { |
| 227 | + throw Error('error') |
| 228 | +} |
| 229 | +``` |
| 230 | + |
| 231 | +也就是说,只要这一条链内都被 `await` 住了,那么最外层的 `try catch` 就能捕获异步错误。但如果有一层异步又脱离了 `await`,那么就无法捕获了: |
| 232 | + |
| 233 | +```typescript |
| 234 | +async function func2() { |
| 235 | + setTimeout(() => { |
| 236 | + throw Error('error') // uncaught |
| 237 | + }) |
| 238 | +} |
| 239 | +``` |
| 240 | + |
| 241 | +针对这个问题,原文也提供了例如 `Promise.all`、链式 Promise、`.catch` 等方法解决,因此只要编写代码时注意对异步的处理,就可以用 `try catch` 捕获这些异步错误。 |
| 242 | + |
| 243 | +## 总结 |
| 244 | + |
| 245 | +关于异步错误的处理,如果还有其它未考虑到的情况,欢迎留言补充。 |
| 246 | + |
| 247 | +> 讨论地址是:[精读《捕获所有异步 error》· Issue #350 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/350) |
| 248 | +
|
| 249 | +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** |
| 250 | + |
| 251 | +> 关注 **前端精读微信公众号** |
| 252 | +
|
| 253 | +<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> |
| 254 | + |
| 255 | +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |
0 commit comments