可以中斷的非同步操作

邊城發表於2021-11-18

前面我們聊到了可能超時的非同步操作,其中提到對 fetch() 非同步操作的“中斷”處理。這次我們就來聊一聊“中斷”非同步操作。

由於 JavaScript 的單執行緒特性,能在 JavaScript 中進行的非同步場景其實不多,大概有如下一些:

  1. setTimeout() / setInterval()
  2. 事件
  3. Ajax
  4. 部分對 Native 方法的呼叫
  5. ……

中斷 Ajax 操作

Ajax 處理基本上也可以歸為“對 Native 方法呼叫”一類,因為基本上都是由瀏覽器提供的 XMLHttpRequest 或者 fetch() 來實現的。所以 Axios、fetch() 和 jQuery.ajax() 等,自己都提供了 abort 的介面。中斷 fetch() 已經在 「可能超時的非同步操作」中已經有示例了,這裡再給個 jQuery 和 Axios 的示例。

jQuery 的 jqXHR 提供了 .abort()

// url 是在 beeceptor.com 上做的一個要 3 秒後響應的 GET 介面
const fetching = $.ajax(url, { type: "get" })
    .done(() => console.log("看不到這句話"))
    .fail(() => console.log("但是能看到這句"));

setTimeout(() => fetching.abort(), 1000);   // 1 秒後中斷請求

也可以用 await 的方式來寫:

(async () => {
    try {
        const fetching = $.ajax(url, { type: "get" });
        setTimeout(() => fetching.abort(), 1000);
        await fetching;
        console.log("看不到這句話");
    } catch (err) {
        console.log("但是能看到這句");
    }
})();

中斷 Axios 請求

Axios 提供了 CancelToken 來實現中斷,這個模式和在前文中中斷 fetch()AbortControllerAbortSignal 是同樣的道理。

// Node 中需要 import;瀏覽器中直接引用的 axios.js 會有全域性 axios 物件
import axios from "Axios";

(async () => {
    const { CancelToken } = axios;
    const source = CancelToken.source();    // 建立一箇中斷源

    try {
        setTimeout(() => source.cancel("1 秒中斷"), 1000);
        const data = await axios.get(
            url,    // beeceptor.com 上做的一個要 3 秒後響應的 GET 介面
            {
                cancelToken: source.token   // 把 token 傳進去
            }
        );
        console.log("因為超時中斷,看不到這句話");
    } catch (err) {
        if (axios.isCancel(err)) {
            console.log("超時中斷了 Axios 請求", err);
            // 超時中斷了 Axios 請求 Cancel { message: '1 秒中斷' }
        } else {
            console.log("發生其他錯誤");
        }
    }
})();

中斷定時器和事件

setTiemout() / setInteraval() 的中斷,可以說是比較簡單,使用 clearTimeout() / clearInterval() 就可以辦到。

而中斷事件 —— 直接登出事件處理函式就好了。不過需要注意的是,部分事件框架在登出事件的時候需要提供註冊的那個事件處理函式才有登出,比如 removeEventListener() 就是需要提供原處理函式;而 jQuery 通過 .off()登出事件處理函式時只需要提供名稱和 namespace(如果有的話)即可。

不過當這些過程封裝在 Promise 中的時候,記得要在“登出”處理的時候 reject(當然,如果約定好了 resolve 一個特殊值也可以)。以 setTimeout 為例:

async function delayToDo() {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            resolve("延遲後拿到這段文字");
        }, 5000);
        setTimeout(() => {
            clearTimeout(timer);
            reject("超時了");
        }, 1000);
    });
}

可以說,這段程式碼是相當沒用 —— 誰會沒事在設定一個延時任務之後立即設定一個更短的超時操作?

帶出一個 abort() 函式

如果我們需要設定一個延時任務,並在後面某種情況下中斷它,正確的做法是把 timer 帶到延時任務函式外面去,以便其他地方使用。而更好的辦法是帶出一個 abort() 函式,使語義更準確。

function delayToDo(ms) {
    let timer;
    const promise = new Promise(resolve => {
        timer = setTimeout(() => {
            resolve("延遲後拿到這段文字");
        }, ms);
    });
    promise.abort = () => clearTimeout(timer);
    return promise;
}

const promise = delayToDo(5000);

// 在其他業務邏輯中通過 promise.abort() 來中斷延時任務
setTimeout(() => promise.abort(), 1000);

