搜尋結果被舊請求覆蓋?用 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 部分失敗處理。搜尋框只採納最新輸入,與一次載入多份資料的完成規則不同。
參考資料: