文章

取消异步请求方案深度解析

取消异步请求方案深度解析

一句话概括

通过 AbortController 信号机制优雅地中断浏览器中的异步请求,是现代前端处理请求取消、避免内存泄漏和竞态条件的最佳实践。

背景

在前端开发中,异步请求是无处不在的。但你是否遇到过以下场景:

搜索联想——用户的噩梦。 用户在搜索框输入 “react”,你立即发起了请求 A。随后用户继续输入 “react hook”,又发起了请求 B。但请求 A 可能比请求 B 更晚返回,导致搜索结果先显示 “react”,随后被 “react hook” 覆盖——用户看到的是滞后的数据,体验极差。

页面切换——内存泄漏的温床。 用户在列表页点击了某个项目,页面跳转到详情页,但列表页发起的请求还在后台运行并等待回调。当响应返回时,你的 React 组件已经被卸载,setState 会抛出 Cannot update unmounted component 错误,同时造成不必要的内存占用。

重复请求——资源的浪费。 用户反复点击按钮、刷新页面、同一个接口被多次调用——这些都会对服务器造成不必要的压力,也浪费了用户的带宽。

文件上传 / 大文件下载——用户取消后的空耗。 用户选择了一个大文件开始上传,中途后悔了点了取消,但请求还在悄悄发送直到完成,浪费了大量时间和流量。

解决这些问题的核心,就是请求取消机制。本文将系统讲解三种取消方案的原理、用法与最佳实践,重点剖析浏览器原生提供的 AbortController API,以及它在现代前端框架中的实际应用。

概念与定义

AbortController

AbortController 是浏览器原生提供的 Web API(自 DOM 标准引入),用于中止一个或多个 Web 请求(包括 fetch 请求及其他支持 AbortSignal 的异步操作)。它是一个简单的对象,包含:

  • controller.signal:返回一个 AbortSignal 对象实例,该对象可以传递给支持取消的 API(如 fetch)。
  • controller.abort():调用此方法会触发 signal 的 aborted 状态,所有监听该 signal 的操作将被中断。

AbortSignal

AbortSignal 是一个对象,表示一个可选的取消信号。AbortController 实例的 .signal 属性就是 AbortSignal。你可以:

  • 通过 signal.aborted 属性判断是否已中止。
  • 通过 signal.addEventListener('abort', callback) 监听中止事件。
  • signal 传递给 fetch() 等 API,当 signal 中止时,对应的请求会被中断。

请求取消的语义

需要特别注意的是,取消请求的语义因场景而异

  • fetch 取消:浏览器会终止网络连接(尽可能),请求不再等待响应,但服务器端可能已经处理了该请求(幂等性问题需注意)。
  • axios 取消:底层也是基于 AbortController(新版)或 CancelToken(旧版),效果与 fetch 类似。
  • Promise 取消:JavaScript 原生 Promise 没有取消机制,需要通过包装或额外的控制变量来实现”可取消”的异步行为。

最小示例

以下是一个完整可运行的示例,展示如何使用 AbortController 取消一个 fetch 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;

// 发起带取消信号的 fetch 请求
const fetchWithCancel = async (url) => {
  try {
    const response = await fetch(url, { signal });
    const data = await response.json();
    console.log('请求成功:', data);
    return data;
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('请求已被取消');
    } else {
      console.error('其他错误:', error);
    }
    throw error;
  }
};

// 立即发起请求(2秒后自动取消作为演示)
const promise = fetchWithCancel('https://jsonplaceholder.typicode.com/posts/1');

// 模拟:1.5秒后取消请求
setTimeout(() => {
  controller.abort();
  console.log('已调用 abort()');
}, 1500);

运行结果:请求将在 1.5 秒时被中断,控制台输出 “请求已被取消”。这就是 AbortController 的最小工作模型。

核心知识点拆解

1. AbortController API 详解

构造函数

1
const controller = new AbortController();

AbortController 构造函数不接受任何参数,创建一个新的控制器实例。

