使用者先點了檔案 A,又點了檔案 B。標題顯示 B,正文卻是 A 的內容。兩次讀取返回的資料都沒錯,問題出在 A 的結果到達時,使用者已經換了選擇,卻沒有任何程式碼攔住它。

「點選 → 載入 → 渲染」這條呼叫鏈解釋不了這個現象,因為兩次點選走的是完全相同的函式。還得知道兩個請求各自什麼時候開始、什麼時候結束,以及誰有權更新介面。下面用一個小的 JavaScript 示例走一遍,也會檢查只修正文時容易漏掉的錯誤提示和載入狀態。

先讀到第一個 await

下面是一個簡化的檔案讀取器。load 返回檔案內容的 Promise,publish 把區域性更新合併到介面狀態。兩者都從參數傳入,所以示例不依賴任何 UI 框架:

export function createUnsafeReader(load, publish) {
  return async function select(file) {
    publish({ file, text: '', error: null, loading: true });
    try {
      const text = await load(file);
      publish({ text });
    } catch (error) {
      publish({ error: error.message });
    } finally {
      publish({ loading: false });
    }
  };
}

await 之前,函式記下選中的檔案,並清空舊內容。等待期間,另一次 select 可以開始。await 之後,沒有任何檢查確認介面是否還屬於這次請求。

區域性變數 file 在等待期間不會變,共享的介面狀態卻會變。整個 bug 就在這裡:函式手裡的資料對它自己來說沒錯,但它已經不是該顯示的那次呼叫了。

畫一條時間線

用兩個可以手動控制的讀取,讓 B 先返回、A 後返回:

步驟 事件 目前檔案 正文 載入中
1 選擇 A,開始讀取 A 空 是
2 選擇 B,開始讀取 B 空 是
3 B 讀取成功 B B 的內容 否
4 A 讀取成功 B A 的內容 否

兩次呼叫跑的是同一段程式碼。這個返回順序讓「最後完成的讀取」贏了,而介面要的是「最後選擇的檔案」。

動手修之前,先確認介面到底該怎麼表現。單檔案預覽只需要顯示最新選擇;批次匯入要保留每個結果,儲存佇列可能還要求按順序寫完,這些場景直接丟掉舊結果反而是錯的。下面的修法只適用於新選擇會取代舊選擇的檢視。

不用定時器也能穩定重現

讓 A 等 100 毫秒、B 等 10 毫秒的測試又慢,又依賴排程。換成由測試控制完成時機的 Promise:

function deferred() {
  let resolve, reject;
  const promise = new Promise((yes, no) => {
    resolve = yes;
    reject = no;
  });
  return { promise, resolve, reject };
}

分別建立 A、B 兩個讀取,先選中兩個檔案,再按 B、A 的順序讓它們完成:

const a = deferred(),
  b = deferred();
const state = {};
const select = createUnsafeReader(
  (file) => (file === 'A' ? a : b).promise,
  (patch) => Object.assign(state, patch),
);
const first = select('A'),
  second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // 确认旧实现的问题能够复现。

最後一個斷言故意檢查錯誤結果,用來證明示例確實能觸發問題。修復版的測試則要求正文是 B。重現和修復分成兩個測試,就不會有人把「測試透過」誤讀成「舊實作沒問題」。

給每次選擇編個號

單個檢視用一個遞增計數器就夠了。選擇開始時加一,把編號存在本次呼叫裡,寫回任何結果之前先和最新編號比較:

export function createReader(load, publish) {
  let latest = 0;
  return async function select(file) {
    const request = ++latest;
    publish({ file, text: '', error: null, loading: true });
    try {
      const text = await load(file);
      if (request === latest) publish({ text });
    } catch (error) {
      if (request === latest) publish({ error: error.message });
    } finally {
      if (request === latest) publish({ loading: false });
    }
  };
}

計數器屬於這個讀取器例項。放到全域性,一個視窗裡的點選會讓另一個視窗的結果作廢;放進 select 內部,每次呼叫都從頭計數,根本分不清請求。

只比較路徑也不行。使用者按 A → B → A 的順序選擇時,第一次和第三次路徑相同,但第一次讀到的可能是更舊的檔案內容。編號能區分這兩次。

示例假設 publish 同步更新狀態,load 失敗時丟擲 Error。如果你的框架會批次更新、元件可能被解除安裝,或者介面會丟擲別的型別,這些情況也要處理。

用三次獨立讀取測試 A → B → A

每次呼叫都要有自己的 deferred Promise。前面的測試把 A、B 各對映到一個 Promise;如果第二次選擇 A 時還複用第一個,就把需要區分的兩次請求合併了,測不出這個問題。

這裡假設 load 對同一路徑的不同呼叫可能返回不同快照,例如兩次讀取之間檔案被修改。測試直接提供「A version 1」和「A version 2」來控制結果。這些是教學用例,並非 Yenpo 的線上事故記錄。如果載入器始終返回同一份不可變快照,這個場景就不會出現正文版本差異;分析真實專案時,要先查清快取和讀取方式。

