axios 中一個請求取消的示例:
axios 取消請求的示例代碼
import React, { useState, useEffect } from "react";
import axios, { AxiosResponse } from "axios";
export default function App() {
const [index, setIndex] = useState(0);
const [imgUrl, setImgUrl] = useState("");
useEffect(() => {
console.log(</span>loading ${<span class="pl-smi">index</span>}<span class="pl-pds">
);
const source = axios.CancelToken.source();
axios
.get("https://dog.ceo/api/breeds/image/random", {
cancelToken: source.token
})
.then((res: AxiosResponse<{ message: string; status: string }>) => {
console.log(</span>${<span class="pl-smi">index</span>} done<span class="pl-pds">
);
setImgUrl(res.data.message);
})
.catch(err => {
if (axios.isCancel(source)) {
console.log(err.message);
}
});
<span class="pl-k">return</span> () <span class="pl-k">=></span> {
<span class="pl-c1">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">`</span>canceling ${<span class="pl-smi">index</span>}<span class="pl-pds">`</span></span>);
<span class="pl-smi">source</span>.<span class="pl-en">cancel</span>(<span class="pl-s"><span class="pl-pds">`</span>canceling ${<span class="pl-smi">index</span>}<span class="pl-pds">`</span></span>);
};
}, [index]);
return (
<div>
<button
onClick={() => {
setIndex(index + 1);
}}
>
click
</button>
<div>
<img src={imgUrl} alt="" />
</div>
</div>
);
}
axios 中一個請求取消的示例
通過解讀其源碼不難實現出一個自己的版本。Here we go...
Promise 鏈與攔截器
這個和請求的取消其實關系不大,但不妨先來了解一下,axios 中如何組織起來一個 Promise 鏈(Promise chain),從而實現在請求前后可執行一個攔截器(Interceptor)的。
簡單來說,通過 axios 發起的請求,可在請求前后執行一些函數,來實現特定功能,比如請求前添加一些自定義的 header,請求后進行一些數據上的統一轉換等。
用法
首先,通過 axios 實例配置需要執行的攔截器:
axios.interceptors.request.use(function (config) {
console.log('before request')
return config;
}, function (error) {
return Promise.reject(error);
});
axios.interceptors.response.use(function (response) {
console.log('after response');
return response;
}, function (error) {
return Promise.reject(error);
});
然后每次請求前后都會打印出相應信息,攔截器生效了。
axios({
url: "https://dog.ceo/api/breeds/image/random",
method: "GET"
}).then(res => {
console.log("load success");
});
下面編寫一個頁面,放置一個按鈕,點擊后發起請求,后續示例中將一直使用該頁面來測試。
import React from "react";
import axios from "axios";
export default function App() {
const sendRequest = () => {
axios.interceptors.request.use(
config => {
console.log("before request");
return config;
},
function(error) {
return Promise.reject(error);
}
);
<span class="pl-smi">axios</span>.<span class="pl-smi">interceptors</span>.<span class="pl-smi">response</span>.<span class="pl-en">use</span>(
<span class="pl-v">response</span> <span class="pl-k">=></span> {
<span class="pl-c1">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">"</span>after response<span class="pl-pds">"</span></span>);
<span class="pl-k">return</span> <span class="pl-smi">response</span>;
},
<span class="pl-k">function</span>(<span class="pl-v">error</span>) {
<span class="pl-k">return</span> <span class="pl-c1">Promise</span>.<span class="pl-c1">reject</span>(<span class="pl-smi">error</span>);
}
);
<span class="pl-en">axios</span>({
url: <span class="pl-s"><span class="pl-pds">"</span><a href="https://dog.ceo/api/breeds/image/random" rel="noreferrer noopener" class="rgh-linkified-code"><span class="pl-pds"></span>https://dog.ceo/api/breeds/image/random</a><span class="pl-pds">"</span></span>,
method: <span class="pl-s"><span class="pl-pds">"</span>GET<span class="pl-pds">"</span></span>
}).<span class="pl-c1">then</span>(<span class="pl-v">res</span> <span class="pl-k">=></span> {
<span class="pl-c1">console</span>.<span class="pl-c1">log</span>(<span class="pl-s"><span class="pl-pds">"</span>load success<span class="pl-pds">"</span></span>);
});
};
return (
<div>
<button onClick={sendRequest}>click me</button>
</div>
);
}
點擊按鈕后運行結果:
before request
after response
load success
攔截器機制的實現
實現分兩步走,先看請求前的攔截器。
請求前攔截器的實現
Promise 的常規用法如下:
new Promise(resolve,reject);
假如我們封裝一個類似 axios 的請求庫,可以這么寫:
interface Config {
url: string;
method: "GET" | "POST";
}
function request(config: Config) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText);
};
xhr.onerror = err => {
reject(err);
};
xhr.send();
});
}
除了像上面那個直接 new
一個 Promise 外,其實任意對象值都可以形成一個 Promise,方法是調用 Promise.resolve
,
Promise.resolve(value).then(()=>{ /**... */ });
這種方式創建 Promise 的好處是,我們可以從 config
開始,創建一個 Promise 鏈,在真實的請求發出前,先執行一些函數,像這樣:
function request(config: Config) {
return Promise.resolve(config)
.then(config => {
console.log("interceptor 1");
return config;
})
.then(config => {
console.log("interceptor 2");
return config;
})
.then(config => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText);
};
xhr.onerror = err => {
reject(err);
};
xhr.send();
});
});
}
將前面示例中 axios 替換為我們自己寫的 request
函數,示例可以正常跑起來,輸出如下:
interceptor 1
interceptor 2
load success
這里,已經實現了 axios 中請求前攔截器的功能。仔細觀察,上面三個 then
當中的函數,形成了一個 Promise 鏈,在這個鏈中順次執行,每一個都可以看成一個攔截器,即使是執行發送請求的那個 then
。
於是我們可以將他們抽取成三個函數,每個函數就是一個攔截器。
function interceptor1(config: Config) {
console.log("interceptor 1");
return config;
}
function interceptor2(config: Config) {
console.log("interceptor 2");
return config;
}
function xmlHttpRequest<T>(config: Config) {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText as any);
};
xhr.onerror = err => {
reject(err);
};
xhr.send();
});
}
接下來要做的,就是從 Promise 鏈的頭部 Promise.resolve(config)
開始,將上面三個函數串起來。借助 Monkey patch 這不難實現:
function request<T = any>(config: Config) {
let chain: Promise<any> = Promise.resolve(config);
chain = chain.then(interceptor1);
chain = chain.then(interceptor2);
chain = chain.then(xmlHttpRequest);
return chain as Promise<T>;
}
然后,將上面硬編碼的寫法程式化一下,就實現了任意個請求前攔截器的功能。
擴展配置,以接收攔截器:
interface Config {
url: string;
method: "GET" | "POST";
interceptors?: Interceptor<Config>[];
}
創建一個數組,將執行請求的函數做為默認的元素放進去,然后將用戶配置的攔截器壓入數組前面,這樣形成了一個攔截器的數組。最后再遍歷這個數組形成 Promise 鏈。
function request<T = any>({ interceptors = [], ...config }: Config) {
// 發送請求的攔截器為默認,用戶配置的攔截器壓入數組前面
const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];
interceptors.forEach(interceptor => {
tmpInterceptors.unshift(interceptor);
});
let chain: Promise<any> = Promise.resolve(config);
tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));
return chain as Promise<T>;
}
使用:
request({
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
interceptors: [interceptor1, interceptor2]
}).then(res => {
console.log("load success");
});
執行結果:
interceptor 2
interceptor 1
load success
注意這里順序為傳入的攔截器的反序,不過這不重要,可通過傳遞的順序來控制。
響應后攔截器
上面實現了在請求前執行一序列攔截函數,同理,如果將攔截器壓入到數組后面,即執行請求那個函數的后面,便實現了響應后的攔截器。
繼續擴展配置,將請求與響應的攔截器分開:
interface Config {
url: string;
method: "GET" | "POST";
interceptors?: {
request: Interceptor<Config>[];
response: Interceptor<any>[];
};
}
更新 request
方法,請求前攔截器的邏輯不變,將新增的響應攔截器通過 push
壓入數組后面:
function request<T = any>({
interceptors = { request: [], response: [] },
...config
}: Config) {
const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];
interceptors.request.forEach(interceptor => {
tmpInterceptors.unshift(interceptor);
});
interceptors.response.forEach(interceptor => {
tmpInterceptors.push(interceptor);
});
let chain: Promise<any> = Promise.resolve(config);
tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));
return chain as Promise<T>;
}
類似 interceptor1
interceptor2
,新增兩個攔截器用於響應后執行,
function interceptor3<T>(res: T) {
console.log("interceptor 3");
return res;
}
function interceptor4<T>(res: T) {
console.log("interceptor 4");
return res;
}
測試代碼:
request({
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
interceptors: {
request: [interceptor1, interceptor2],
response: [interceptor3, interceptor4]
}
}).then(res => {
console.log("load success");
});
運行結果:
interceptor 2
interceptor 1
interceptor 3
interceptor 4
load success
不難看出,當我們發起一次 axios 請求時,其實是發起了一次 Promise 鏈,鏈上的函數順次執行。
request interceptor 1
request interceptor 2
...
request
response interceptor 1
response interceptor 2
...
因為拉弓沒有回頭箭,請求發出后,能夠取消的是后續操作,而不是請求本身,所以上面的 Promise 鏈中,需要實現 request
之后的攔截器和后續回調的取消執行。
request interceptor 1
request interceptor 2
...
request
# 🚫 后續操作不再執行
response interceptor 1
response interceptor 2
...
請求的取消
Promise 鏈的中斷
中斷 Promise 鏈的執行,可通過 throw 異常來實現。
添加一個中間函數,將執行請求的函數進行封裝,無論其成功與否,都拋出異常將后續執行中斷。
function adapter(config: Config) {
return xmlHttpRequest(config).then(
res => {
throw "baddie!";
},
err => {
throw "baddie!";
}
);
}
更新 request
函數使用 adapter
而不是直接使用 xmlHttpRequest
:
function request<T = any>({
interceptors = { request: [], response: [] },
...config
}: Config) {
- const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];
+ const tmpInterceptors: Interceptor<any>[] = [adapter];
interceptors.request.forEach(interceptor => {
tmpInterceptors.unshift(interceptor);
});
interceptors.response.forEach(interceptor => {
tmpInterceptors.push(interceptor);
});
let chain: Promise<any> = Promise.resolve(config);
tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));
return chain as Promise<T>;
}
再次執行其輸出結果為:
interceptor 2
interceptor 1
Uncaught (in promise) baddie!
請求取消的實現
按照 axios 的實現思路,要實現請求的取消,需要先創建一個 token,通過該 token 可調用一個 cancel
方法;通過將 token 傳遞到配置中,在發起請求時對 token 進行檢查以判定該 token 是否執行過取消,如果是則利用上面的思路,將 Promise 鏈中斷掉。
構造 token
所以不難看出,這里的 token 對象至少:
- 有一個
cancel
方法 - 有一個字段記錄
cancel
方法是否被調用過
額外地,
- 如果有一個字段記錄取消的原因,那也不錯。
由此我們得到這么一個類:
class CancelTokenSource {
private _canceled = false;
get canceled() {
return this._canceled;
}
private _message = "unknown reason";
get message() {
return this._message;
}
cancel(reason?: string) {
if (this.canceled) return;
if (reason) {
this._message = reason;
}
this._canceled = true;
}
}
添加 token 到配置
擴展配置,以接收一個用來取消的 token 對象:
interface Config {
url: string;
method: "GET" | "POST";
+ cancelToken?: CancelTokenSource;
interceptors?: {
request: Interceptor<Config>[];
response: Interceptor<any>[];
};
}
請求邏輯中處理取消
同時更新 xmlHttpRequest
函數,判斷 token 的狀態是否調用過取消,如果是則調用 xhr.abort()
,同時添加 onabort
回調以 reject 掉 Promise:
function xmlHttpRequest<T>(config: Config) {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText as any);
};
xhr.onerror = err => {
reject(err);
};
+ xhr.onabort = () => {
+ reject();
+ };
+ if (config.cancelToken) {
+ xhr.abort();
+ }
xhr.send();
});
}
取消的調用
將拋異常的代碼抽取成方法以在多處調用,更新 adapter
的邏輯,在沒有取消的情況下正常返回和 reject。
function throwIfCancelRequested(config: Config) {
if (config.cancelToken && config.cancelToken.canceled) {
throw config.cancelToken.message;
}
}
function adapter(config: Config) {
throwIfCancelRequested(config);
return xmlHttpRequest(config).then(
res => {
throwIfCancelRequested(config);
return res;
},
err => {
throwIfCancelRequested(config);
return Promise.reject(err);
}
);
}
測試請求的取消
似乎一切 okay,接下來測試一波。以下代碼期望每次點擊按鈕發起請求,請求前先取消掉之前的請求。為了區分每次不同的請求,添加 index
變量,按鈕點擊時自增。
import React, { useEffect, useState } from "react";
export default function App() {
const [index, setIndex] = useState(0);
useEffect(() => {
const token = new CancelTokenSource();
request({
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
cancelToken: token,
interceptors: {
request: [interceptor1, interceptor2],
response: [interceptor3, interceptor4]
}
})
.then(res => {
console.log(</span>load ${<span class="pl-smi">index</span>} success<span class="pl-pds">
);
})
.catch(err => {
console.log("outer catch ", err);
});
<span class="pl-k">return</span> () <span class="pl-k">=></span> {
<span class="pl-smi">token</span>.<span class="pl-en">cancel</span>(<span class="pl-s"><span class="pl-pds">`</span>just cancel ${<span class="pl-smi">index</span>}<span class="pl-pds">`</span></span>);
};
}, [index]);
return (
<div>
<button
onClick={() => {
setIndex(index + 1);
}}
>
click me
</button>
</div>
);
}
加載頁面進行測試,useEffect
會在頁面加載后首次運行,會觸發一次完整的請求流程。然后連續點擊兩次按鈕,以取消掉兩次中的前一次。運行結果:
interceptor 2
interceptor 1
interceptor 3
interceptor 4
load 0 success
interceptor 2
interceptor 1
interceptor 2
interceptor 1
outer catch just cancel 1
interceptor 3
interceptor 4
load 2 success
現有實現中的問題
從輸出來看,
- 第一部分為首次請求,是一次正常的請求。
- 第二部分為第一次點擊的請求攔截器的執行。
- 第三部分為第二次點擊,將第一次請求進行了取消,然后完成一次完整的請求。
從輸出和網絡請求來看,有兩個問題:
xhr.abort()
沒有生效,連續的兩次點擊中,瀏覽器調試工具中會有兩條狀態為 200 的請求。- 第一條請求后續的回調確實被取消掉了,但它是在等待請求成功后,在成功回調中取消的,這點可通過在取消函數中添加標志位來查看。
function throwIfCancelRequested(config: Config, flag?: number) {
if (config.cancelToken && config.cancelToken.canceled) {
console.log(flag);
throw config.cancelToken.message;
}
}
function adapter(config: Config) {
throwIfCancelRequested(config, 1);
return xmlHttpRequest(config).then(
res => {
//ℹ 后續輸出證明,實際生效的是此處
throwIfCancelRequested(config, 2);
return res;
},
err => {
//ℹ 而非此處,即使取消的動作是在請求進行過程中
throwIfCancelRequested(config, 3);
return Promise.reject(err);
}
);
}
輸出:
interceptor 2
interceptor 1
interceptor 2
interceptor 1
2
outer catch just cancel 1
interceptor 3
interceptor 4
load 2 success
優化
下面的優化需要解決上面的問題。所用到的方法便是 axios 中的邏輯 ,也是一開始看源碼會不太理解的地方。
其實外部調用 cancel()
的時機並不確定,所以 token 對象上記錄其是否被取消的字段,何時被置為 true
是不確定的,因此,我們取消請求的邏輯(xhr.abort()
)應該是在一個 Promise 中來完成。
因此,在 CancelTokenSource
類中,創建一個 Promise 類型的字段,它會在 cancel()
方法被調用的時候 resolve 掉。
更新后的 CancelTokenSource
類:
class CancelTokenSource {
public promise: Promise<unknown>;
private resolvePromise!: (value?: any) => void;
constructor() {
this.promise = new Promise(resolve => {
this.resolvePromise = resolve;
});
}
private _canceled = false;
get canceled() {
return this._canceled;
}
private _message = "unknown reason";
get message() {
return this._message;
}
cancel(reason?: string) {
if (reason) {
this._message = reason;
}
this._canceled = true;
this.resolvePromise();
}
}
更新后訪問 canceled
字段的邏輯:
function xmlHttpRequest<T>(config: Config) {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText as any);
};
xhr.onerror = err => {
reject(err);
};
xhr.onabort = () => {
reject();
};
if (config.cancelToken) {
config.cancelToken.promise.then(() => {
xhr.abort();
});
}
xhr.send();
});
}
測試優化后的版本
輸出結果:
interceptor 2 interceptor 1 interceptor 3 interceptor 4 load 0 success
interceptor 2
interceptor 1
interceptor 2
3
interceptor 1
outer catch just cancel 1
interceptor 3
interceptor 4
load 2 success
瀏覽器調試工具的網絡會有一次飄紅被 abort
掉的請求,同時上面的輸出(生效的地方是 3 而非 2)顯示被取消的請求正確地 reject 掉了。
完整代碼
自己實現的請求取消機制完整代碼
import React, { useState, useEffect } from "react";
class CancelTokenSource {
public promise: Promise<unknown>;
private resolvePromise!: (value?: any) => void;
constructor() {
this.promise = new Promise(resolve => {
this.resolvePromise = resolve;
});
}
private _canceled = false;
get canceled() {
return this._canceled;
}
private _message = "unknown reason";
get message() {
return this._message;
}
cancel(reason?: string) {
if (reason) {
this._message = reason;
}
this._canceled = true;
this.resolvePromise();
}
}
type Interceptor<T> = (value: T) => T | Promise<T>;
interface Config {
url: string;
method: "GET" | "POST";
cancelToken?: CancelTokenSource;
interceptors?: {
request: Interceptor<Config>[];
response: Interceptor<any>[];
};
}
function interceptor1(config: Config) {
console.log("interceptor 1");
return config;
}
function interceptor2(config: Config) {
console.log("interceptor 2");
return config;
}
function interceptor3<T>(res: T) {
console.log("interceptor 3");
return res;
}
function interceptor4<T>(res: T) {
console.log("interceptor 4");
return res;
}
function xmlHttpRequest<T>(config: Config) {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(config.method, config.url);
xhr.onload = () => {
resolve(xhr.responseText as any);
};
xhr.onerror = err => {
reject(err);
};
xhr.onabort = () => {
reject();
};
if (config.cancelToken) {
config.cancelToken.promise.then(() => {
xhr.abort();
});
}
xhr.send();
});
}
function throwIfCancelRequested(config: Config, flag?: number) {
if (config.cancelToken && config.cancelToken.canceled) {
console.log(flag);
throw config.cancelToken.message;
}
}
function adapter(config: Config) {
throwIfCancelRequested(config, 1);
return xmlHttpRequest(config).then(
res => {
throwIfCancelRequested(config, 2);
return res;
},
err => {
throwIfCancelRequested(config, 3);
return Promise.reject(err);
}
);
}
function request<T = any>({
interceptors = { request: [], response: [] },
...config
}: Config) {
const tmpInterceptors: Interceptor<any>[] = [adapter];
interceptors.request.forEach(interceptor => {
tmpInterceptors.unshift(interceptor);
});
interceptors.response.forEach(interceptor => {
tmpInterceptors.push(interceptor);
});
let chain: Promise<any> = Promise.resolve(config);
tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));
return chain as Promise<T>;
}
export default function App() {
const [index, setIndex] = useState(0);
useEffect(() => {
const token = new CancelTokenSource();
request({
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
cancelToken: token,
interceptors: {
request: [interceptor1, interceptor2],
response: [interceptor3, interceptor4]
}
})
.then(res => {
console.log(</span>load ${<span class="pl-smi">index</span>} success<span class="pl-pds">
);
})
.catch(err => {
console.log("outer catch ", err);
});
<span class="pl-k">return</span> () <span class="pl-k">=></span> {
<span class="pl-smi">token</span>.<span class="pl-en">cancel</span>(<span class="pl-s"><span class="pl-pds">`</span>just cancel ${<span class="pl-smi">index</span>}<span class="pl-pds">`</span></span>);
};
}, [index]);
return (
<div>
<button
onClick={() => {
setIndex(index + 1);
}}
>
click me
</button>
</div>
);
}
運行效果