signal 属性

1
2
3
const { signal } = controller;
console.log(signal instanceof AbortSignal); // true
console.log(signal.aborted); // false(初始状态)

AbortSignal 对象本身是一个事件目标(EventTarget),可以监听 abort 事件:

1
2
3
4
5
6
7
8
9
signal.addEventListener('abort', () => {
  console.log('Signal 已中止!');
  // 执行清理逻辑:取消定时器、停止播放、释放资源等
});

// 也可以使用 onabort 快捷方式
signal.onabort = () => {
  console.log('通过 onabort 回调处理取消');
};

abort() 方法

1
2
3
4
5
controller.abort();
// 调用后:
// 1. signal.aborted === true
// 2. 所有监听 'abort' 事件的回调被执行
// 3. 传递给 fetch 等 API 的 signal 会触发中止

abort() 方法还支持传入一个可选的reason参数(现代浏览器支持),用于传递取消原因:

1
2
3
4
5
controller.abort('用户主动取消');
// 在 abort 事件中可以通过 signal.reason 获取该值
signal.addEventListener('abort', () => {
  console.log('取消原因:', signal.reason); // "用户主动取消"
});

组合多个信号

AbortSignal 还支持通过 AbortSignal.any() 组合多个信号(现代浏览器支持):

1
2
3
// 任一信号中止,整个组合信号就中止
const combinedSignal = AbortSignal.any([signal1, signal2]);
fetch(url, { signal: combinedSignal });

2. axios 取消请求机制

CancelToken(旧方案,已废弃)

axios 早期使用 CancelToken 来取消请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 旧版 axios 取消请求方式(v0.22 之前)
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/api/data', {
  cancelToken: source.token
}).catch(err => {
  if (axios.isCancel(err)) {
    console.log('请求已取消', err.message);
  }
});

// 取消请求
source.cancel('Operation canceled by the user.');

问题:CancelToken 是 axios 自己实现的一套机制,与原生 AbortController 不兼容,迁移成本高。axios 在 v0.22 之后已废弃此方案。

AbortController(新方案,推荐)

新版 axios(v0.22+)原生支持 AbortController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 新版 axios 取消请求方式(推荐)
const controller = new AbortController();

axios.get('/api/data', {
  signal: controller.signal
}).catch(err => {
  if (axios.isCancel(err)) {
    console.log('请求已取消');
  } else if (err.name === 'AbortError') {
    console.log('通过 AbortController 取消');
  }
});

// 取消请求
controller.abort();

批量取消

在大型应用中,可能需要同时取消多个请求。可以使用 AbortController 的组合能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 管理多个请求的控制器
class RequestManager {
  constructor() {
    this.controllers = new Map();
  }

  // 添加请求并返回 controller
  addRequest(id) {
    const controller = new AbortController();
    this.controllers.set(id, controller);
    return controller;
  }

  // 取消单个请求
  cancelRequest(id) {
    const controller = this.controllers.get(id);
    if (controller) {
      controller.abort();
      this.controllers.delete(id);
    }
  }

  // 取消所有请求
  cancelAll() {
    this.controllers.forEach(controller => controller.abort());
    this.controllers.clear();
  }
}

const manager = new RequestManager();

// 发起多个请求
const ctrl1 = manager.addRequest('fetch-list');
const ctrl2 = manager.addRequest('fetch-detail');

// 页面切换时取消所有请求
manager.cancelAll();

3. Promise 取消思路

为什么 Promise 不能被取消?

这是一个经典的误解。Promise 代表的是一个异步操作的最终结果,它是一次性的、不可变的状态容器:

  • Promise 有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。
  • 状态一旦变更,就不可逆转
  • Promise 规范(ES6+)从未定义”取消”操作。

这与 Promise 的设计哲学一致:Promise 代表的是”承诺的结果”,你不能撤销一个已经发生的承诺。

通过包装实现可取消的 Promise

