你的應用需要一個 “可取消的異步 HTTP 請求模塊”
作者:李永寧
原文地址:https://juejin.cn/post/6935238528510984205
如何取消一個異步 HTTP 請求?
異步 HTTP 請求在現代 web 應用中可以說是隨處可見。為了更好的用戶體驗,05 年出現了 Ajax,支持不刷新頁面實現局部更新。
Ajax 支持同步和異步兩種方式,但是大家基本上只用異步方法,因為發(fā)送同步請求會讓瀏覽器進入暫時性的假死狀態(tài),特別是請求需要處理大數據量、長時間等待的接口,這種情況下采用同步請求,會帶來非常不好的用戶體驗。所以大家普遍都采用異步請求的方式,于是就有了今天的話題,你可能需要一個可取消的異步 HTTP 請求。大家可以思考以下幾個問題:
為什么需要可取消的異步 HTTP 請求?
我們經常會遇到發(fā)送了某個 HTTP 請求,在等待接口響應的過程中突然不需要其結果的情形。
在什么場景下會用到?
比如頁面上有多個 Tab 標簽,點擊每個標簽發(fā)送對應的 HTTP 請求,然后將請求結果顯示在內容區(qū)。現在,用戶在操作時,點了 Tab1 標簽,得到了接口 1 的請求結果,但是在點了 Tab2 后由于接口需要等待 3s 才能返回,用戶不想等了,直接點了 Tab3,這時 Tab2 的接口的返回結果就不再需要了。
它有什么用?
這時如果你不取消 Tab2 的請求,內容區(qū)就會出現這樣的現象:首先顯示 Tab3 接口的結果,然后等一下( 0 <= waitTime <= 3 )內容去又變成了 Tab2 的數據,這時就發(fā)現,如果不取消 Tab2 發(fā)送的異步 HTTP 請求就有問題了,測試就會要你來領 bug。
下面兩個動畫是對上述過程的一個演示,可以幫助大家更加清晰的理解這個場景。第一個動畫是正常操作,在點擊 Tab 2 按鈕時,等待接口返回后才繼續(xù)點擊 Tab 3。但第二個動畫就演示了這個異常現象,點擊 Tab 2 后沒等接口返回直接點擊 Tab 3,發(fā)現內容去先顯示 Tab 3 接口的內容,然后又變成了 Tab 2 接口的結果。
正常結果:

異常結果:

