在前端项目中,为了解决请求竞态问题(Race Condition)并优化用户体验,一种常见的策略是在发起新的相同类型的请求之前,取消(abort)之前未完成的同类型请求。这尤其适用于那些用户快速输入触发的请求,例如搜索建议、表单验证等,因为我们通常只关心最新请求的结果。
实现这一目标的核心是使用 AbortController
API。
核心原理
-
AbortController
: 这是一个 Web API,提供了一个AbortSignal
对象,可以用于取消一个或多个 Web 请求。new AbortController()
创建一个控制器实例。controller.signal
: 获取一个信号量,将其传递给fetch
或XMLHttpRequest
的signal
选项。controller.abort()
: 调用此方法会触发signal
上的abort
事件,所有监听该信号的请求都会被取消。
-
请求管理 : 我们需要一个机制来存储当前正在进行的请求的
AbortController
实例,并根据请求的"类型"进行管理。当新的同类型请求到来时,先找到并取消旧的请求,再发起新的请求。
统一封装方案
我们可以创建一个 RequestManager
类或函数,来统一管理和封装请求的取消逻辑。
RequestManager
类实现
js
class RequestManager {
// 用于存储每个"类型"的请求对应的 AbortController 实例
// Key: 请求类型标识 (例如:'searchUsers', 'getProductDetails')
// Value: AbortController 实例
private pendingRequests: Map<string, AbortController>;
constructor() {
this.pendingRequests = new Map();
}
/**
* 取消指定类型的所有未完成请求
* @param requestType - 请求的类型标识
*/
abort(requestType: string): void {
if (this.pendingRequests.has(requestType)) {
const controller = this.pendingRequests.get(requestType);
if (controller) {
controller.abort(); // 调用 abort 方法取消请求
console.log(`Aborted previous request for type: ${requestType}`);
}
this.pendingRequests.delete(requestType); // 从 Map 中移除
}
}
/**
* 取消所有未完成的请求
*/
abortAll(): void {
this.pendingRequests.forEach((controller, requestType) => {
controller.abort();
console.log(`Aborted all pending request for type: ${requestType}`);
});
this.pendingRequests.clear();
}
/**
* 封装 fetch 请求,实现自动取消同类型旧请求的逻辑
* @param requestType - 请求的类型标识,用于区分和管理同类型请求
* @param url - 请求的 URL
* @param options - fetch 请求的选项 (如 method, headers, body 等)
* @returns Promise<Response>
*/
async fetchWrapper<T>(
requestType: string,
url: string,
options?: RequestInit
): Promise<T> {
// 1. 在发起新请求之前,先取消同类型的所有旧请求
this.abort(requestType);
// 2. 创建一个新的 AbortController 实例
const controller = new AbortController();
this.pendingRequests.set(requestType, controller); // 将新的 controller 存储起来
// 3. 将 signal 传递给 fetch 请求
const fetchOptions: RequestInit = {
...options,
signal: controller.signal, // 绑定 AbortSignal
};
try {
const response = await fetch(url, fetchOptions);
// 4. 请求完成后(成功或失败),从 pendingRequests 中移除
// 注意:这里必须在 finally 块中移除,确保无论成功失败都清理
// 但为了避免在请求被取消时,又立即移除controller,导致无法捕获 AbortError,
// 更好的做法是在 finally 块中判断是否是 AbortError,如果不是,则移除。
// 或者更简单,在 catch 中处理 AbortError,其他情况在 finally 中清理
// 这里我们直接在 finally 中清理,因为 abort 已经在前面处理了。
// 如果请求成功,则它不再是 pending 状态。
// 如果请求失败(非 AbortError),则它也不再是 pending 状态。
// 如果请求被 abort,则 AbortError 会被捕获,然后也会清理。
this.pendingRequests.delete(requestType);
if (!response.ok) {
// 处理 HTTP 错误,例如 4xx, 5xx
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`HTTP error! Status: ${response.status}, Message: ${errorData.message || 'Unknown error'}`);
}
return await response.json() as T; // 假设返回 JSON 数据
} catch (error: any) {
// 5. 处理请求取消的错误
if (error.name === 'AbortError') {
console.warn(`Request for type '${requestType}' was aborted.`);
// 可以选择抛出自定义错误或返回一个表示取消的状态
throw new Error('Request aborted'); // 或者返回 Promise.reject(new AbortError('Request aborted'));
} else {
// 处理其他网络错误或解析错误
console.error(`Request for type '${requestType}' failed:`, error);
throw error;
}
} finally {
// 确保无论请求成功、失败还是取消,都从 pendingRequests 中移除
// 这一步非常重要,防止 Map 内存泄漏
// 实际上,为了避免在 abort 之后立即又被 delete 掉,
// 导致下次同类型请求无法正确判断 pending 状态,
// 应该在请求成功或非 AbortError 失败时才 delete。
// 但在上面的 try/catch 结构中,已经包含了 delete,所以这里可以省略。
// 再次思考:如果请求成功或非 AbortError 失败,try/catch 块会执行到 delete。
// 如果是 AbortError,则会进入 catch 块,并抛出错误,但不会再次 delete。
// 所以,当前的逻辑是正确的。
}
}
}
// 导出单例,确保整个应用共享同一个请求管理器
export const requestManager = new RequestManager();
如何使用
-
导入
requestManager
实例:jsimport { requestManager } from './requestManager'; // 假设你的文件名为 requestManager.ts/js
-
在组件或业务逻辑中使用:
js// 示例:搜索输入框 let searchTimeout: ReturnType<typeof setTimeout> | null = null; async function handleSearchInput(event: Event) { const query = (event.target as HTMLInputElement).value; if (searchTimeout) { clearTimeout(searchTimeout); } searchTimeout = setTimeout(async () => { if (!query) { console.log('Search query is empty, not sending request.'); // 如果清空了,也可以选择取消当前正在进行的搜索请求 requestManager.abort('searchUsers'); return; } try { // 使用封装的 fetchWrapper,传入一个唯一的请求类型标识 'searchUsers' const data = await requestManager.fetchWrapper<{ users: any[] }>( 'searchUsers', `/api/users?q=${encodeURIComponent(query)}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } } ); console.log('Search results:', data.users); // 更新 UI } catch (error: any) { if (error.message === 'Request aborted') { // 这是预期的行为,表示请求被新的请求取代了,无需报错 console.log('Previous search request was aborted.'); } else { console.error('Failed to fetch search results:', error); // 处理其他错误,例如网络问题 } } }, 300); // 300ms 防抖 } // 示例:提交表单 async function handleSubmitForm(formData: FormData) { try { // 假设我们只允许同时进行一个表单提交请求,旧的提交会被取消 const data = await requestManager.fetchWrapper<{ success: boolean }>( 'submitForm', '/api/submit', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData)), headers: { 'Content-Type': 'application/json' } } ); console.log('Form submission successful:', data); // 更新 UI } catch (error: any) { if (error.message === 'Request aborted') { console.log('Previous form submission was aborted.'); } else { console.error('Form submission failed:', error); } } } // 在页面卸载或路由切换时,可以取消所有未完成的请求 // 例如在 React 的 useEffect cleanup 或 Vue 的 onUnmounted 中 // import { useEffect } from 'react'; // useEffect(() => { // return () => { // requestManager.abortAll(); // }; // }, []);
关键点和注意事项
-
请求类型标识 (
requestType
) : 这是区分不同类型请求的关键。你需要根据业务逻辑定义合适的requestType
。- 对于搜索建议,可以是
'searchUsers'
。 - 对于产品详情,可以是
'getProductDetails'
。 - 对于表单提交,可以是
'submitForm'
。 - 如果请求的 URL 和参数完全相同才算同类型,你可以考虑将 URL 或 URL+参数的哈希值作为
requestType
。
- 对于搜索建议,可以是
-
AbortError
处理 : 当请求被取消时,fetch
Promise 会以AbortError
拒绝。在catch
块中,需要特别处理这种错误,因为它通常不是真正的失败,而是预期的取消行为。 -
与防抖/节流结合 : 请求竞态问题常常伴随着用户快速操作(如输入、点击)触发的频繁请求。将上述请求取消逻辑与防抖 (Debounce) 或节流 (Throttle) 结合使用,可以进一步优化性能和用户体验。防抖确保在用户停止操作一段时间后才发送请求,而请求取消则处理了在防抖期间用户再次操作时,旧请求被新请求取代的问题。
-
清理
pendingRequests
: 确保在请求完成(成功或失败)或被取消后,对应的AbortController
实例能从pendingRequests
Map 中移除,防止内存泄漏。当前的fetchWrapper
实现已经包含了这个清理逻辑。 -
全局管理 : 将
RequestManager
作为一个单例导出,确保整个应用共享同一个管理器实例,这样才能正确地跟踪和取消所有相关请求。 -
与其他 HTTP 库集成 : 如果你使用像 Axios 这样的库,它们通常也支持
AbortController
或有自己的取消令牌机制。你可以将RequestManager
的逻辑适配到这些库中。例如,Axios 允许你将signal
选项直接传递给请求配置:js// Axios 示例 // ... 在 fetchWrapper 中创建 controller // axios.get(url, { signal: controller.signal });
通过这种统一的封装方式,你可以有效地管理前端请求,避免竞态问题,提升应用的响应速度和稳定性。