虽然没有原生的取消机制,但可以通过额外逻辑模拟取消行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 方法一:使用控制变量
function cancellablePromise(executor) {
  let isCancelled = false;

  const promise = new Promise((resolve, reject) => {
    executor(
      (value) => {
        if (!isCancelled) resolve(value);
      },
      (error) => {
        if (!isCancelled) reject(error);
      }
    );
  });

  return {
    promise,
    cancel() {
      isCancelled = true;
    }
  };
}

// 使用示例
const { promise, cancel } = cancellablePromise((resolve, reject) => {
  const timer = setTimeout(() => resolve('完成'), 3000);
  // 返回清理函数
  return () => clearTimeout(timer);
});

promise.then(console.log).catch(console.error);
cancel(); // 3秒后不会输出任何内容(被取消了)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 方法二:基于 AbortSignal 的取消 Promise
function promiseWithSignal(promise, signal) {
  return new Promise((resolve, reject) => {
    // 监听 abort 事件
    const abortHandler = () => {
      reject(new DOMException('Aborted', 'AbortError'));
    };

    if (signal?.aborted) {
      reject(new DOMException('Aborted', 'AbortError'));
      return;
    }

    signal?.addEventListener('abort', abortHandler, { once: true });

    // 包装原始 promise
    promise
      .then((value) => {
        signal?.removeEventListener('abort', abortHandler);
        resolve(value);
      })
      .catch((error) => {
        signal?.removeEventListener('abort', abortHandler);
        reject(error);
      });
  });
}

// 使用示例
const controller = new AbortController();
const dataPromise = promiseWithSignal(
  fetch('https://api.example.com/data').then(r => r.json()),
  controller.signal
);

// 取消
controller.abort(); // dataPromise 会被 reject,reason 是 AbortError

4. 取消请求的最佳实践

组件卸载时取消

在 React 中,组件卸载时取消请求是避免内存泄漏的关键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect, useRef } from 'react';

function UserProfile({ userId }) {
  const abortControllerRef = useRef(null);

  useEffect(() => {
    // 每次 userId 变化时,创建新的 controller
    abortControllerRef.current = new AbortController();

    fetch(`/api/users/${userId}`, {
      signal: abortControllerRef.current.signal
    })
      .then(r => r.json())
      .then(data => console.log(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('请求错误:', err);
        }
      });

    // 清理函数:组件卸载时自动取消
    return () => {
      abortControllerRef.current?.abort();
    };
  }, [userId]);

  return <div>用户资料</div>;
}

路由切换时取消

在单页应用(SPA)中,路由切换不等同于组件卸载(如果使用了 React Router 的 <Outlet> 或 Vue 的 keep-alive,组件可能复用)。因此需要在路由守卫中处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// React Router v6 路由级别请求管理
import { useEffect } from 'react';
import { useNavigate, useNavigationType } from 'react-router-dom';