這樣的需求和場景在現代 web 應用開發(fā)中可以說是非常常見了。只是大家平時可能不太會意識到這里會有問題,因為這個 bug 這只存在于需要經過長時間等待才可以得到響應結果的接口(比如 Tab 2),所以如果你的應用存在接口需要處理和傳輸大量數據或者應用在弱網環(huán)境下使用時,就可能會遇到這個問題。所以,一個成熟、穩(wěn)定的 web 應用必須支持可取消的異步 HTTP 請求。
示例
現代 web 應用開發(fā)中,通用的做法是封裝一個公共的 HTTP 請求模塊,該模塊一般是基于第三方開源庫(比如 Axios)或者原生方法(比如 Fetch API、XMLHttpRequest)。
接下來我們就通過一個示例來模擬真實的項目場景,其實上述動畫來源于真實的項目開發(fā),實際案例不方便提供,所以通過示例來模擬。案例中的 HTTP 請求模塊(request)分別通過 Axios、Fetch API、XMLHttpRequest 各實現了一遍。
服務端
這里通過 express 框架來實現服務端,使用
node server.js或nodemon server.js來啟動
const app = require('express')()
const cors = require('cors')
app.use(cors())
app.get('/tab1', (req, res) => {
res.json('Tab 1 的結果')
})
app.get('/tab2', (req, res) => {
// 這里通過延時代碼來模擬處理大數據量的場景
setTimeout(() => {
res.json('Tab 2 的結果')
}, 3000)
})
app.get('/tab3', (req, res) => {
res.json('Tab 3 的結果')
})
app.listen(3000, () => {
console.info('app start at 3000 port')
})
前端
index.html
<!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>
#content {
display: flex;
justify-content: center;
align-items: center;
width: 200px;
height: 100px;
border: 1px solid #eee;
}
</style>
</head>
<body>
<!-- 內容顯示區(qū) -->
<h3>內容區(qū)</h3>
<p id="content">no content</p>
<!-- 通過三個按鈕來模擬三個 Tab 標簽 -->
<button id="tab1">Tab 1</button>
<button id="tab2">Tab 2</button>
<button id="tab3">Tab 3</button>
<button id="reset">reset</button>
<!-- axios -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- 基于 axios 封裝的 request 接口 -->
<script src="./axiosRequest.js"></script>
<!-- 基于 fetch 封裝的 request 接口 -->
<!-- <script src="./fetchRequest.js"></script> -->
<!-- 基于 XMLHttpRequest 封裝的 request 接口 -->
<!-- <script src="./xhrRequest.js"></script> -->
<script>
// 分別給三個按鈕添加點擊事件,當發(fā)生點擊時執(zhí)行相應的回調函數請求對應的接口,請求成功后將結果顯示到內容區(qū)
const tab1 = document.querySelector('#tab1')
const tab2 = document.querySelector('#tab2')
const tab3 = document.querySelector('#tab3')
// 內容顯示區(qū)
const content = document.querySelector('#content')
// reset
const reset = document.querySelector('#reset')
tab1.addEventListener('click', async function () {
const { data } = await request({ url: '/tab1' })
content.textContent = data
})
tab2.addEventListener('click', async function () {
const { data } = await request({ url: '/tab2' }, true)
content.textContent = data
})
tab3.addEventListener('click', async function () {
const { data } = await request({ url: '/tab3' })
content.textContent = data
})
reset.addEventListener('click', function () {
content.textContent = 'no content'
})
</script>
</body>
</html>
axiosRequest.js
基于 Axios 封裝的 request 接口,可以根據自己的業(yè)務需要去擴展,一般是在兩個攔截的地方去做一些擴展
const baseURL = 'http://localhost:3000'
const ins = axios.create({
baseURL,
timeout: 10000
})
ins.interceptors.request.use(config => {
// 攔截請求,可以在這里自定義一些配置,比如 token
return config
})
ins.interceptors.response.use(response => {
// 攔截響應,可以根據服務端返回的狀態(tài)碼做一些自定義的響應和信息提示
return response
})
function request(reqArgs) {
return ins.request(reqArgs)
}
fetchRequest.js
基于 fetch API 封裝的 request 接口,封裝簡單,只為說明問題;返回的數據格式兼容基于 axiosRequest 的示例代碼
const baseURL = 'http://localhost:3000'
function request(reqArgs) {
// 接口返回的數據格式是為了兼容 axios 的示例代碼
return fetch(baseURL + reqArgs.url).then(async response => ({ data: await response.json() }))
}
xhrRequest.js
基于 XMLHttpRequest API 封裝的 request 接口,封裝簡單,只為說明問題;返回的數據格式兼容基于 axiosRequest 的示例代碼
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)
})
}
解決方案
這里演示如何改造一個已有應用,以達到最小改動實現安全無縫升級的目的。而對于一個新起的項目,只需在做架構的時候將以下解決方案集成進去即可。
解決方案可以分為兩類:
原生方法
Axios、Fetch API、XMLHttpRequest 原生都提供了取消異步 HTTP 請求的能力,只是有的可能沒那么好用,比如 Fetch API。
通用方法
我更推薦通用方法,簡單易懂,不需要記各種各樣的原生方法。
可取消的 Promise
在進入正式的改造之前,先給大家普及一個知識,如何取消一個 Promise?
大家都知道,Promise 的邏輯一旦開始執(zhí)行,就無法被停止,除非它執(zhí)行完成。所以我們經常會遇到異步邏輯正常處理過程中,程序卻不再需要其結果的情形,這點和我們的案例很像。這時候如果能夠取消 Promise 就好了,一些第三方庫,比如 Axios,就提供了這個特性。實際上,TC39 委員會也曾準備增加這個特性,但相關提案最終被撤回了。結果 ES6 的 Promise 被認為是 “激進的”。
實際上,我們可以通過 Promise 的特性來提供一種臨時性的封裝,以實現類似取消 Promise 的功能(但知識類似)。我們都知道 Promise 的狀態(tài)一旦落定(從 pending 變?yōu)?fulfilled 或 rejected)就不可再次改變。
const p = new Promise((resolve, reject) => {
resolve('result message')
// 這個 resolve 會被忽略
resolve('我被忽略了。。。')
console.log('I am running !!')
})
// I am running!!
// Promise {<fulfilled>: "result message"}
console.log(p)
我們可以利用這個特性來實現一個可取消的 Promise。可以向外暴露一個取消函數,需要取消 Promise 時就調用該函數,函數被調用時會執(zhí)行 Promise 的 resovle 或 reject 方法,這樣接口得到響應時再執(zhí)行 resolve 或 reject 就會被忽略。通過這樣的方式來實現類似取消 Promise 的功能。
<!-- 可取消的 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')
// 結果顯示區(qū)
const result = document.querySelector('#result')
// 給三個按鈕添加 click 事件
req1.addEventListener('click', async function () {
const ret = await request('/req')
result.textContent = ret
})
cancel.addEventListener('click', function () {
cancelReq()
})
reset.addEventListener('click', function() {
result.textContent = 'no result'
})
</script>
</body>
</html>
有了以上基礎,接下來我們就可以開始改造我們的案例代碼了。
效果
先上效果,可以看到,升級以后,之前的問題就不存在了。

