阅读视图

发现新文章,点击刷新页面。

前端-请求接口中断处理指南

请求中断处理指南

📋 今日处理总结

处理的问题

  1. 实现 eligibleForPrizeExchange 请求中断:当打开 auto select 时,中断正在进行的 eligibleForPrizeExchange 请求
  2. 实现 target-prizes 请求中断:当关闭 auto select 时,中断正在进行的 target-prizes 请求
  3. 解决竞态条件问题:防止旧请求的结果覆盖新请求的结果
  4. 修复 loading 状态管理:确保 loading 状态能正确显示和更新

核心实现逻辑

1. 使用 AbortController + RequestId 双重保障
// 1. 定义状态管理
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

// 2. 在请求函数中
const fetchData = async () => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的 AbortController 和 requestId
    const requestId = ++currentRequestId.value;
    const newAbortController = new AbortController();
    abortController.value = newAbortController;
    
    try {
        // 检查 requestId 是否仍然是最新的
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,直接返回
        }
        
        const signal = newAbortController.signal;
        
        // 发送请求,传递 signal
        const result = await apiCall(params, signal);
        
        // 请求完成后,再次检查 requestId
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,忽略结果
        }
        
        // 检查当前状态是否仍然匹配
        if (!shouldProcessResult()) {
            return; // 状态已改变,不处理结果
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error) {
        // 如果是 AbortError,不需要处理错误
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 只有当前请求完成时,才清理 AbortController
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};
2. 在 Store 中传递 signal
// stores/example.ts
async function fetchData(params: any, signal?: AbortSignal) {
    const uniqueKey = `fetchData-${Date.now()}-${Math.random()}`;
    const { data, execute } = useApiFetch(url, {
        method: "POST",
        body: JSON.stringify(params),
        signal: signal,
        key: uniqueKey, // 确保每次创建新实例
    });
    
    await execute();
    
    // 处理结果...
}

// 在 catch 中处理 AbortError
catch (e: any) {
    if (e?.name === 'AbortError' || e?.message?.includes('aborted')) {
        return { success: false, data: null, message: 'Request aborted' };
    }
    // 处理其他错误...
}
3. 在 useApiFetch 中动态设置 signal
// composables/useApiFetch.ts
onRequest({ options }) {
    if (signal !== undefined && signal !== null) {
        options.signal = signal;
        // 检查 signal 是否已经被中断
        if (signal.aborted) {
            throw new DOMException('The operation was aborted.', 'AbortError');
        }
    }
}

🎯 通用处理模式

模式一:单一请求中断

场景:用户快速操作,需要中断之前的请求

const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

const fetchData = async () => {
    // 1. 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 2. 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    try {
        // 3. 检查是否仍然是最新请求
        if (requestId !== currentRequestId.value) return;
        
        // 4. 发送请求
        const result = await apiCall(controller.signal);
        
        // 5. 再次检查
        if (requestId !== currentRequestId.value) return;
        
        // 6. 处理结果
        processResult(result);
        
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};

模式二:多请求互斥中断

场景:两个不同的请求,一个触发时中断另一个

const requestAController = ref<AbortController | null>(null);
const requestBController = ref<AbortController | null>(null);

const fetchA = async () => {
    // 中断 B
    if (requestBController.value) {
        requestBController.value.abort();
        requestBController.value = null;
    }
    
    // 开始 A
    const controller = new AbortController();
    requestAController.value = controller;
    // ... 发送请求
};

const fetchB = async () => {
    // 中断 A
    if (requestAController.value) {
        requestAController.value.abort();
        requestAController.value = null;
    }
    
    // 开始 B
    const controller = new AbortController();
    requestBController.value = controller;
    // ... 发送请求
};

模式三:状态检查 + RequestId 双重验证

场景:请求结果需要匹配当前状态(如 autoSelect 状态)

const fetchData = async () => {
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    
    try {
        const result = await apiCall(controller.signal);
        
        // 1. 检查 requestId(防止竞态条件)
        if (requestId !== currentRequestId.value) return;
        
        // 2. 检查状态(确保结果匹配当前状态)
        if (!isValidState()) return;
        
        // 3. 处理结果
        updateData(result);
        
    } catch (error) {
        // 处理错误...
    }
};

🔍 常见场景及处理方法

场景 1:搜索框输入

问题:用户快速输入,需要中断之前的搜索请求

解决方法

const searchController = ref<AbortController | null>(null);
const searchRequestId = ref<number>(0);