function useCancelOnNavigation() {
  const abortControllerRef = useRef(new AbortController());
  const navigate = useNavigate();

  useEffect(() => {
    const controller = abortControllerRef.current;

    // 监听路由变化事件
    const handleRouteChange = () => {
      // 先取消上一个路由的所有请求
      controller.abort();
      // 创建新的 controller 供下一个路由使用
      abortControllerRef.current = new AbortController();
    };

    // 在 navigation 事件前触发
    return () => {
      controller.abort();
    };
  }, [navigate]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Vue Router 路由守卫取消请求
import axios from 'axios';

const controllerMap = new Map();

router.beforeEach((to, from, next) => {
  // 取消上一个路由的所有请求
  controllerMap.forEach(controller => controller.abort());
  controllerMap.clear();

  // 为新路由创建 controller
  const controller = new AbortController();
  controllerMap.set(to.fullPath, controller);

  // 将 controller 挂载到 axios 全局
  axios.defaults.signal = controller.signal;

  next();
});

竞态请求处理

当同一个请求可能被多次触发时(如搜索联想),需要确保只处理最后一次请求的响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 方案一:请求 ID 管理(debounce 配合)
function createRequestManager() {
  let currentController = null;
  let requestId = 0;

  return {
    async fetch(url, options = {}) {
      // 取消上一次请求
      if (currentController) {
        currentController.abort();
      }

      // 创建新请求
      currentController = new AbortController();
      const thisRequestId = ++requestId;

      try {
        const response = await fetch(url, {
          ...options,
          signal: currentController.signal
        });

        // 检查请求是否过期(用户可能已经发起了更新的请求)
        if (thisRequestId !== requestId) {
          console.log('请求已过期,忽略结果');
          return;
        }

        const data = await response.json();
        return data;
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('请求被后续请求取消');
        }
        throw error;
      }
    },

    cancelAll() {
      if (currentController) {
        currentController.abort();
        currentController = null;
      }
    }
  };
}

实战案例

React 搜索联想组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { useState, useEffect, useRef, useCallback } from 'react';

function SearchAutocomplete({ onSelect }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const abortControllerRef = useRef(null);
  const debounceRef = useRef(null);

  const search = useCallback(async (searchQuery) => {
    // 取消上一次未完成的请求
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    if (!searchQuery.trim()) {
      setResults([]);
      setLoading(false);
      return;
    }

    // 创建新的 AbortController
    abortControllerRef.current = new AbortController();
    setLoading(true);

    try {
      const response = await fetch(
        `https://api.example.com/search?q=${encodeURIComponent(searchQuery)}`,
        { signal: abortControllerRef.current.signal }
      );

      if (!response.ok) throw new Error('请求失败');

      const data = await response.json();
      setResults(data.results || []);
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('搜索失败:', error);
        setResults([]);
      }
    } finally {
      setLoading(false);
    }
  }, []);

  const handleInputChange = (e) => {
    const value = e.target.value;
    setQuery(value);

    // 防抖:停止输入 300ms 后才发起请求
    if (debounceRef.current) {
      clearTimeout(debounceRef.current);
    }

    debounceRef.current = setTimeout(() => {
      search(value);
    }, 300);
  };

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
      if (debounceRef.current) {
        clearTimeout(debounceRef.current);
      }
    };
  }, []);

  return (
    <div className="search-autocomplete">
      <input
        type="text"
        value={query}
        onChange={handleInputChange}
        placeholder="搜索..."
      />
      {loading && <span className="loading">加载中...</span>}
      <ul className="results">
        {results.map((item) => (
          <li key={item.id} onClick={() => onSelect(item)}>
            {item.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

Vue 路由切换取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// useFetch.js - Vue Composition API 请求管理 hook
import { ref, onUnmounted } from 'vue';
import axios from 'axios';

export function useFetch() {
  const abortController = ref(null);

  const request = async (url, options = {}) => {
    // 取消上一次请求(如果存在)
    if (abortController.value) {
      abortController.value.abort();
    }

    abortController.value = new AbortController();

    try {
      const response = await axios.get(url, {
        signal: abortController.value.signal,
        ...options
      });
      return response.data;
    } catch (error) {
      if (axios.isCancel(error) || error.name === 'AbortError') {
        console.log('请求已被取消');
        return null;
      }
      throw error;
    }
  };

  const cancel = () => {
    abortController.value?.abort();
  };

  // 组件卸载时自动取消
  onUnmounted(() => {
    cancel();
  });

  return { request, cancel };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 在 Vue 组件中使用
// UserList.vue
import { defineComponent, ref } from 'vue';
import { useFetch } from '@/composables/useFetch';

export default defineComponent({
  setup() {
    const users = ref([]);
    const { request, cancel } = useFetch();

    const loadUsers = async () => {
      const data = await request('/api/users');
      if (data) users.value = data;
    };

    // 路由切换时(beforeRouteLeave)可以手动取消
    // beforeRouteLeave(() => {
    //   cancel();
    // });

    loadUsers();

    return { users };
  }
});

重复请求自动取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// RequestDeduplicator - 请求去重与自动取消工具
class RequestDeduplicator {
  constructor() {
    this.pendingControllers = new Map();
  }

  // 生成请求的唯一 key
  generateKey(url, options = {}) {
    return `${url}:${JSON.stringify(options)}`;
  }

  async request(url, options = {}) {
    const key = this.generateKey(url, options);

    // 如果存在相同请求,先取消旧的
    if (this.pendingControllers.has(key)) {
      const oldController = this.pendingControllers.get(key);
      oldController.abort(`[去重] 取消旧的 ${key} 请求`);
      console.log(`取消重复请求: ${key}`);
    }

    // 创建新请求的 controller
    const controller = new AbortController();
    this.pendingControllers.set(key, controller);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });

      // 请求完成后移除记录
      this.pendingControllers.delete(key);

      return response;
    } catch (error) {
      this.pendingControllers.delete(key);

      if (error.name === 'AbortError') {
        // 区分"主动取消"和"去重取消"
        if (error.message.startsWith('[去重]')) {
          console.log('重复请求被新请求替代');
        }
        return null;
      }

      throw error;
    }
  }

  // 取消所有请求
  cancelAll() {
    this.pendingControllers.forEach(controller => controller.abort());
    this.pendingControllers.clear();
  }
}

const deduplicator = new RequestDeduplicator();

// 多次调用同一个接口,只有最新的会生效
deduplicator.request('/api/users');       // 旧请求
deduplicator.request('/api/users');       // 旧请求被取消
deduplicator.request('/api/users');       // 这个是最终生效的

底层原理

AbortController 在浏览器中的实现原理

AbortControllerAbortSignal 是浏览器 DOM 标准的一部分,其实现位于浏览器的底层 C++ 代码中。以 Chromium 为例:

对象模型

1
2
3
4
5
6
7
AbortController
  └── signal: AbortSignal
          ├── aborted: boolean
          ├── reason: any
          └── eventTarget (继承自 EventTarget)

AbortSignal 继承自 EventTarget,支持 addEventListener/removeEventListener

核心状态机

1
2
3
4
5
6
7
8
9
[Ready]
    │
    │ controller.abort() 被调用
    ▼
[Aborting] ──→ signal.aborted = true
    │
    │ 所有监听 'abort' 事件被触发
    ▼
[Aborted]

信号传递机制

controller.abort() 被调用时,浏览器内部会执行以下步骤:

  1. signal.aborted 标记为 true
  2. 设置 signal.reason 为传入的 reason(默认是 “AbortError” DOMException)
  3. 同步触发 signal 上的 abort 事件(调用所有注册的 abort 监听器)
  4. 对于正在进行中的 fetch 请求,浏览器会尝试通过关闭底层 TCP 连接来终止网络通信

fetch 取消的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch(url, { signal })
    │
    ├─→ 检查 signal.aborted(发起请求前)
    │        └─→ 若是,直接抛出 AbortError
    │
    └─→ 发起网络请求(HTTP 请求已发送)
            │
            ├─→ signal.addEventListener('abort', handler)
            │        └─→ 浏览器关闭 TCP 连接
            │
            └─→ 请求被中止
                    │
                    └─→ fetch Promise 以 AbortError reject

关键点:HTTP 请求一旦发出,服务器可能已经处理了该请求。取消只是客户端停止等待响应。如果请求是幂等的(GET、PUT、DELETE),重新发起请求通常是安全的;如果是非幂等的(POST),需要后端配合做幂等处理。

取消后资源释放

正确取消后,以下资源会被释放:

资源类型释放情况说明
TCP 连接✅ 浏览器关闭连接不再等待响应
内存(响应数据)✅ 不再分配未完成的数据传输被中断
定时器❌ 需要手动清理setTimeout/setInterval 不受 AbortController 影响
流(ReadableStream)✅ 自动取消fetch 的 body 流会被取消
DOM 更新❌ 需要检查需确保组件已卸载或做了状态守卫
WebSocket 连接❌ 需手动关闭AbortController 不支持 WebSocket 取消
文件句柄❌ 需手动释放如 FileReader、MediaStreamTrack

重要提醒:AbortController 不会自动取消 setTimeoutsetInterval、WebSocket 等不受支持的操作。如果你需要在取消请求时同时清理定时器,应该在 abort 事件处理函数中手动清理:

1
2
3
4
5
6
7
8
9
const controller = new AbortController();
const timer = setTimeout(() => console.log('done'), 5000);

controller.signal.addEventListener('abort', () => {
  clearTimeout(timer); // 手动清理定时器
  console.log('请求取消,清理定时器');
});

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

高频面试题解析

Q1: AbortController 的工作原理是什么?

:AbortController 是浏览器原生提供的 Web API,用于中止一个或多个 Web 请求或异步操作。其核心由两部分组成:

AbortController:控制器对象,提供 signal 属性和 abort() 方法。

1
2
const controller = new AbortController();
const { signal } = controller;

AbortSignal:信号对象,继承自 EventTarget。当 controller.abort() 被调用时,signal 的 aborted 属性变为 true,并同步触发 abort 事件。

1
2
3
4
5
6
// 工作流程:
// 1. 创建 controller,得到 signal
// 2. 将 signal 传递给 fetch 等支持取消的 API
// 3. API 内部监听 signal 的 abort 事件
// 4. 调用 controller.abort() 触发中止
// 5. fetch 等 API 收到信号,停止等待并 reject AbortError

浏览器内部通过事件分发机制实现:当 abort() 被调用时,浏览器会将 aborted 标志设为 true,然后同步触发所有注册在 AbortSignal 上的 abort 事件监听器。对于 fetch,浏览器还会在底层关闭对应的 TCP 连接,确保请求被真正中止。

Q2: 如何在 React 组件卸载时取消所有未完成的请求?

:核心思路是在 useEffect 的清理函数中调用 abort(),并在组件卸载前中止所有未完成的请求。以下是完整的自定义 Hook 方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useEffect, useRef, useCallback } from 'react';

// useCancelOnUnmount - 组件卸载时自动取消请求的 Hook
function useCancelOnUnmount() {
  const abortControllerRef = useRef(null);

  // 获取当前的 signal(首次调用时创建 controller)
  const getSignal = useCallback(() => {
    if (!abortControllerRef.current) {
      abortControllerRef.current = new AbortController();
    }
    return abortControllerRef.current.signal;
  }, []);

  // 组件卸载时自动取消
  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
        abortControllerRef.current = null;
      }
    };
  }, []);

  return { getSignal };
}

