1734 字
9 分鐘

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

參考資料:

MDN:AbortController.abort()

MDN:Using Fetch

MDN:Window.setTimeout()

MDN:Window.clearTimeout()

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