用轉運箱物件把 abort() 運出來

注意 delayToDo() 不是一個 async 函式。如果使用 async 修飾,我們是拿不到 returnpromise 的。在確實需要用 async 修飾的情況下,只好變通一下,通過一個“轉運箱”物件把 abort() 帶出來。

function delayToDo(ms, transferBox) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            resolve("延遲後拿到這段文字");
        }, ms);

        // 如果有轉運箱,就把 abort 函式給運出去
        if (transferBox) transferBox.abort = (message) => {
            clearTimeout(timer);
            reject({ abort: true, message });
        };
    });
}

// 定義一個轉運箱物件,注意作用域(所以定義在 IIFE 外面)
const box = {};

(async () => {
    try {
        const s = await delayToDo(5000, box);
        console.log("不會輸出這句", s);
    } catch (err) {
        console.log("出錯", err);
    }
})();

// 1 秒後通過轉運出來的 abort 中斷延時操作
setTimeout(() => box.abort("超時中斷"), 1000);

// 1 秒後會輸出下面這行
// 出錯 { abort: true, message: '超時中斷' }

使用 AbortController & AbortSignal

使用轉運箱的操作,看起來和 Axios 的 CancelToken 很像。只不過 CancelToken 是把訊號帶到非同步操作裡面去,而轉運箱是把中斷函式帶到外面來。AbortControllerCanelToken 的原理差不多,現代環境 (Chrome 66+,Nodejs 15+) 都有 AbortController,不妨嘗試用用這個專業工具類。

function delayToDo(ms, signal) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => resolve("延遲後拿到這段文字"), ms);

        if (signal) {
            // 如果 AbortController 發出了中斷訊號,會觸發 onabort 事件
            signal.onabort = () => {
                clearTimeout(timer);
                reject({ abort: true, message: "timeout" });
            };
        }
    });
}

const abortController = new AbortController();
(async () => {
    try {
        const s = await delayToDo(5000, abortController.signal);
        console.log("不會輸出這句", s);
    } catch (err) {
        console.log("出錯", err);
    }
})();

setTimeout(() => abortController.abort(), 1000);

這段程式碼和上面那段其實沒有多大區別,只不過使用了 AbortController 之後語義更明確一些。畢竟它是專門用來幹“中斷”這件事的。但遺憾的是 AbortControllerabort() 方法不帶任何引數,不能把中斷訊息(原因)帶進去。

實現一個 MyAbort

AbortController 還在實驗階段,並不是很成熟,所以有一些不理想也很正常。但是這個原理說起來其實不難,不妨自己實現一個。

這段 JavaScript 程式碼使用 ESM 語法、Private field、Field declarations、Symbol 等。若不明白請查 MDN
const ABORT = Symbol("abort");

export class MyAbortSingal {
    #onabort;
    aborted;
    reson;

    // 使用模組內未匯出的 ABORT Symbol 來定義,目的有兩個
    // 1) 避免被使用者呼叫
    // 2) 給 MyAbort 呼叫(如果做成 private field,MyAbort 就不能訪問)
    [ABORT](reson) {
        this.reson = reson;
        this.aborted = true;
        if (this.#onabort) {
            this.#onabort(reson);
        }
    }

    // 允許設定 onabort,但不允許獲取(也不需要獲取)
    set onabort(fn) {
        if (typeof fn === "function") {
            this.#onabort = fn;
        }
    }
}

export class MyAbort {
    #signal;

    constructor() {
        this.#signal = new MyAbortSingal();
    }

