1118 字
6 分鐘

搜尋結果被舊請求覆蓋?用 AbortController 與序號處理 fetch 競態

搜尋框先送出 java,再送出 javascript;第二次結果先回來,第一次卻在稍後把畫面改回舊結果。這種 fetch 競態取決於完成順序,debounce 只能減少呼叫次數,不能保證已送出的請求依序完成。

每次輸入改變時就讓舊操作失效;取消請求用 AbortController,寫入畫面前再檢查操作序號。 同一個檢查也要保護錯誤與 loading,否則舊請求失敗時仍可能把新搜尋改成錯誤畫面。

取消網路請求與決定誰能更新畫面#

MDN 的 abort 文件說明它能中止 fetch、回應本文讀取與串流。但你的資料轉換、快取讀取或自訂 Promise 不一定接受 signal;呼叫 abort() 也不會撤銷已完成的畫面更新。

序號負責另一件事:當結果即將寫入狀態時,確認它還屬於最近一次輸入。下面把載入與狀態輸出分開,方便用完全不理會 abort 的假載入器驗證這個邊界。

function createLatestSearch(load, onState) {
let sequence = 0;
let active;
let disposed = false;
async function run(rawQuery) {
if (disposed) return;
const id = ++sequence;
active?.abort();
active = undefined;
const query = rawQuery.trim();
if (!query) {
onState({ query, items: [], loading: false, error: null });
return;
}
const controller = new AbortController();
active = controller;
const isCurrent = () => !disposed && id === sequence;
onState({ query, items: [], loading: true, error: null });
try {
const items = await load(query, controller.signal);
if (!isCurrent()) return;
onState({ query, items, loading: false, error: null });
} catch (error) {
if (!isCurrent()) return;
onState({ query, items: [], loading: false, error });
} finally {
if (isCurrent()) active = undefined;
}
}
function dispose() {
disposed = true;
++sequence;
active?.abort();
active = undefined;
}
return { run, dispose };
}

onState 是同步更新狀態的函式,應自行處理渲染錯誤。這個範例在新查詢開始時清空舊結果;若產品想保留舊畫面等待新結果,可以改 loading 那筆狀態,但仍須標示目前查詢,避免讓讀者誤認舊資料已符合新條件。

清空輸入也會增加序號並取消舊請求。dispose() 適合元件卸載或離開頁面時呼叫;它阻止尚未完成的操作繼續輸出狀態,不會取消已在伺服器完成的工作。

接到 fetch 時,把本文與轉換放進同一個載入器#

以下 /api/search 與 renderSearch 是介面示意,需要換成專案的 endpoint、回應 schema 與狀態處理:

const search = createLatestSearch(async (query, signal) => {
const params = new URLSearchParams({ q: query });
const response = await fetch('/api/search?' + params, { signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (!Array.isArray(data.items)) throw new Error('搜尋回應格式不符');
return data.items;
}, renderSearch);
// 在每次 input 事件呼叫,不要只在 debounce 計時器到期後才讓舊操作失效。
// search.run(input.value);
// 元件卸載時:search.dispose();

這個範例刻意沒有加 debounce。如果還要延後送出請求,應在每次 input 事件先使舊操作失效,再排程新載入;不能等新計時器到期才取消,否則等待期間舊回應仍有機會寫入。需要逾時時,可依 AbortSignal.timeout 與第一個取消原因 合併 signal;序號檢查仍放在完整 await load() 之後。

用逆序完成測試,不靠網路剛好慢下來#

把第一段函式和下面測試放進同一份 .mjs,使用 Node.js 執行。載入器故意忽略 signal,讓舊請求確實完成;新請求仍必須保有畫面所有權。

import assert from 'node:assert/strict';
const pending = new Map();
const states = [];
const search = createLatestSearch((query) => new Promise((resolve, reject) => {
pending.set(query, { resolve, reject });
}), (state) => states.push(state));
const older = search.run('java');
const newer = search.run('javascript');
pending.get('javascript').resolve(['新結果']);
await newer;
pending.get('java').reject(new Error('舊請求失敗'));
await older;
assert.deepEqual(states.at(-1), {
query: 'javascript', items: ['新結果'], loading: false, error: null,
});
const delayed = search.run('css');
await search.run(' ');
pending.get('css').resolve(['不應再出現']);
await delayed;
assert.equal(states.at(-1).query, '');
assert.deepEqual(states.at(-1).items, []);
const beforeDispose = search.run('astro');
search.dispose();
const count = states.length;
pending.get('astro').resolve(['已離開頁面']);
await beforeDispose;
assert.equal(states.length, count);
console.log('stale response checks passed');

這份狀態測試已由本次維護流程在 Node.js v24.13.0 執行,另檢查了舊成功晚到、新請求仍 pending 時舊請求完成,以及目前請求真正失敗的分支。它驗證操作序號的保護,不是瀏覽器網路取消、DOM 渲染或實際搜尋 API 的端到端測試;接入專案後仍需驗收那些部分。

若需求是保留多個獨立 API 的成功資料,應改看 Promise.allSettled 部分失敗處理。搜尋框只採納最新輸入,與一次載入多份資料的完成規則不同。

參考資料:

MDN:AbortController.abort()

MDN:Using Fetch

搜尋結果被舊請求覆蓋?用 AbortController 與序號處理 fetch 競態
https://laplusda.com/posts/javascript-fetch-stale-response-race/
作者
Zero
發佈於
2026-10-04
許可協議
CC BY-NC-SA 4.0