取消功能的設計與實現
#需求分析
有些場景下,我們希望能主動取消請求,比如常見的搜索框案例,在用戶輸入過程中,搜索框的內容也在不斷變化,正常情況每次變化我們都應該向服務端發送一次請求。但是當用戶輸入過快的時候,我們不希望每次變化請求都發出去,通常一個解決方案是前端用 debounce 的方案,比如延時 200ms 發送請求。這樣當用戶連續輸入的字符,只要輸入間隔小於 200ms,前面輸入的字符都不會發請求。
但是還有一種極端情況是后端接口很慢,比如超過 1s 才能響應,這個時候即使做了 200ms 的 debounce,但是在我慢慢輸入(每個輸入間隔超過 200ms)的情況下,在前面的請求沒有響應前,也有可能發出去多個請求。因為接口的響應時長是不定的,如果先發出去的請求響應時長比后發出去的請求要久一些,后請求的響應先回來,先請求的響應后回來,就會出現前面請求響應結果覆蓋后面請求響應結果的情況,那么就亂了。因此在這個場景下,我們除了做 debounce,還希望后面的請求發出去的時候,如果前面的請求還沒有響應,我們可以把前面的請求取消。
從 axios 的取消接口設計層面,我們希望做如下的設計:
const CancelToken = axios.CancelToken; const source = CancelToken.source(); axios.get('/user/12345', { cancelToken: source.token }).catch(function (e) { if (axios.isCancel(e)) { console.log('Request canceled', e.message); } else { // 處理錯誤 } }); // 取消請求 (請求原因是可選的) source.cancel('Operation canceled by the user.');
我們給 axios
添加一個 CancelToken
的對象,它有一個 source
方法可以返回一個 source
對象,source.token
是在每次請求的時候傳給配置對象中的 cancelToken
屬性,然后在請求發出去之后,我們可以通過 source.cancel
方法取消請求。
我們還支持另一種方式的調用:
const CancelToken = axios.CancelToken; let cancel; axios.get('/user/12345', { cancelToken: new CancelToken(function executor(c) { cancel = c; }) }); // 取消請求 cancel();
axios.CancelToken
是一個類,我們直接把它實例化的對象傳給請求配置中的 cancelToken
屬性,CancelToken
的構造函數參數支持傳入一個 executor
方法,該方法的參數是一個取消函數 c
,我們可以在 executor
方法執行的內部拿到這個取消函數 c
,賦值給我們外部定義的 cancel
變量,之后我們可以通過調用這個 cancel
方法來取消請求。
#異步分離的設計方案
通過需求分析,我們知道想要實現取消某次請求,我們需要為該請求配置一個 cancelToken
,然后在外部調用一個 cancel
方法。
請求的發送是一個異步過程,最終會執行 xhr.send
方法,xhr
對象提供了 abort
方法,可以把請求取消。因為我們在外部是碰不到 xhr
對象的,所以我們想在執行 cancel
的時候,去執行 xhr.abort
方法。
現在就相當於我們在 xhr
異步請求過程中,插入一段代碼,當我們在外部執行 cancel
函數的時候,會驅動這段代碼的執行,然后執行 xhr.abort
方法取消請求。
我們可以利用 Promise 實現異步分離,也就是在 cancelToken
中保存一個 pending
狀態的 Promise 對象,然后當我們執行 cancel
方法的時候,能夠訪問到這個 Promise 對象,把它從 pending
狀態變成 resolved
狀態,這樣我們就可以在 then
函數中去實現取消請求的邏輯,類似如下的代碼:
if (cancelToken) { cancelToken.promise .then(reason => { request.abort() reject(reason) }) }
#CancelToken 類實現
接下來,我們就來實現這個 CancelToken
類,先來看一下接口定義:
#接口定義
types/index.ts
:
export interface AxiosRequestConfig { // ... cancelToken?: CancelToken } export interface CancelToken { promise: Promise<string> reason?: string } export interface Canceler { (message?: string): void } export interface CancelExecutor { (cancel: Canceler): void }
其中 CancelToken
是實例類型的接口定義,Canceler
是取消方法的接口定義,CancelExecutor
是 CancelToken
類構造函數參數的接口定義。
#代碼實現
我們單獨創建 cancel
目錄來管理取消相關的代碼,在 cancel
目錄下創建 CancelToken.ts
文件:
import { CancelExecutor } from '../types' interface ResolvePromise { (reason?: string): void } export default class CancelToken { promise: Promise<string> reason?: string constructor(executor: CancelExecutor) { let resolvePromise: ResolvePromise this.promise = new Promise<string>(resolve => { resolvePromise = resolve }) executor(message => { if (this.reason) { return } this.reason = message resolvePromise(this.reason) }) } }
在 CancelToken
構造函數內部,實例化一個 pending
狀態的 Promise 對象,然后用一個 resolvePromise
變量指向 resolve
函數。接着執行 executor
函數,傳入一個 cancel
函數,在 cancel
函數內部,會調用 resolvePromise
把 Promise 對象從 pending
狀態變為 resolved
狀態。
接着我們在 xhr.ts
中插入一段取消請求的邏輯。
core/xhr.ts
:
const { /*....*/ cancelToken } = config if (cancelToken) { cancelToken.promise.then(reason => { request.abort() reject(reason) }) }
這樣就滿足了第二種使用方式,接着我們要實現第一種使用方式,給 CancelToken
擴展靜態接口。
#CancelToken 擴展靜態接口
#接口定義
types/index.ts
:
export interface CancelTokenSource { token: CancelToken cancel: Canceler } export interface CancelTokenStatic { new(executor: CancelExecutor): CancelToken source(): CancelTokenSource }
其中 CancelTokenSource
作為 CancelToken
類靜態方法 source
函數的返回值類型,CancelTokenStatic
則作為 CancelToken
類的類類型。
#代碼實現
cancel/CancelToken.ts
:
export default class CancelToken { // ... static source(): CancelTokenSource { let cancel!: Canceler const token = new CancelToken(c => { cancel = c }) return { cancel, token } } }
source
的靜態方法很簡單,定義一個 cancel
變量實例化一個 CancelToken
類型的對象,然后在 executor
函數中,把 cancel
指向參數 c
這個取消函數。
這樣就滿足了我們第一種使用方式,但是在第一種使用方式的例子中,我們在捕獲請求的時候,通過 axios.isCancel
來判斷這個錯誤參數 e 是不是一次取消請求導致的錯誤,接下來我們對取消錯誤的原因做一層包裝,並且把給 axios
擴展靜態方法
#Cancel 類實現及 axios 的擴展
#接口定義
export interface Cancel { message?: string } export interface CancelStatic { new(message?: string): Cancel } export interface AxiosStatic extends AxiosInstance { create(config?: AxiosRequestConfig): AxiosInstance CancelToken: CancelTokenStatic Cancel: CancelStatic isCancel: (value: any) => boolean }
其中 Cancel
是實例類型的接口定義,CancelStatic
是類類型的接口定義,並且我們給 axios
擴展了多個靜態方法。
#代碼實現
我在 cancel
目錄下創建 Cancel.ts
文件。
export default class Cancel { message?: string constructor(message?: string) { this.message = message } } export function isCancel(value: any): boolean { return value instanceof Cancel }
Cancel
類非常簡單,擁有一個 message
的公共屬性。isCancel
方法也非常簡單,通過 instanceof
來判斷傳入的值是不是一個 Cancel
對象。
接着我們對 CancelToken
類中的 reason
類型做修改,把它變成一個 Cancel
類型的實例。
先修改定義部分。
types/index.ts
:
export interface CancelToken { promise: Promise<Cancel> reason?: Cancel }
再修改實現部分:
import Cancel from './Cancel' interface ResolvePromise { (reason?: Cancel): void } export default class CancelToken { promise: Promise<Cancel> reason?: Cancel constructor(executor: CancelExecutor) { let resolvePromise: ResolvePromise this.promise = new Promise<Cancel>(resolve => { resolvePromise = resolve }) executor(message => { if (this.reason) { return } this.reason = new Cancel(message) resolvePromise(this.reason) }) } }
接下來我們給 axios
擴展一些靜態方法,供用戶使用。
axios.ts
:
import CancelToken from './cancel/CancelToken' import Cancel, { isCancel } from './cancel/Cancel' axios.CancelToken = CancelToken axios.Cancel = Cancel axios.isCancel = isCancel
#額外邏輯實現
除此之外,我們還需要實現一些額外邏輯,比如當一個請求攜帶的 cancelToken
已經被使用過,那么我們甚至都可以不發送這個請求,只需要拋一個異常即可,並且拋異常的信息就是我們取消的原因,所以我們需要給 CancelToken
擴展一個方法。
先修改定義部分。
types/index.ts
:
export interface CancelToken { promise: Promise<Cancel> reason?: Cancel throwIfRequested(): void }
添加一個 throwIfRequested
方法,接下來實現它:
cancel/CancelToken.ts
:
export default class CancelToken { // ... throwIfRequested(): void { if (this.reason) { throw this.reason } } }
判斷如果存在 this.reason
,說明這個 token
已經被使用過了,直接拋錯。
接下來在發送請求前增加一段邏輯。
core/dispatchRequest.ts
:
export default function dispatchRequest(config: AxiosRequestConfig): AxiosPromise { throwIfCancellationRequested(config) processConfig(config) // ... } function throwIfCancellationRequested(config: AxiosRequestConfig): void { if (config.cancelToken) { config.cancelToken.throwIfRequested() } }
發送請求前檢查一下配置的 cancelToken 是否已經使用過了,如果已經被用過則不用法請求,直接拋異常。
#demo 編寫
在 examples
目錄下創建 cancel
目錄,在 cancel
目錄下創建 index.html
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Cancel example</title> </head> <body> <script src="/__build__/cancel.js"></script> </body> </html>
接着創建 app.ts
作為入口文件:
import axios, { Canceler } from '../../src/index' const CancelToken = axios.CancelToken const source = CancelToken.source() axios.get('/cancel/get', { cancelToken: source.token }).catch(function(e) { if (axios.isCancel(e)) { console.log('Request canceled', e.message) } }) setTimeout(() => { source.cancel('Operation canceled by the user.') axios.post('/cancel/post', { a: 1 }, { cancelToken: source.token }).catch(function(e) { if (axios.isCancel(e)) { console.log(e.message) } }) }, 100) let cancel: Canceler axios.get('/cancel/get', { cancelToken: new CancelToken(c => { cancel = c }) }).catch(function(e) { if (axios.isCancel(e)) { console.log('Request canceled') } }) setTimeout(() => { cancel() }, 200)
我們的 demo 展示了 2 種使用方式,也演示了如果一個 token 已經被使用過,則再次攜帶該 token 的請求並不會發送。
至此,我們完成了 ts-axios
的請求取消功能,我們巧妙地利用了 Promise 實現了異步分離。目前官方 axios
庫的一些大的 feature 我們都已經實現了,下面的章節我們就開始補充完善 ts-axios
的其它功能。