// 使用示例
function UserProfile({ userId }) {
  const { getSignal } = useCancelOnUnmount();

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: getSignal()
        });
        const data = await response.json();
        console.log('用户数据:', data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('请求失败:', error);
        }
      }
    };

    fetchUser();
  }, [userId, getSignal]);

  return <div>用户资料页面</div>;
}

对于多个请求的管理,可以扩展为请求管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 更完整的请求管理 Hook
function useAsyncRequest() {
  const controllerRef = useRef(new AbortController());
  const requestsRef = useRef([]);

  useEffect(() => {
    return () => {
      controllerRef.current.abort();
    };
  }, []);

  const request = useCallback(async (url, options = {}) => {
    const response = await fetch(url, {
      ...options,
      signal: controllerRef.current.signal
    });
    return response.json();
  }, []);

  return { request };
}

Q3: Promise 为什么不能被取消?如何实现可取消的 Promise?

Promise 的不可取消性是由其设计目标决定的。Promise 代表的是一个异步操作的最终结果,是一种一次性、不可变的状态容器

  • pendingfulfilled:一旦成功就不能变回 pending
  • pendingrejected:一旦失败就不能变回 pending
  • 状态变更不可逆

这种设计是为了保证 Promise 链的可预测性和一致性。如果 Promise 可以被取消,.then() 链的行为将变得不确定。