    // 允許獲取 signal,但不允許設定
    get signal() { return this.#signal; }

    abort(reson) {
        this.#signal[ABORT](reson);
    }
}

MyAbort 可以直接替換掉前面示例程式碼中的 AbortController。而且在呼叫 .abort() 的時候還可以傳入原因,變化的程式碼如下:

import { MyAbort } from "./my-abort.js";

function delayToDo(ms, signal) {
    return new Promise((resolve, reject) => {
        ...
        reject({ abort: true, message: signal.reson });
        ...    
    });
}

const abortController = new MyAbort();
...

setTimeout(() => abortController.abort("一秒超時"), 1000);

更細緻地中斷

對於定時器和事件,主要是採用了“登出”的手段來進行中斷。但實際上這個粒度可能有點粗。

中斷死迴圈

如果有一件事情,需要不斷地去嘗試,直到成功為止。這種事情通常會寫成一個死迴圈,直至達到目的才會跳出迴圈。如果不是 JavaScript,比如 Java 或者 C#,一般會開個新執行緒來幹,然後在每次迴圈的時候都檢查一下是否存在 abort 訊號,如果有就中斷。

JavaScript 是單執行緒,要寫死迴圈就是真死。不過有就變通的辦法 —— 使用 setInterval() 來週期性的處理,就像一個迴圈一樣,不斷地隔一段時間就去處理一次,直到使用 clearInterval() 來結束掉(就像是退出迴圈)。跟迴圈一產,在週期性處理的過程中,是可以判斷 abort 訊號的,就像這樣:

function loop(signal) {
    const timer = setInterval(
        () => {
            if (signal.aborted) {
                clearInterval(timer);
                return;
            }
            // TODO 業務處理
        },
        200
    );
    signal.onabort = () => clearInterval(timer);
}

const ac = new AbortController();
loop(ac.signal);

你看,死迴圈並不是真死,還是要留中斷介面的。

中斷複雜的多步驟非同步操作

除了迴圈,還有一些非同步操作也是很花時間的。比如說,處理某個業務需要跟後端多次互動:

  1. 通過使用者輸入的資訊進行認證
  2. 拿到認證之後去獲取使用者基本資訊
  3. 從使用者資訊的部門編號去拿部門資訊
  4. 根據部門資訊去獲取本部門相關的資料

這裡舉例的業務操作有 這麼多步驟,其實是可以跟後端協商簡化的,但它不在我們今天討論的範圍內。實際的業務中也確實會存在不少需要多個步驟來處理完成的情況。我們現在要討論的是怎麼中斷。先看看這個業務過程的示例程式碼:

async function longBusiness() {
    const auth = await remoteAuth();
    const userInfo = await fetchUserInfo(auth.token);
    const department = await fetchDepartment(userInfo.departmentId);
    const data = await fetchData(department);
    dealWithData();
}

語句不多,但很耗時。如果一次互動需要花 1 秒,這個操作完成至少需要 4 秒。如果使用者在第 2 秒的時候想中斷,怎麼辦?

其實和上面處理 setInterval() 一樣,適當插入對 abort 訊號的檢查就好:

async function sleep(ms) {
    return new Promise(resolve => setTimeout(() => {
        console.log(`完成 ${ms} 任務`);
        resolve();
    }, ms));
}

// 模擬非同步函式
const remoteAuth = () => sleep(1000);
const fetchUserInfo = () => sleep(2000);
const fetchDepartment = () => sleep(3000);
const fetchData = () => sleep(4000);

async function longBusiness(signal) {
    try {
        const auth = await remoteAuth();
        checkAbort();
        const userInfo = await fetchUserInfo(auth?.token);
        checkAbort();
        const department = await fetchDepartment(userInfo?.departmentId);
        checkAbort();
        const data = await fetchData(department);
        checkAbort();
        // TODO 處理資料
    } catch (err) {
        if (err === signal) {
            console.log("中斷退出");
            return;
        }
        // 其他情況是業務錯誤,應該進行容錯處理,或者丟擲去給外層邏輯處理
        throw err;
    }

    function checkAbort() {
        if (signal.aborted) {
            // 丟擲的功能在 catch 中檢查出來就行,最好定義一個 AbortError
            throw signal;
        }
    }
}

const ac = new AbortController();
longBusiness(ac.signal);

setTimeout(() => {
    ac.abort();
}, 2000);

longBusiness() 在每一次執行了耗時的操作就進行一個 abort 訊號檢查。示例中如果檢查到 abort 資訊,就會丟擲異常來中斷程式。使用丟擲異常的方法來中斷程式會比較方便,如果不喜歡也可以用 if 分支來處理,比如 if (signal.aborted) { return; }

這段示例程式會完成兩個耗時任務,因為請求中斷的時候,第二個耗時任務正在進行中,要它結束之後才有下一次 abort 資訊檢查。

小結

總的來說,中斷並不難。但是我們在寫程式的時候,往往會忘掉對耗時程式進行可能需要的中斷處理。必要的中斷處理可以節約計算資源,提升使用者體驗。有合適的業務場景不妨試試!

相關文章