前面我们聊到了可能超时的异步操作,其中提到对 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 还在实验阶段,并不是很成熟,所以有一些不理想也很正常。但是这个原理说起来其实不难,不妨自己实现一个。

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 信息检查。

小结

总的来说,中断并不难。但是我们在写程序的时候,往往会忘掉对耗时程序进行可能需要的中断处理。必要的中断处理可以节约计算资源,提升用户体验。有合适的业务场景不妨试试!

03-05 18:21