实现可取消 Promise 的方法

方法一:外部取消标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function makeCancellable(promise) {
  let isCancelled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise
      .then((value) => {
        if (isCancelled) {
          reject({ isCancelled: true, value });
        } else {
          resolve(value);
        }
      })
      .catch((error) => {
        if (isCancelled) {
          reject({ isCancelled: true, error });
        } else {
          reject(error);
        }
      });
  });

  return {
    promise: wrappedPromise,
    cancel(reason = 'Cancelled') {
      isCancelled = true;
      return reason;
    },
    get cancelled() {
      return isCancelled;
    }
  };
}

// 使用示例
const { promise, cancel } = makeCancellable(
  new Promise(resolve => setTimeout(() => resolve('data'), 3000))
);

promise.then(console.log).catch(err => {
  if (err.isCancelled) console.log('被取消:', err);
});

setTimeout(() => cancel('用户取消'), 1000);

方法二:基于 AbortSignal 的标准方案(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function toCancellable(promise, signal) {
  return new Promise((resolve, reject) => {
    const rejectIfAborted = () => {
      reject(new DOMException('Aborted', 'AbortError'));
    };

    if (signal?.aborted) {
      rejectIfAborted();
      return;
    }

    const abortHandler = () => {
      reject(new DOMException('Aborted', 'AbortError'));
    };

    signal?.addEventListener('abort', abortHandler, { once: true });

    promise
      .then((value) => {
        signal?.removeEventListener('abort', abortHandler);
        resolve(value);
      })
      .catch((error) => {
        signal?.removeEventListener('abort', abortHandler);
        reject(error);
      });
  });
}

// 使用示例
const controller = new AbortController();

const dataPromise = toCancellable(
  fetch('https://api.example.com/data').then(r => r.json()),
  controller.signal
);

dataPromise
  .then(console.log)
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Promise 已被取消');
    }
  });