index.html
// 在需要取消 http 請求的地方添加取消函數調用
tab3.addEventListener('click', async function () {
// 取消上一個請求
cancelFn('Tab 2 的接口請求被取消了')
const { data } = await request({ url: '/tab3' })
content.textContent = data
})
axiosRequest.js
其實 axios 的原生解決方案和通用解決方案是一致的,都是利用 Promise 落定以后狀態(tài)不可變的特性實現的。
原生方案 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 => {
// 攔截響應,可以根據服務端返回的狀態(tài)碼做一些自定義的響應和信息提示
return response
})
// 初始化為一個函數,防止報錯
let cancelFn = function () {}
function request(reqArgs) {
// 在傳遞的參數中設置一個 cancelToken 實例
reqArgs.cancelToken = new CancelToken(function (cancel) {
// 向外暴露取消函數
cancelFn = cancel
})
return ins.request(reqArgs)
}
通用方案
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 => {
// 攔截響應,可以根據服務端返回的狀態(tài)碼做一些自定義的響應和信息提示
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 })
}
})
}
fetchRequest.js
Fetch API 支持通過 AbortController/AbortSignal 中斷請求,也可使用通用解決方案。其實通用解決方案更好,因為被中斷的 Fetch 會被打上一個標記,將變得不可用,除非刷新頁面。其實 fetch 的原生方案解決不了我們案例中的問題。它雖然中斷了請求,可也阻止了后續(xù)請求的發(fā)送。
原生方案
執(zhí)行以后會在控制臺看到以下信息:
第一個表示用戶終止了 fetch 請求,也就是 cancelFn 函數調用導致產生的提示信息。而第二個錯誤提示是因為我們在中斷 fetch 請求以后重新發(fā)了另外一個 fetch 請求(點擊了 tab 3 按鈕)導致的報錯,告訴你當前 window 對象上的 fetch 已經被用戶終止了,所以你需要刷新頁面,重新初始化這些全局對象(window.fetch)
// 通過 AbortController/AbortSignal 中斷請求
const abortController = new AbortController()
// 向外暴露取消函數
function cancelFn() {
// 中斷所有網絡傳輸,特別適合希望停止傳輸大型負載的情況
abortController.abort()
}
const baseURL = 'http://localhost:3000'
function request(reqArgs) {
// 接口返回的數據格式是為了兼容 axios 的示例代碼
return fetch(baseURL + reqArgs.url, { signal: abortController.signal }).then(async response => ({ data: await response.json() }))
}
通用方案
// 初始化為一個函數,防止報錯
let cancelFn = function () {}
const baseURL = 'http://localhost:3000'
function request(reqArgs) {
return new Promise((resolve, reject) => {
// 接口返回的數據格式是為了兼容 axios 的示例代碼
fetch(baseURL + reqArgs.url).then(async response => resolve({ data: await response.json() }))
// 向外暴露取消函數
cancelFn = function(msg) {
reject({ message: msg })
}
})
}
xhrRequest.js
原生方案
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()
}
通用方案
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 })
}
})
}
總結
這就是所有終止異步 HTTP 請求的方案,可以總結為兩類:
原生方案 基于 Promise 進行二次封裝的通用方案
大家可根據自己的需要進行選擇。
鏈接
視頻講解地址:https://www.bilibili.com/video/BV1D54y1h7md/
github地址:https://github.com/liyongning/cancel-async-http-request.git
1.看到這里了就點個在看支持下吧,你的「點贊,在看」是我創(chuàng)作的動力。
2.關注公眾號
程序員成長指北,回復「1」加入高級前端交流群!「在這里有好多 前端 開發(fā)者,會討論 前端 Node 知識,互相學習」!3.也可添加微信【ikoala520】,一起成長。
“在看轉發(fā)”是最大的支持
