浅析如何实现一个可取消的异步 HTTP 请求模块
一、问题背景
异步 HTTP 请求在现代 web 应用中可以说是随处可见。为了更好的用户体验,05 年出现了 Ajax,支持不刷新页面实现局部更新。
Ajax 支持同步和异步两种方式,但是大家基本上只用异步方法,因为发送同步请求会让浏览器进入暂时性的假死状态,特别是请求需要处理大数据量、长时间等待的接口,这种情况下采用同步请求,会带来非常不好的用户体验。
所以大家普遍都采用异步请求的方式,于是就有了今天的话题,你可能需要一个可取消的异步 HTTP 请求。大家可以思考以下几个问题:
1、为什么需要可取消的异步 HTTP 请求?
我们经常会遇到发送了某个 HTTP 请求,在等待接口响应的过程中突然不需要其结果的情形。
2、在什么场景下会用到?
比如页面上有多个 Tab 标签,点击每个标签发送对应的 HTTP 请求,然后将请求结果显示在内容区。现在,用户在操作时,点了 Tab1 标签,得到了接口 1 的请求结果,但是在点了 Tab2 后由于接口需要等待 3s 才能返回,用户不想等了,直接点了 Tab3,这时 Tab2 的接口的返回结果就不再需要了。
3、它有什么用?
这时如果你不取消 Tab2 的请求,内容区就会出现这样的现象:首先显示 Tab3 接口的结果,然后等一下( 0 <= waitTime <= 3 )内容去又变成了 Tab2 的数据,这时就发现,如果不取消 Tab2 发送的异步 HTTP 请求就有问题了。
我们用一个动画进行对上述过程的一个演示,可以帮助大家更加清晰的理解这个场景。第一种情况正常操作,在点击 Tab 2 按钮时,等待接口返回后才继续点击 Tab 3,这种很简单,没必要演示。
但第二种情况就可能出现问题,下面这个动画演示了这个异常现象,点击 Tab 2 后没等接口返回直接点击 Tab 3,发现内容去先显示 Tab 3 接口的内容,然后又变成了 Tab 2 接口的结果。
这样的需求和场景在现代 web 应用开发中可以说是非常常见了。只是大家平时可能不太会意识到这里会有问题,因为这个 bug 这只存在于需要经过长时间等待才可以得到响应结果的接口(比如 Tab 2),所以如果你的应用存在接口需要处理和传输大量数据或者应用在弱网环境下使用时,就可能会遇到这个问题。所以一个成熟、稳定的 web 应用必须支持可取消的异步 HTTP 请求。
二、解决方案
解决方案可以分为两类:
原生方法:Axios、Fetch API、XMLHttpRequest 原生都提供了取消异步 HTTP 请求的能力,只是有的可能没那么好用,比如 Fetch API。
通用方法:我更推荐通用方法,简单易懂,不需要记各种各样的原生方法。
1、可取消的 Promise
在进入正式的介绍之前,先给大家普及一个知识:如何取消一个Promise?
大家都知道,Promise 的逻辑一旦开始执行,就无法被停止,除非它执行完成。所以我们经常会遇到异步逻辑正常处理过程中,程序却不再需要其结果的情形,这点和我们的案例很像。
这时候如果能够取消 Promise 就好了,一些第三方库,比如 Axios,就提供了这个特性。实际上,TC39 委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果 ES6 的 Promise 被认为是 “激进的”。
实际上,我们可以通过 Promise 的特性来提供一种临时性的封装,以实现类似取消 Promise 的功能(但知识类似)。我们都知道 Promise 的状态一旦落定(从 pending 变为 fulfilled 或 rejected)就不可再次改变。
const p = new Promise((resolve, reject) => {
resolve('result message')
// 这个 resolve 会被忽略
resolve('我被忽略了。。。')
console.log('I am running !!')
})
console.log(p)
// I am running!!
// Promise {<fulfilled>: "result message"}
我们看上面这段代码,第2个 resolve() 被忽略了。我们可以利用这个特性来实现一个可取消的 Promise:可以向外暴露一个取消函数,需要取消 Promise 时就调用该函数,函数被调用时会执行 Promise 的 resovle 或 reject 方法,这样接口得到响应时再执行 resolve 或 reject 就会被忽略。通过这样的方式来实现类似取消 Promise 的功能。
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#result {
display: flex;
justify-content: center;
align-items: center;
width: 200px;
height: 100px;
border: 1px solid #eee;
}
</style>
</head>
<body>
<h3>请求结果</h3>
<p id="result">no result</p>
<!-- 三个按钮:请求按钮、取消请求的按钮、复位按钮 -->
<button id="req1">req 1</button>
<button id="cancel">cancel</button>
<button id="reset">reset</button>
<script>
// 暴露取消 Promise 的接口
let cancelReq = null
// 暴露 request 接口
function request(reqArgs) {
return new Promise((resolve, reject) => {
// 通过延时代码来模拟异步 http 请求
setTimeout(() => {
resolve('result message')
}, 2000);
// 给用户提供一个取消请求的函数
cancelReq = function () {
resolve('请求被取消了')
cancelReq = null
}
})
}
</script>
<script>
const req1 = document.querySelector('#req1')
const cancel = document.querySelector('#cancel')
const reset = document.querySelector('#reset')
const result = document.querySelector('#result')
// 给三个按钮添加 click 事件
req1.addEventListener('click', async function () {
const ret = await request('/req')
result.textContent = ret
})
cancel.addEventListener('click', function () {
cancelReq() // 点击之后先执行取消逻辑,后面Promise执行完也不会再执行resolve了
})
reset.addEventListener('click', function() {
result.textContent = 'no result'
})
</script>
</body>
</html>
我们暴露了一个 cancelReq 函数,执行该函数就会先执行该Promise的resolve()状态,后面就算Promise执行完也不会再改变其状态了。
2、axiosRequest.js
其实 axios 的原生解决方案和通用解决方案是一致的,都是利用 Promise 落定以后状态不可变的特性实现的。
(1)原生方案 Axios 官网
const baseURL = 'http://localhost:3000'
const CancelToken = axios.CancelToken
const ins = axios.create({
baseURL,
timeout: 10000
})
ins.interceptors.request.use(config => {
// 拦截请求,可以在这里自定义一些配置,比如 token
return config
})
ins.interceptors.response.use(response => {
// 拦截响应,可以根据服务端返回的状态码做一些自定义的响应和信息提示
return response
})
// 初始化为一个函数,防止报错
let cancelFn = function () {}
function request(reqArgs) {
// 在传递的参数中设置一个 cancelToken 实例
reqArgs.cancelToken = new CancelToken(function (cancel) {
// 向外暴露取消函数
cancelFn = cancel
})
return ins.request(reqArgs)
}
(2)通用方案
const baseURL = 'http://localhost:3000'
const CancelToken = axios.CancelToken
const ins = axios.create({
baseURL,
timeout: 10000
})
ins.interceptors.request.use(config => {
// 拦截请求,可以在这里自定义一些配置,比如 token
return config
})
ins.interceptors.response.use(response => {
// 拦截响应,可以根据服务端返回的状态码做一些自定义的响应和信息提示
return response
})
// 初始化为一个函数,防止报错
let cancelFn = function () {}
function request(reqArgs) {
return new Promise((resolve, reject) => {
// 请求接口
ins.request(reqArgs).then(res => resolve(res))
// 向外暴露取消函数
cancelFn = function (msg) {
reject({ message: msg })
}
})
}
3、xhrRequest.js
(1)原生方案
const baseURL = 'http://localhost:3000'
const xhr = new XMLHttpRequest()
function request(reqArgs) {
return new Promise((resolve, reject) => {
xhr.onload = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
// 接口返回的数据格式是为了兼容 axios 的示例代码
resolve({ data: xhr.responseText })
} else {
// 出错了
reject(xhr.status)
}
}
xhr.open(reqArgs.method || 'get', baseURL + reqArgs.url, true)
xhr.send(reqArgs.data || null)
})
}
// 向外暴露取消函数
function cancelFn() {
// xhr 原生提供了 abort 方法
xhr.abort()
}
(2)通用方案
const baseURL = 'http://localhost:3000'
const xhr = new XMLHttpRequest()
// 初始化取消函数,防止调用报错
let cancelFn = function() {}
function request(reqArgs) {
return new Promise((resolve, reject) => {
xhr.onload = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
// 接口返回的数据格式是为了兼容 axios 的示例代码
resolve({ data: xhr.responseText })
} else {
// 出错了
reject(xhr.status)
}
}
xhr.open(reqArgs.method || 'get', baseURL + reqArgs.url, true)
xhr.send(reqArgs.data || null)
// 向外暴露取消函数
cancelFn = function (msg) {
reject({ message: msg })
}
})
}
参考文章: