搜尋結果被舊請求覆蓋?用 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;
function invalidate() { ++sequence; active?.abort(); active = undefined; }
async function run(rawQuery) { if (disposed) return; invalidate(); const id = sequence; 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; invalidate(); }
return { run, invalidate, 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();需要逾時時,可依 AbortSignal.timeout 與第一個取消原因 合併 signal;序號檢查仍放在完整 await load() 之後。
debounce 要在輸入當下失效,不能等計時器到期
若只把 search.run() 包進 debounce,使用者已經輸入新文字,舊回應卻可能在新的計時器到期前寫入畫面。上面的 invalidate() 讓「使舊操作失效」能獨立於「送出下一個請求」執行。
把下面函式接在 createLatestSearch 後面。每次 input() 先清除舊計時器並使舊載入失效,再延後送出新查詢;等待期間清空舊結果,loading 到實際開始載入才為 true。
function createDebouncedSearch(load, onState, delay = 250, timers = {}) { if (!Number.isInteger(delay) || delay < 0 || delay > 2_147_483_647) { throw new RangeError('delay must be a supported non-negative integer'); } const setTimer = timers.setTimer ?? setTimeout; const clearTimer = timers.clearTimer ?? clearTimeout; const search = createLatestSearch(load, onState); let pendingTimer = null; let disposed = false;
function input(rawQuery) { if (disposed) return; if (pendingTimer !== null) clearTimer(pendingTimer); pendingTimer = null; search.invalidate(); const query = rawQuery.trim(); onState({ query, items: [], loading: false, error: null }); if (!query) return; pendingTimer = setTimer(() => { pendingTimer = null; return search.run(query); }, delay); }
function dispose() { disposed = true; if (pendingTimer !== null) clearTimer(pendingTimer); pendingTimer = null; search.dispose(); }
return { input, dispose };}預設延遲 250ms 是介面策略,不是標準保證的搜尋等待時間;MDN setTimeout 文件也說明實際觸發可能更晚。timers 是讓下方測試能控制排程的同步介面,正式使用可省略。onState 須同步且不拋錯,若要保留舊結果或顯示「等待輸入完成」,可另外設計狀態,不能取消序號檢查。
以下是 DOM 整合示意:loadSearch 要換成前面的 fetch 載入器,renderSearch 要換成專案自己的同步渲染函式。這裡逐次處理 input,不包含中文輸入法組字策略;需要延到 compositionend 才送出的介面,須另行驗收。
const input = document.querySelector('#search-input');const debounced = createDebouncedSearch(loadSearch, renderSearch);const handleInput = () => debounced.input(input.value);input.addEventListener('input', handleInput);
function unmountSearch() { input.removeEventListener('input', handleInput); debounced.dispose();}clearTimeout() 只取消尚未執行的 timeout,不能取消已送出的 fetch。卸載要同時移除事件、取消計時器、使序號失效與 abort;省略其中一層,就會留下不同種類的舊工作。
用逆序完成測試,不靠網路剛好慢下來
把第一段函式和下面測試放進同一份 .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');重現 debounce 等待期間舊回應晚到
再把以下測試放在相同 .mjs。假計時器讓新查詢保持未送出;此時完成舊查詢,畫面仍須屬於新的輸入。
let nextTimer = 0;const jobs = new Map();const waits = new Map();const events = [];const debounced = createDebouncedSearch((query) => new Promise((resolve) => { waits.set(query, resolve);}), (state) => events.push(state), 250, { setTimer(callback) { jobs.set(++nextTimer, callback); return nextTimer; }, clearTimer(id) { jobs.delete(id); },});function fire() { const [id, callback] = jobs.entries().next().value; jobs.delete(id); return callback();}
debounced.input('java');const first = fire();debounced.input('javascript'); // 計時器尚未觸發const beforeOldReply = events.length;waits.get('java')(['舊結果']);await first;assert.equal(events.length, beforeOldReply);assert.equal(events.at(-1).query, 'javascript');assert.equal(events.at(-1).loading, false);
const second = fire();waits.get('javascript')(['新結果']);await second;assert.deepEqual(events.at(-1).items, ['新結果']);
debounced.input('css');debounced.input(' ');assert.equal(jobs.size, 0);assert.equal(events.at(-1).query, '');debounced.input('astro');debounced.dispose();assert.equal(jobs.size, 0);const afterDispose = events.length;debounced.input('不再送出');assert.equal(events.length, afterDispose);console.log('debounce invalidation checks passed');兩組已刊登的狀態測試由本次維護流程在 Node.js v24.13.0 執行,另外檢查了連續輸入只保留最新計時器、載入中卸載及非法 delay。測試驗證序號與排程規則,不涵蓋瀏覽器網路取消、DOM、中文輸入法或真實 API;接入專案後仍需驗收那些部分。
若需求是保留多個獨立 API 的成功資料,應改看 Promise.allSettled 部分失敗處理。搜尋框只採納最新輸入,與一次載入多份資料的完成規則不同。
參考資料: