我靠 AbortController 解决了前端请求取消和超时这个老大难

上个月做了个数据可视化大屏,页面里要同时请求十几个接口。用户在大屏间切换时,旧页面的请求还在飞,结果后端日志里堆满了无用调用,前端也时不时报“setState on unmounted component”。一开始我用一个布尔变量标记是否已卸载,在请求回调里判断一下。但后来发现根本拦不住已发出的请求,只是不处理结果而已,网络照走。

后来研究了一下 AbortController,发现这东西才是正解。不仅能真正取消网络请求,还能用来打断任何正在运行的异步任务。这篇文章就当是我的学习笔记,把几个实用的套路面整理下来。

先弄明白 AbortController 是什么

AbortController 是一个浏览器原生对象,它支持通过 signal 去“命令”一个异步操作停止。你可以把它想象成一个遥控器,它有 abort() 按钮。按一下,所有接在同一个 signal 上的任务都会收到“终止”信号。

最简单的用法是配合 fetch。

const controller = new AbortController();
const signal = controller.signal;

console.log(signal.aborted); // false

// 触发取消
controller.abort();

console.log(signal.aborted); // true

当你调用 abort() 后,signal 的状态就变成 aborted,而且会触发一个 abort 事件。如果你把 signal 传给 fetch,fetch 就会在收到 abort 信号时立即中断请求。

用 AbortController 取消一个正在进行的 fetch

const controller = new AbortController();

fetch('https://api.example.com/large-data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => console.log(data))
    .catch(err => {
        if (err.name === 'AbortError') {
            console.log('请求被取消了');
        } else {
            console.error('其他错误', err);
        }
    });

// 比如用户点击了“取消”按钮
button.addEventListener('click', () => controller.abort());

如果请求在未完成时被 abort,fetch 得到的 error 会是 DOMException,并且 name 属性为 `AbortError`。所以你在 catch 里可以专门判断一下。

实现 fetch 超时(老套路升级版)

以前我们实现超时,要么用 Promise.race 搭配 setTimeout,但即便超时了,底层请求还在跑。正确的方式就是 setTimeout 里调用 abort。

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
    const res = await fetch('/api/data', { signal: controller.signal });
    const data = await res.json();
    clearTimeout(timeoutId);
    console.log(data);
} catch (err) {
    clearTimeout(timeoutId);
    if (err.name === 'AbortError') {
        console.warn('请求超时,已中止');
    } else {
        console.error('网络错误', err);
    }
}

其实这个套路已经不算新鲜,但它和 Promise.race 最大的区别是:abort() 真正告诉浏览器“这个请求别要了”,节省了网络资源。

不止能取消 fetch,还能取消任意 Promise

AbortController 不是只给 fetch 用的,任何你自己写的异步函数都能配合它实现“可取消”。关键点在于监听 signal 的 abort 事件,然后在事件回调里 reject。

举个例子,我写了一个模拟延迟任务的 sleep 函数,支持取消:

function cancellableSleep(ms, signal) {
    return new Promise((resolve, reject) => {
        if (signal.aborted) {
            reject(new DOMException('Sleep cancelled', 'AbortError'));
            return;
        }

        const timer = setTimeout(() => {
            resolve('睡醒了');
            cleanup();
        }, ms);

        function onAbort() {
            clearTimeout(timer);
            reject(new DOMException('Sleep cancelled', 'AbortError'));
            cleanup();
        }

        function cleanup() {
            signal.removeEventListener('abort', onAbort);
        }

        signal.addEventListener('abort', onAbort);
    });
}

这段代码就是用 AbortController 让一个普通的 setTimeout 变得可中断。当外部调用 controller.abort(),就会触发 abort 事件,然后我们清除定时器,并让 Promise reject。

有了这个基础,你就可以把任何异步操作包进去。比如一个大文件读取、一个动画循环、一个递归查询等。

实战:可取消的请求队列

我写了一个简单的请求队列,当用户点击“取消全部”时,所有还没完成的请求都会被中断。

class RequestQueue {
    constructor() {
        this.controllers = new Set();
    }

    add(fetchPromise) {
        const controller = new AbortController();
        this.controllers.add(controller);
        // 把 signal 传给 fetchPromise,但 fetchPromise 必须支持接受 signal
        // 这里用闭包把 controller 和返回的 promise 关联起来
        const wrapped = fetchPromise(controller.signal);
        wrapped.finally(() => this.controllers.delete(controller));
        return wrapped;
    }

    cancelAll() {
        this.controllers.forEach(c => c.abort());
        this.controllers.clear();
    }
}

使用的时候:

const queue = new RequestQueue();

