处理可能超时的异步操作
自从 ECMAScript 的 Promise ES2015 和 async/await ES2017 特性发布以后,异步在前端界已经成为特别常见的操作。异步代码和同步代码在处理问题顺序上会存在一些差别,编写异步代码需要拥有跟编写同步代码不同的“意识”。
本文要聊的问题可能仍然“无趣”,但很现实 —— 如果一段代码久久不能执行完成,会怎么样?
如果这是同步代码,我们会看到一种叫做“无响应”的现象,或者通俗地说 —— “死掉了”;但是如果是一段异步代码呢?可能我们等不到结果,但别的代码仍在继续,就好像这件事情没有发生一般。
当然事情并不是真的没发生,只不过在不同的情况下会产生不同的现象。比如有加载动画的页面,看起来就是一直在加载;又比如应该进行数据更新的页面,看不到数据变化;再比如一个对话框,怎么也关不掉 …… 这些现象我们统称为 BUG。但也有一些时候,某个异步操作过程并没有“回显”,它就默默地死在那里,没有人知道,待页面刷新之后,就连一点遗迹都不会留下。
Axios 自带超时处理使用 Axios 进行 Web Api 调用就是一种常见的异步操作过程。通常我们的代码会这样写:
try {const res = await axios.get(url, options);// TODO 正常进行后续业务} catch(err) {// TODO 进行容错处理,或者报错}这段代码一般情况下都执行良好,直到有一天用户抱怨说:怎么等了半天没反应?
然后开发者意识到,由于服务器压力增大,这个请求已经很难瞬时响应了。考虑到用户的感受,加了一个 loading 动画:
try {showLoading();const res = await axios.get(url, options);// TODO 正常业务} catch (err) {// TODO 容错处理} finally {hideLoading();}然而有一天,有用户说:“我等了半个小时,居然一直在那转圈圈!”于是开发者意识到,由于某种原因,请求被卡死了,这种情况下应该重发请求,或者直接报告给用户 —— 嗯,得加个超时检查。
幸运的是 Axios 确实可以处理超时,只需要在 options 里添加一个 timeout: 3000 就能解决问题。如果超时,可以在 catch 块中检测并处理:
try {...}catch (err) {if (err.isAxiosError && !err.response && err.request&& err.message.startsWith("timeout")) {// 如果是 Axios 的 request 错误,并且消息是延时消息// TODO 处理超时}}finally {...}Axios 没问题了,如果用 fetch() 呢?
处理 fetch() 超时 fetch() 自己不具备处理超时的能力,需要我们判断超时后通过 AbortController 来触发“取消”请求操作。
如果需要中断一个 fetch() 操作,只需从一个 AbortController 对象获取 signal,并将这个信号对象作为 fetch() 的选项传入。大概就是这样:
const ac = new AbortController();const { signal } = ac;fetch(url, { signal }).then(res => {// TODO 处理业务});
// 1 秒后取消 fetch 操作 setTimeout(() => ac.abort(), 1000);ac.abort() 会向 signal 发送信号,触发它的 abort 事件,并将其 .aborted 属性置为 true。fetch() 内部处理会利用这些信息中止掉请求。
上面这个示例演示了如何实现 fetch() 操作的超时处理。前端培训如果使用 await 的形式来处理,需要把 setTimeout(...) 放在 fetch(...) 之前:
const ac = new AbortController();const { signal } = ac;setTimeout(() => ac.abort(), 1000);const res = await fetch(url, { signal }).catch(() => undefined);为了避免使用 try ... catch ... 来处理请求失败,这里在 fetch() 后加了一个 .catch(...) 在忽略错误的情况。如果发生错误,res 会被赋值为 undefined。实际的业务处理可能需要更合理的 catch() 处理来让 res 包含可识别的错误信息。
本来到这里就可以结束了,但是对每一个 fetch() 调用都写这么长一段代码,会显得很繁琐,不如封装一下:
async function fetchWithTimeout(timeout, resoure, init = {}) {const ac = new AbortController();const signal = ac.signal;setTimeout(() => ac.abort(), timeout);return fetch(resoure, { ...init, signal });}没问题了吗?不,有问题。
如果我们在上述代码的 setTimeout(...) 里输出一条信息:
setTimeout(() => {console.log("It's timeout");ac.abort();}, timeout);并且在调用的给一个足够的时间:
fetchWithTimeout(5000, url).then(res => console.log("success"));我们会看到输出 success,并在 5 秒后看到输出 It's timeout。
对了,我们虽然为 fetch(...) 处理了超时,但是并没有在 fetch(...) 成功的情况下干掉 timer。作为一个思维缜密的程序员,怎么能够犯这样的错误呢?干掉他!
async function fetchWithTimeout(timeout, resoure, init = {}) {const ac = new AbortController();const signal = ac.signal;
}完美!但问题还没结束。
万物皆可超时 Axios 和 fetch 都提供了中断异步操作的途径,但对于一个不具备 abort 能力的普通 Promise 来说,该怎么办?
对于这样的 Promise,我只能说,让他去吧,随便他去干到天荒地老 —— 反正我也没办法阻止。但生活总得继续,我不能一直等啊!
这种情况下我们可以把 setTimeout() 封装成一个 Promise,然后使用 Promise.race() 来实现“过时不候”:
race 是竞速的意思,所以 Promise.race() 的行为是不是很好理解?function waitWithTimeout(promise, timeout, timeoutMessage = "timeout") {let timer;const timeoutPromise = new Promise((_, reject) => {timer = setTimeout(() => reject(timeoutMessage), timeout);});
}可以写一个 Timeout 来模拟看看效果:
(async () => {const business = new Promise(resolve => setTimeout(resolve, 1000 * 10));
})();至于如何写可以中止的异步操作,下次再聊。
评论