// 取消
controller.abort();

方法三:利用 Promise.race 实现超时取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function withTimeout(promise, timeoutMs, timeoutError = new Error('Timeout')) {
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(timeoutError), timeoutMs)
  );

  return Promise.race([promise, timeoutPromise]);
}

// 使用示例:5秒超时
const result = await withTimeout(
  fetch('/api/slow-endpoint').then(r => r.json()),
  5000,
  new Error('请求超时')
);

Q4: 搜索联想场景如何避免竞态问题?

:搜索联想是最经典的竞态问题场景。用户快速输入 “abc”,可能同时发出三个请求:请求 A(”a”)、请求 B(”ab”)、请求 C(”abc”)。如果请求 A 比请求 C 更晚返回,用户会看到错误的搜索结果。

完整解决方案(结合防抖 + AbortController):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// SearchManager - 搜索请求竞态处理管理器
class SearchManager {
  constructor() {
    this.controller = null;
    this.requestId = 0;
    this.debounceTimer = null;
  }

  search(query, options = {}) {
    const { onSuccess, onError, onLoading, debounceMs = 300 } = options;

    // 清除之前的防抖定时器
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    // 设置新的防抖定时器
    this.debounceTimer = setTimeout(async () => {
      await this.executeSearch(query, onSuccess, onError, onLoading);
    }, debounceMs);
  }

