搜尋框已改成新字詞,畫面卻突然跳回舊結果。請求發出得早,不代表一定先回來;舊請求較慢、新請求較快,若兩個回應都直接寫入同一份結果,最後完成的舊請求就會蓋掉新內容。
要守住最新搜尋,可以在字詞改變時取消舊請求,同時檢查請求序號,讓只有目前這一次能更新結果、錯誤與載入狀態。取消和限制寫入各有作用,不要只處理結果,卻讓舊請求的finally把新查詢的轉圈提早關掉。
先把回應順序放慢,確認真的是舊請求
在自有測試頁,讓old查詢延遲900毫秒,new查詢延遲100毫秒。先輸入old,80毫秒後改成new。new先完成,old後完成;沒有保護的區塊最後顯示old,取消舊請求並核對序號的區塊則保留new。


這是實際Vue 3頁面向自有HTTP測試服務發請求的結果。延遲是為了重現亂序而安排,不是正式搜尋速度。正式網站還要核對API資料、查詢參數與權限,不能把前端顯示new就當成搜尋內容全部正確。
watch清理上一輪請求
下列範例適用已建立的Vue 3單檔元件,放在script setup。query是搜尋字詞,results保存API回傳的items陣列。示例API路徑要換成自己的入口,並依實際JSON欄位調整,不能直接假定所有服務都回items。
import { ref, watch } from 'vue';
const query = ref('');
const results = ref([]);
const loading = ref(false);
const errorText = ref('');
let latest = 0;
watch(query, async (value, oldValue, onCleanup) => {
const requestId = ++latest;
const controller = new AbortController();
onCleanup(() => controller.abort());
results.value = [];
errorText.value = '';
if (!value.trim()) {
loading.value = false;
return;
}
loading.value = true;
try {
const response = await fetch(
'/api/search?q=' + encodeURIComponent(value),
{ signal: controller.signal }
);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (requestId === latest) results.value = data.items;
} catch (error) {
if (requestId === latest && error.name !== 'AbortError') {
errorText.value = '搜尋暫時失敗,請再試一次。';
}
} finally {
if (requestId === latest) loading.value = false;
}
});
同一個元件的template可以先接入基本狀態。結果內容要依自己的資料結構輸出,以下只顯示筆數,不包含完整文章連結或權限判斷。
<label>搜尋字詞<input v-model="query"></label>
<p v-if="loading" role="status">搜尋中…</p>
<p v-else-if="errorText" role="status">{{ errorText }}</p>
<p v-else role="status">目前結果:{{ results.length }} 筆</p>
初次還沒有查詢時,正式介面應另顯示「先輸入關鍵字」,不要把0筆當成已完成搜尋。這個顯示片段只接出狀態,整合時按網站流程補齊首次、空結果與成功畫面。
為什麼還要檢查requestId
每輪開始先遞增latest,清空輸入也算一輪,讓先前回應失去更新資格。回應讀完後,比較自己的requestId是否仍等於latest;不同,就不再修改當前畫面。
序號也保護錯誤與loading。舊請求被取消時會進入catch;如果不核對身分,就可能把「搜尋失敗」顯示在新的查詢上。舊請求的finally同樣不能解除新一輪的loading,所以這些分支都要依序號決定是否寫入。
這個保護不只依賴輸入文字是否相同。訪客可能先搜A、改B、又回A;第一個A與最後一個A是不同請求。只比對字詞,有時仍讓很早的A回應被接受,序號則能分開每一輪。
取消瀏覽器請求,不等於伺服器停止工作
AbortController可以取消fetch請求與回應讀取。取消後,fetch或仍在進行的讀取會以中止錯誤結束,前端可以把它視為舊查詢被替換,而不是向訪客報一般服務故障。
但請求可能已到伺服器,伺服器仍在查詢或計算。本文的延遲服務即使客戶端取消,仍走完自己的等待與回應處理;前端取消不能證明後端工作已停止。若要真正中止長工作,需要服務端另外設計取消機制。
也不要把這套搜尋取消寫法直接搬到付款、訂單或資料寫入。那些動作即使畫面停止等待,後端仍可能已完成。這篇針對可被新查詢取代的讀取請求,不替代交易狀態確認。
清理函式要在等待前安排
範例使用watch回呼第三個參數onCleanup,並在第一個await之前登記取消。這樣字詞快速改變時,上一輪已經有清理入口,沒有等回應完成才想到要取消。
Vue 3.5另有onWatcherCleanup API,但它必須在watch回呼的同步執行期間呼叫,不能在await之後才登記。不要把不同API的限制混在一起;依專案版本和使用方式讀官方文件,保留清楚的清理位置。
當watch是元件setup裡同步建立,元件卸載時會被停止。若在非同步流程裡才建立watch,就要注意停止責任。不要讓離開頁面後的監看與請求持續修改已不再使用的狀態。
減少請求與防止亂序,分開安排
debounce可以等使用者停一下再搜尋,減少每個字都發請求,但不能保證所有已發出的請求依序回來。即使延遲300毫秒才發,也可能同時存在兩個速度不同的回應;仍需取消或限制舊回應寫入。
cache、錯誤重試與分頁也會增加狀態來源。新字詞應搭配正確的頁碼與篩選條件,不要只取消q的舊請求,卻接受舊分類或舊分頁結果。請求身分應代表這次完整查詢條件。
不要把取消當成唯一的寫入保護
若日後改用另一個請求包裝,它可能沒有把signal傳給fetch,或服務回應已在本地快取中完成。此時取消呼叫不一定阻止後續處理;最後寫入前仍核對序號,就能保住目前查詢。
本文另讓測試包裝刻意忽略signal,兩個HTTP回應都回來,修正版仍只保留new。這個比較是為了確認序號本身有作用,正式程式仍應把signal正確傳下去,不必為了測試而改動第三方服務。
資料讀回後如果還有耗時處理,也要在真正更新狀態前再次核對身分。不要只在fetch開始時判斷一次,之後無論經過多久都照寫;新查詢可能在讀取或處理期間已開始。
用三種情況驗證
先讓舊查詢慢、新查詢快,確認最後只保留新結果;再輸入後立刻清空,確認舊回應不把結果填回來;最後讓最新請求回錯誤,確認載入結束且提示屬於這一次,而不是顯示空結果。
儲存查詢順序、完成順序與畫面結果,修改只限這個搜尋元件。取消訊息留在維護紀錄,訪客只需要清楚的當前狀態。結果和輸入框一致後,再核對每筆連結與API資料,才能完成整個搜尋功能的檢查。

評論0