const handleSearch = debounce(async (keyword: string) => {
    // 中断之前的搜索
    if (searchController.value) {
        searchController.value.abort();
    }
    
    const requestId = ++searchRequestId.value;
    const controller = new AbortController();
    searchController.value = controller;
    
    try {
        if (requestId !== searchRequestId.value) return;
        
        const results = await searchApi(keyword, controller.signal);
        
        if (requestId !== searchRequestId.value) return;
        
        updateSearchResults(results);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
}, 300);

场景 2:标签页切换

问题:切换标签页时,需要中断当前标签页的请求

解决方法

const tabControllers = ref<Map<string, AbortController>>(new Map());

const fetchTabData = async (tabId: string) => {
    // 中断当前标签页的请求
    const currentController = tabControllers.value.get(tabId);
    if (currentController) {
        currentController.abort();
    }
    
    // 创建新的请求
    const controller = new AbortController();
    tabControllers.value.set(tabId, controller);
    
    try {
        const data = await fetchTabDataApi(tabId, controller.signal);
        updateTabData(tabId, data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
};

场景 3:表单提交

问题:用户快速点击提交按钮,需要防止重复提交

解决方法

const submitController = ref<AbortController | null>(null);
const isSubmitting = ref<boolean>(false);

const handleSubmit = async () => {
    if (isSubmitting.value) {
        // 中断之前的提交
        if (submitController.value) {
            submitController.value.abort();
        }
    }
    
    isSubmitting.value = true;
    const controller = new AbortController();
    submitController.value = controller;
    
    try {
        await submitForm(formData, controller.signal);
        showSuccess();
    } catch (error) {
        if (error.name !== 'AbortError') {
            showError(error);
        }
    } finally {
        isSubmitting.value = false;
        submitController.value = null;
    }
};

场景 4:下拉刷新

问题:用户快速下拉刷新,需要中断之前的刷新请求

解决方法

const refreshController = ref<AbortController | null>(null);

const handleRefresh = async () => {
    // 中断之前的刷新
    if (refreshController.value) {
        refreshController.value.abort();
    }
    
    const controller = new AbortController();
    refreshController.value = controller;
    
    try {
        const data = await refreshData(controller.signal);
        updateData(data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        refreshController.value = null;
    }
};

✅ 最佳实践

1. 必须使用 RequestId

原因:防止竞态条件,确保只有最新请求的结果被使用

// ✅ 正确
const requestId = ++currentRequestId.value;
// ... 请求完成后检查
if (requestId !== currentRequestId.value) return;

// ❌ 错误:没有 requestId,无法防止竞态条件
const controller = new AbortController();

2. 多次检查 RequestId

原因:异步操作中,状态可能随时改变

// ✅ 正确:在关键步骤前都检查
const result = await apiCall();
if (requestId !== currentRequestId.value) return;

processData(result);
if (requestId !== currentRequestId.value) return;

updateUI(result);

3. 状态检查 + RequestId 双重验证

原因:确保结果不仅是最新的,还要匹配当前状态

// ✅ 正确
if (requestId !== currentRequestId.value) return;
if (!isValidState()) return; // 检查状态是否匹配

// ❌ 错误:只检查 requestId,不检查状态
if (requestId !== currentRequestId.value) return;
// 直接使用结果,可能状态已改变

4. 正确处理 AbortError

原因:AbortError 是正常的取消操作,不应该显示错误

// ✅ 正确
catch (error) {
    if (error.name !== 'AbortError') {
        handleError(error); // 只处理真正的错误
    }
}

// ❌ 错误:所有错误都处理,包括 AbortError
catch (error) {
    handleError(error); // 会显示"请求被中断"的错误提示
}

5. 在 finally 中清理 AbortController

原因:确保只有当前请求完成时才清理,避免影响新请求

// ✅ 正确
finally {
    if (requestId === currentRequestId.value) {
        abortController.value = null; // 只有当前请求完成时才清理
    }
}

// ❌ 错误:总是清理,可能影响新请求
finally {
    abortController.value = null; // 可能清理了新请求的 controller
}

6. 使用唯一的 key 创建新的 useApiFetch 实例

原因:避免 useFetch 缓存导致的问题

// ✅ 正确
const uniqueKey = `apiCall-${Date.now()}-${Math.random()}`;
const { data, execute } = useApiFetch(url, {
    signal: signal,
    key: uniqueKey, // 确保每次创建新实例
});

// ❌ 错误:使用相同的 key,可能导致缓存问题
const { data, execute } = useApiFetch(url, {
    signal: signal,
    // 没有 key,可能使用缓存的实例
});

📝 代码模板

完整模板

// 1. 定义状态
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);
const isLoading = ref<boolean>(false);

// 2. 请求函数
const fetchData = async (params: any) => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    isLoading.value = true;
    
    try {
        // 检查 1:请求开始前
        if (requestId !== currentRequestId.value) {
            isLoading.value = false;
            return;
        }
        
        const signal = controller.signal;
        if (!signal) {
            isLoading.value = false;
            return;
        }
        
        // 发送请求
        const result = await store.fetchData(params, signal);
        
        // 检查 2:请求完成后
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 检查 3:状态验证(如果需要)
        if (!isValidState()) {
            return;
        }
        
        // 检查 4:更新数据前
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error: any) {
        // 处理错误(忽略 AbortError)
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 清理(只有当前请求完成时)
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
        isLoading.value = false;
    }
};

// 3. 中断函数(在状态改变时调用)
const interruptRequest = () => {
    if (abortController.value) {
        abortController.value.abort();
        abortController.value = null;
        currentRequestId.value++; // 更新 requestId,确保旧请求结果被忽略
    }
};

🚨 注意事项

  1. 不要忘记传递 signal:确保 signal 正确传递到 API 调用
  2. 不要忘记检查 requestId:防止竞态条件
  3. 不要忘记检查状态:确保结果匹配当前状态
  4. 不要忘记处理 AbortError:避免显示"请求被中断"的错误
  5. 不要忘记清理 AbortController:避免内存泄漏
  6. 使用唯一的 key:避免 useFetch 缓存问题

📚 相关文件

  • pages/wallet/components/redemption/WalletSelectionSection.vue - target-prizes 中断实现
  • pages/wallet/components/RedeemWithPrizesModal.vue - eligibleForPrizeExchange 中断实现
  • stores/wallet.ts - API 调用中的 signal 传递
  • composables/useApiFetch.ts - signal 的动态设置
❌