下載檔案裡增加了一個故意不完整的 createPathOnlyReader。它記錄 selectedFile,在成功、錯誤和 finally 三處都用 file === selectedFile 判斷。再次選中 A 後,第一次 A 的結果仍能透過所有判斷:

事件 只比較路徑 比較請求編號
依次選擇 A、B、A 目前路徑是 A 目前請求編號是 3
第三次請求返回 A version 2 顯示 A version 2 顯示 A version 2
B 返回 忽略 B 忽略請求 2
第一次請求返回 A version 1 顯示 A version 1 保留 A version 2

兩個配對測試按完全相同的順序執行這兩種讀取器。名稱帶 reproduce: 的測試故意要求出現舊版本,證明路徑判斷確實失效;請求編號版則要求保留 version 2。另外兩個測試讓第一次 A 在第三次仍未完成時返回:舊的成功結果不能寫正文或關閉載入狀態,舊的失敗不能顯示錯誤;目前 A 自己失敗時,錯誤仍要正常顯示。

讓兩個讀取器各管各的狀態

每個獨立檢視建立自己的讀取器,分別儲存 latest,並讓 publish 更新各自的狀態。同一檢視後續的選擇繼續使用原來的讀取器;如果每次點選都重新建立,就失去了讓舊請求作廢的共用計數器。

最後一個測試建立左右兩個讀取器,各有獨立的 deferred 載入器和狀態物件,再按以下順序執行:

  1. 左側選 A,右側選 A,左側再選 B。左側目前編號是 2,右側仍是 1。
  2. 讓右側 A 返回,左側 B 繼續等待。右側應顯示結果並結束載入,左側仍為空且保持載入。
  3. 讓左側舊的 A 失敗。左側應繼續等待 B,不顯示舊錯誤。
  4. 讓左側 B 返回。左側顯示 B,右側仍保留自己的 A。

如果把 latest 移到模組頂層,第二步就會失敗:左側的新選擇會讓右側請求作廢,右側一直停在載入狀態。即使計數器獨立,共用一個介面狀態物件也會互相覆蓋。測試會在交錯完成後檢查兩邊的狀態。

錯誤和載入狀態也會寫錯地方

如果只給 publish({ text }) 加判斷,正文錯亂是沒了,但還剩兩個問題:

  • 選了 B 之後 A 失敗了。A 的 catch 把「A 讀取失敗」顯示在 B 的介面上。
  • B 還在載入,A 先結束了。A 的 finally 把 B 的載入狀態提前關掉了。

要測出第二種情況,得讓 A 完成、B 繼續等待,然後立刻檢查 loading 仍是 true。如果等兩個請求都結束再看最終狀態,就看不到載入狀態被提前關掉的那一刻。

第一種情況,在 B 等待時讓 A 失敗,檢查 B 既沒有出現舊錯誤,也還在載入,然後再讓 B 完成。另外單獨測一下目前請求失敗時錯誤能正常顯示:一個把所有錯誤都吞掉的「修復」,也能透過舊錯誤那條測試。

所以真正要守住的規則,比「最後正文是對的」更嚴格:只有目前請求能在完成時寫介面,不管寫的是正文、錯誤,還是關閉載入狀態。

取消請求和忽略結果是兩回事

如果載入器支援取消,取消 A 能省掉一些無用功。但發出取消之後,A 仍然可能寫回:結果可能已經在快取裡了,底層操作可能無視取消訊號,也可能正好在取消的同時完成。

取消用來停掉不再需要的工作,編號用來決定結果能不能改介面。用之前要看清載入器是否真的支援 AbortSignal,不是所有返回 Promise 的介面都支援。

忽略舊結果,也撤銷不了 load 過程中已經做過的事。示例裡的載入器只返回文字;如果真實的載入器還會寫共享快取或修改資料模型,這些寫入要另外追蹤。編號只保護介面本身。

跑一下測試

下載 async-selection.mjs,用 Node.js 22 或更新版本執行:

node --test async-selection.mjs

十個測試覆蓋最初的 A → B 覆蓋問題、成功/錯誤/載入狀態保護、A → B → A 重複請求,以及兩個獨立讀取器。兩個名稱帶 reproduce: 的測試故意檢查錯誤結果,其餘八個驗證應有的行為。不用定時器,也沒有外部依賴。

放到真實專案裡,要找到入口處理函式、每一個 await 或回撥、等待期間可能被改動的共享狀態,以及返回路徑上的每一處寫入。把事件順序記在這些原始碼位置旁邊。

在 Yenpo 裡,可以開著入口檔案,讓 AI Reader 追蹤 await 前後的狀態變化。核對回答時,分別看成功、錯誤和 finally 三條路徑。如果它只講到「Promise 返回了」,就接著問:哪個請求有資格使用這個結果?