function fetchData(url) {
    return queue.add(signal => fetch(url, { signal }).then(res => res.json()));
}

// 发起几个请求
fetchData('/api/users').then(users => console.log(users));
fetchData('/api/posts').then(posts => console.log(posts));

// 用户切换页面,取消所有
queue.cancelAll();

这个队列里的 cancelAll() 一执行,所有 fetch 都会中止,并且 catch 里会收到 AbortError。你再也不用担心旧页面请求影响新页面了。

搭配 streams 实现“可取消的流式读取”

有一种高级玩法是把 AbortController 和 fetch 返回的 Response.body 的 ReadableStream 结合起来,实现中断下载。举个例子:

const controller = new AbortController();
const res = await fetch('/big-file.zip', { signal: controller.signal });
const reader = res.body.getReader();

async function readLoop() {
    while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        console.log('chunk length:', value.length);
    }
}

// 取消读取
controller.abort();

这里 fetch 的 signal 中止后,reader.read() 也会抛错(一个 TypeError 或 AbortError),从而退出循环。这种场景在文件上传下载进度条并允许取消时特别有用。

注意的几个坑

  • signal.aborted 是同步判断的:如果你在创建 Promise 的时候就发现 signal 已经 aborted,应该立即 reject,而不是继续等。我在 cancellableSleep 里已经做了这个检查。
  • addEventListener 后要记得 remove,否则会内存泄漏:我上面的 sleep 在 resolve/reject 后调用 cleanup 移除监听。
  • 多次调用 abort() 是幂等的:无论调用多少次,signal.aborted 始终是 true,不会重复触发事件。
  • fetch 对 abort 的响应不是即时的:理论上说,调用 abort() 后,浏览器会尽快终止网络请求,但不可能真的立刻断开 TCP 连接。所以不要依赖“取消后立即就没有流量”这种假设。

一个完整的可取消上传模拟案例

为了让你看到真实效果,我写一个小 demo:一个“开始上传”按钮和一个“取消”按钮。点击开始后,每隔 200ms 模拟进度增加,当进度达到 100% 时自动完成,如果中途点击取消,则停止更新并提示。

<button id="startBtn">开始上传</button>
<button id="cancelBtn">取消</button>
<progress id="progress" value="0" max="100"></progress>

<script>
const controller = new AbortController();
const progress = document.getElementById('progress');

function upload() {
    return new Promise((resolve, reject) => {
        if (controller.signal.aborted) {
            reject(new DOMException('Upload cancelled', 'AbortError'));
            return;
        }

        let percent = 0;
        const timer = setInterval(() => {
            percent += Math.floor(Math.random() * 10) + 1;
            if (percent >= 100) {
                clearInterval(timer);
                resolve('上传成功');
            } else {
                progress.value = percent;
            }
        }, 200);

        function onAbort() {
            clearInterval(timer);
            reject(new DOMException('Upload cancelled', 'AbortError'));
            progress.value = 0;
        }

        controller.signal.addEventListener('abort', onAbort);
    });
}

document.getElementById('startBtn').addEventListener('click', () => {
    upload()
        .then(msg => alert(msg))
        .catch(err => {
            if (err.name === 'AbortError') {
                console.log('上传被取消');
            }
        });
});

document.getElementById('cancelBtn').addEventListener('click', () => controller.abort());
</script>

注意,这个 demo 里的 controller 在开始上传时创建,如果第一次取消后,controller 已经 aborted,第二次点击开始就直接 reject。所以在真实场景里,每次开始上传前都要 new 一个新的 AbortController。

代替 onbeforeunload 清理旧请求的实践

以前我习惯在 beforeunload 里发一个信号,现在不用了。直接在页面切换逻辑里调用 controller.abort() 即可。如果你用的是 Vue/React,可以在组件卸载时执行 abort。

// Vue 3 中
onUnmounted(() => controller.abort());

React 的 useEffect 清理函数同样适用。AbortController 已经成了我 100% 的项目标配。

总结

AbortController 其实不是什么新鲜玩意,但很多人对它只停留在“配合 fetch 用”的层面。把它想成一个通用的“取消信号”,你就能在任意异步逻辑里用好它。通过监听 abort 事件,你可以随时打断循环、清除定时器、终止请求,这让复杂的前端交互变得非常可控。

如果你也被“请求已发但页面已跳转”之类的问题折磨过,不妨把 AbortController 用起来。代码量不多,收益却很直观。

我靠 AbortController 解决了前端请求取消和超时这个老大难
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 我靠 AbortController 解决了前端请求取消和超时这个老大难 https://www.taomawang.com/web/javascript/2562.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务