玩轉 AbortController 控制器

jump__jump發表於2022-06-19

絕大部分情況,網路請求都是先請求先響應。但是某些情況下,由於未知的一些問題,可能會導致先請求的 api 後返回。最簡單的解決方案就是新增 loading 狀態,在所有請求都完成後才能進行下一次請求。

但不是所有的業務都可以採用這種方式。這時候開發者就需要對其進行處理以避免渲染錯誤資料。

使用“版本號”

我們可以使用版本號來決策業務處理以及資料渲染:

const invariant = (condition: boolean, errorMsg: string) => {
    if (condition) {
        throw new Error(errorMsg)
    }
}

let versionForXXXQuery = 0;

const checkVersionForXXXQuery = (currentVersion: number) => {
    // 版本不匹配,就丟擲錯誤
    invariant(currentVersion !== versionForXXXQuery, 'The current version is wrong')
}

const XXXQuery = async () => {
    // 此處只能使用 ++versionForXXXQuery 而不能使用 versionForXXXQuery++
    // 否則版本永遠不對應
    const queryVersion = ++versionForXXXQuery;

    // 業務請求
    checkVersion(queryVersion)
    // 業務處理
    // ?介面渲染


    // 業務請求
    checkVersion(queryVersion)
    // 業務處理
    // ?介面渲染
}

如此,先請求的 api 後返回就會被錯誤中止執行,但最終渲染到介面上的只有最新版本的請求。但是該方案對業務的侵入性太強。雖然我們可以利用 class 和 AOP 來簡程式碼和邏輯。但對於開發來說依舊不友好。這時候我們可以使用 AbortController。

使用 AbortController

AbortController 取消之前請求

話不多說,先使用 AbortController 完成上面相同的功能。

let abortControllerForXXXQuery: AbortController | null = null

const XXXQuery = async () => {
    // 當前有中止控制器,直接把上一次取消
    if (abortControllerForXXXQuery) {
        abortControllerForXXXQuery.abort()
    }

    // 新建控制器
    abortControllerForXXXQuery = new AbortController();

    // 獲取訊號
    const { signal } = abortControllerForXXXQuery
    
    const resA = await fetch('xxxA', { signal });
    // 業務處理
    // ?介面渲染

    const resB = await fetch('xxxB', { signal });
    // 業務處理
    // ?介面渲染
}

我們可以看到:程式碼非常簡單,同時得到了效能增強,瀏覽器將提前停止獲取資料(注:伺服器依舊會處理多次請求,只能通過 loading 來降低伺服器壓力)。

AbortController 移除繫結事件

雖然程式碼很簡單,但是為什麼需要這樣新增一個 AbortController 類而不是直接通過新增 api 來進行中止網路請求操作呢?這樣不是增加了複雜度嗎?筆者開始也是這樣認為的。到後面才發現。AbortController 類雖然較為複雜了,但是它是通用的,因此 AbortController 可以被其他 Web 標準和 JavaScript 庫使用。

const controller = new AbortController()
const { signal } = controller

// 新增事件並傳遞 signal
window.addEventListener('click', () => {
    console.log('can abort')
}, { signal })

window.addEventListener('click', () => {
    console.log('click')
});

// 開始請求並且新增 signal
fetch('xxxA', { signal })

// 移除第一個 click 事件同時中止未完成的請求
controller.abort()

通用的 AbortController

既然它是通用的,那是不是也可以終止業務方法呢。答案是肯定的。先來看看 AbortController 到底為啥能夠通用呢?

AbortController 提供了一個訊號量 signal 和中止 abort 方法,通過這個訊號量可以獲取狀態以及繫結事件。

const controller = new AbortController();

// 獲取訊號量
const { signal } = controller;

// 獲取當前是否已經執行過 abort,目前返回 false
signal.aborted

// 新增事件
signal.addEventListener('abort', () => {
    console.log('觸發 abort')
})

// 新增事件
signal.addEventListener('abort', () => {
    console.log('觸發 abort2')
})


// 中止 (不可以解構直接執行 abort,有 this 指向問題)
// 控制檯列印 觸發 abort,觸發 abort2
controller.abort()

// 當前是否已經執行過 abort,返回 ture
signal.aborted

// 控制檯無反應
controller.abort();

無疑,上述的事件新增了 abort 事件的監聽。綜上,筆者簡單封裝了一下 AbortController。Helper 類如下所示:

class AbortControllerHelper {
    private readonly signal: AbortSignal 

    constructor(signal: AbortSignal) {
        this.signal = signal
        signal.addEventListener('abort', () => this.abort())
    }

    /**
     * 執行呼叫方法,只需要 signal 狀態的話則無需在子類實現
     */
    abort = (): void => {} 

    /**
     * 檢查當前是否可以執行
     * @param useBoolean 是否使用布林值返回
     * @returns 
     */
    checkCanExecution = (useBoolean: boolean = false): boolean => {
        const { aborted } = this.signal
        // 如果使用布林值,返回是否可以繼續執行
        if (useBoolean) {
            return !aborted
        }
        // 直接丟擲異常
        if (aborted) {
            throw new Error('abort has already triggered');
        }
        return true
    }
}

如此,開發者可以新增子類繼承 AbortControllerHelper 並放入 signal。然後通過一個 AbortController 中止多個乃至多種不同事件。

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 部落格下幫忙 star 一下。

部落格地址

參考資料

AbortController MDN