  async executeSearch(query, onSuccess, onError, onLoading) {
    // 取消上一次请求
    if (this.controller) {
      this.controller.abort();
    }

    // 创建新的 controller
    this.controller = new AbortController();
    const currentRequestId = ++this.requestId;

    onLoading?.(true);

    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`,
        {
          signal: this.controller.signal,
          headers: { 'X-Request-Id': currentRequestId }
        }
      );

      // 关键:检查这个请求是否已过时
      // 如果用户在这期间输入了新内容,requestId 已经增加
      if (currentRequestId !== this.requestId) {
        console.log(`请求 ${currentRequestId} 已过期,忽略结果`);
        return;
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      onSuccess?.(data);
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log(`请求 ${currentRequestId} 被取消`);
        return; // 不触发 error 回调
      }

      if (currentRequestId === this.requestId) {
        // 只有最新的请求才触发 error
        onError?.(error);
      }
    } finally {
      if (currentRequestId === this.requestId) {
        onLoading?.(false);
      }
    }
  }

  cancel() {
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }
    if (this.controller) {
      this.controller.abort();
    }
  }
}

// 在 React 组件中使用
function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const searchManagerRef = useRef(new SearchManager());

  useEffect(() => {
    return () => {
      searchManagerRef.current.cancel();
    };
  }, []);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);

    searchManagerRef.current.search(value, {
      onSuccess: (data) => setResults(data),
      onError: (err) => console.error('搜索失败', err),
      onLoading: setIsLoading,
      debounceMs: 300
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isLoading && <span>搜索中...</span>}
      <ul>
        {results.map(r => <li key={r.id}>{r.name}</li>)}
      </ul>
    </div>
  );
}

关键技巧总结

  1. 请求 ID 标记:每次发起新请求时递增 ID,结果返回时检查 ID 是否匹配。
  2. 防抖(Debounce):避免每个字符变化都发请求。
  3. AbortController 取消:确保旧请求被中断,减少不必要的网络和计算。
  4. 状态守卫:在 finally 中检查是否是最新请求,再更新 loading 状态。

总结与扩展

核心要点回顾

  1. AbortController 是浏览器原生标准,是取消 fetch 请求的首选方案,兼容性好、性能优秀。
  2. axios v0.22+ 原生支持 AbortController,CancelToken 已废弃,应使用 signal 参数。
  3. Promise 本身不可取消,但可以通过包装、状态标志或 AbortSignal 来实现可取消的异步行为。
  4. 组件卸载 + 路由切换是取消请求的两个关键时机,必须配合 useEffect 清理函数或路由守卫来处理。
  5. 搜索联想是最经典的竞态场景,需要防抖 + 请求 ID 标记 + AbortController 三管齐下。

AbortController 在非请求场景的应用

AbortController 的 signal 并不局限于网络请求,任何支持 AbortSignal 的异步操作都可以被取消:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 取消 Web Animations API 动画
const animation = element.animate([...], { duration: 2000 });
controller.signal.addEventListener('abort', () => {
  animation.cancel();
});

// 取消媒体流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
controller.signal.addEventListener('abort', () => {
  stream.getTracks().forEach(track => track.stop());
});

// 取消 async iterator(ReadableStream)
const reader = response.body.getReader();
controller.signal.addEventListener('abort', () => {
  reader.cancel();
});

// 取消 EventTarget 上的事件监听(通过 AbortController)
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
// 稍后移除监听
controller.abort();

与 RxJS takeUntil 的对比

RxJS 提供了强大的响应式取消能力,与 AbortController 对比如下:

特性AbortControllerRxJS takeUntil
依赖浏览器原生 API需要引入 rxjs 库
适用场景网络请求、EventTarget所有 Observable 流
语法复杂度简单直接需要学习 RxJS 操作符
组合能力AbortSignal.any()更多操作符(combineLatest 等)
取消语义中止信号,清理逻辑需手动自动完成(complete 通知)
学习曲线平缓陡峭
1
2
3
4
5
6
7
8
9
10
11
// RxJS takeUntil 写法(对比参考)
import { fromEvent, takeUntil } from 'rxjs';

const destroy$ = fromEvent(window, 'beforeunload');

fetch('/api/data')
  .then(r => r.json())
  .pipe(
    takeUntil(destroy$) // 页面卸载时自动取消
  )
  .subscribe(console.log);

如果你的项目已经使用了 RxJS,takeUntil 是优雅的取消方案;如果项目较轻量,AbortController 是更简洁的选择。


掌握好请求取消机制,不仅能提升用户体验(避免错误数据展示)、优化性能(减少无效网络请求),更是防止内存泄漏和构建健壮前端应用的基础。希望本文能帮助你深入理解这一关键技能,在实际项目中游刃有余。

本文由作者按照 CC BY 4.0 进行授权