请求竞态问题统一封装

在前端项目中,为了解决请求竞态问题(Race Condition)并优化用户体验,一种常见的策略是在发起新的相同类型的请求之前,取消(abort)之前未完成的同类型请求。这尤其适用于那些用户快速输入触发的请求,例如搜索建议、表单验证等,因为我们通常只关心最新请求的结果。

实现这一目标的核心是使用 AbortController API。

核心原理

  1. AbortController : 这是一个 Web API,提供了一个 AbortSignal 对象,可以用于取消一个或多个 Web 请求。

    • new AbortController() 创建一个控制器实例。
    • controller.signal: 获取一个信号量,将其传递给 fetchXMLHttpRequestsignal 选项。
    • controller.abort(): 调用此方法会触发 signal 上的 abort 事件,所有监听该信号的请求都会被取消。
  2. 请求管理 : 我们需要一个机制来存储当前正在进行的请求的 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();

如何使用

  1. 导入 requestManager 实例:

    js 复制代码
    import { requestManager } from './requestManager'; // 假设你的文件名为 requestManager.ts/js
  2. 在组件或业务逻辑中使用:

    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();
    //   };
    // }, []);

关键点和注意事项

  1. 请求类型标识 (requestType) : 这是区分不同类型请求的关键。你需要根据业务逻辑定义合适的 requestType

    • 对于搜索建议,可以是 'searchUsers'
    • 对于产品详情,可以是 'getProductDetails'
    • 对于表单提交,可以是 'submitForm'
    • 如果请求的 URL 和参数完全相同才算同类型,你可以考虑将 URL 或 URL+参数的哈希值作为 requestType
  2. AbortError 处理 : 当请求被取消时,fetch Promise 会以 AbortError 拒绝。在 catch 块中,需要特别处理这种错误,因为它通常不是真正的失败,而是预期的取消行为。

  3. 与防抖/节流结合 : 请求竞态问题常常伴随着用户快速操作(如输入、点击)触发的频繁请求。将上述请求取消逻辑与防抖 (Debounce)节流 (Throttle) 结合使用,可以进一步优化性能和用户体验。防抖确保在用户停止操作一段时间后才发送请求,而请求取消则处理了在防抖期间用户再次操作时,旧请求被新请求取代的问题。

  4. 清理 pendingRequests : 确保在请求完成(成功或失败)或被取消后,对应的 AbortController 实例能从 pendingRequests Map 中移除,防止内存泄漏。当前的 fetchWrapper 实现已经包含了这个清理逻辑。

  5. 全局管理 : 将 RequestManager 作为一个单例导出,确保整个应用共享同一个管理器实例,这样才能正确地跟踪和取消所有相关请求。

  6. 与其他 HTTP 库集成 : 如果你使用像 Axios 这样的库,它们通常也支持 AbortController 或有自己的取消令牌机制。你可以将 RequestManager 的逻辑适配到这些库中。例如,Axios 允许你将 signal 选项直接传递给请求配置:

    js 复制代码
    // Axios 示例
    // ... 在 fetchWrapper 中创建 controller
    // axios.get(url, { signal: controller.signal });

通过这种统一的封装方式,你可以有效地管理前端请求,避免竞态问题,提升应用的响应速度和稳定性。

相关推荐
10年前端老司机2 小时前
什么!纯前端也能识别图片中的文案、还支持100多个国家的语言
前端·javascript·vue.js
摸鱼仙人~2 小时前
React 性能优化实战指南:从理论到实践的完整攻略
前端·react.js·性能优化
程序员阿超的博客3 小时前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 2453 小时前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇7 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖8 小时前
http的缓存问题
前端·javascript·http
loriloy8 小时前
前端资源帖
前端
源码超级联盟8 小时前
display的block和inline-block有什么区别
前端
GISer_Jing8 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js