儀表板要載入三樣東西:檔案清單、空間用量、最近開啟。
這是我第一次在正式專案裡大量寫 async/await,很自然就寫成這樣:
async function loadDashboard() {
const files = await api.getFiles();
const quota = await api.getQuota();
const recent = await api.getRecent();
return { files, quota, recent };
}
讀起來很舒服,一行一行,像在寫同步程式。
然後測試的同事說:「儀表板好像有點慢。」
打開 Network 面板,三個請求一個接一個排開,像一道階梯。每支 API 大約 300ms,整頁等了將近一秒。
在 Angular,這三個請求我會用 forkJoin 包起來,三個同時出發,300ms 就結束了。
問題不在 React,而是在 await 本身:它讓程式讀起來像同步,也就讓人不小心寫出了同步的行為。
forkJoin 做了什麼forkJoin({
files: this.api.getFiles(),
quota: this.api.getQuota(),
recent: this.api.getRecent(),
}).subscribe(({ files, quota, recent }) => { ... });
三件事:
這個 Angular 讀者都很熟,就不多說了。
Promise.all:幾乎一對一React 這邊的對應物是 JavaScript 原生的 Promise.all:
async function loadDashboard() {
const [files, quota, recent] = await Promise.all([
api.getFiles(),
api.getQuota(),
api.getRecent(),
]);
return { files, quota, recent };
}
同時出發、全部完成才給結果、一個失敗就整個失敗——三個特性一模一樣。
但值得想一下:為什麼這樣寫就平行了?
答案在 Day 8:Promise 是 eager 的,建立的瞬間就出發。
陣列 [api.getFiles(), api.getQuota(), api.getRecent()] 建立的那一刻,三個函式都被呼叫了,三個請求都已經在路上。Promise.all 什麼都沒有「啟動」,它只負責等。
反過來看開場那段:
const files = await api.getFiles(); // 在這裡停下來等
const quota = await api.getQuota(); // 上一行回來了,這行才被執行
await 會讓函式暫停。第二個請求不是在排隊,而是根本還沒被建立——要等上一行回來,程式才走得到這裡。
所以真正的規則是:想讓它們同時跑,就先把 Promise 全部建立好,再一起等。
Promise.all 跟 forkJoin 一樣是 fail-fast:任何一個 reject,整個就 reject。
這在很多情境是對的。但儀表板不是——「最近開啟」掛了,不應該讓整頁空白。
Angular 的做法是在那個比較不重要的請求上接 catchError:
forkJoin({
files: this.api.getFiles(),
quota: this.api.getQuota(),
recent: this.api.getRecent().pipe(catchError(() => of([]))),
})
React 這邊一樣做得到,而且幾乎長得一樣:
const [files, quota, recent] = await Promise.all([
api.getFiles(),
api.getQuota(),
api.getRecent().catch(() => []), // 失敗就給空陣列
]);
如果每一個都想各自處理,就用 Promise.allSettled:
const results = await Promise.allSettled([
api.getFiles(),
api.getQuota(),
api.getRecent(),
]);
// 每一筆都是 { status: 'fulfilled', value } 或 { status: 'rejected', reason }
它永遠不會 reject,每個請求的成敗都交給你自己判斷。
Promise.all 在第一個請求失敗時就會 reject——但其他請求不會停。
它們還在路上,照樣跑完、照樣佔頻寬,只是結果沒人要了。Promise 不能取消,這件事到了 Promise.all 依然成立。
要收乾淨,還是得靠 AbortController:
const controller = new AbortController();
const options = { signal: controller.signal };
try {
const [files, quota, recent] = await Promise.all([
api.getFiles(options),
api.getQuota(options),
api.getRecent(options),
]);
return { files, quota, recent };
} catch (err) {
controller.abort(); // 一個失敗,把還在跑的全部收掉
throw err;
}
forkJoin 失敗時會自動取消其他內層訂閱。這裡,還是得自己來。
combineLatest 去哪了?接下來要做篩選:檔案清單加上一個類型下拉選單,只顯示選中的類型。
在 Angular,這是 combineLatest 的標準場景:
visibleFiles$ = combineLatest([this.files$, this.filter$]).pipe(
map(([files, filter]) => files.filter(f => f.type === filter))
);
兩個來源,任何一個變了,就重新組合一次。
我已經準備好要手刻一個 useCombineLatest 了,打開編輯器,寫下了第一行:
const visibleFiles = files.filter(f => f.type === filter);
然後我停下來,看著這一行。
就這樣?
就這樣。它寫在元件函式本體裡,不需要 hook、不需要訂閱、不需要任何運算子。
原因還是 Day 8 那句話:每一次 render 都是一張快照。
files 變了,元件重跑,這行重新算一次。filter 變了,元件重跑,這行又重新算一次。任何一個來源改變,衍生出來的值都會自動跟著更新——因為整個函式本來就會重新執行。
combineLatest 在解決的問題,是「多條隨時間變化的串流,要怎麼在任一條有新值時重新組合」。
而 React 根本沒有串流。它只有快照,快照裡的每個值本來就擺在一起。沒有需要組合的東西,自然也不需要組合的工具。
順帶一提,Angular 自己也走到了同一個結論:
visibleFiles = computed(() =>
this.files().filter(f => f.type === this.filter())
);
Signals 時代的 computed() 做的事跟 React 那一行幾乎一樣——讀到哪些值,就依賴哪些值,變了就重算。只是 Angular 靠依賴追蹤精準地重算,React 靠整個函式重跑。
至於「每次 render 都重算會不會太貴」:一般的篩選跟排序完全不用擔心。真的遇到很重的運算,再用 useMemo 包起來就好(Day 4 講過它的真正用途)。
這是這個系列第一次,我必須承認一件事:
這裡,React 比較簡單。
Promise.all 解決了同一個函式裡的排隊。但 React 還有另一種更隱蔽的瀑布。
<DashboardPage> ← useEffect 抓資料,等回來才 render 子元件
<FileSection> ← 掛載後才開始抓自己的資料
<FileDetail /> ← 再等上一層回來,才輪到它
0ms DashboardPage 出現 → 發出請求 A
300ms A 回來 → FileSection 出現 → 發出請求 B
600ms B 回來 → FileDetail 出現 → 發出請求 C
900ms C 回來 → 畫面完成
每一層的 useEffect 都要等元件真的出現在畫面上才會執行,而元件要等上一層的資料回來才會被 render。請求被元件樹串成了一條線。
這一次,連 Promise.all 都救不了——因為這些請求散落在不同的元件裡,根本不在同一個地方。
React Router 的解法是把資料請求從元件裡搬出來,放進路由的 loader:
// routes/dashboard.tsx
export async function loader() {
const [files, quota] = await Promise.all([
api.getFiles(),
api.getQuota(),
]);
return { files, quota };
}
loader 在導航時就執行,不用等元件長出來;而且同一次導航對應到的各層路由,它們的 loader 會一起出發。
讓請求跟著路由走,而不是跟著元件樹走。
這個主題還有很多可以談,留到 Day 13 講資料請求時再細說。
forkJoin 有對應方法。Promise.all 幾乎是一對一,失敗處理有 .catch 跟 allSettled,取消還是得靠 AbortController。
真正要小心的不是 API,而是 await 帶來的錯覺:它讓非同步程式讀起來像同步,也讓人不小心寫出同步的行為。想平行,就先把所有 Promise 建立好,再一起等。
combineLatest 則沒有對應方法,因為它要解決的問題在 React 裡不存在。Observable 需要運算子把多條時間軸組合起來;React 沒有時間軸,只有快照,而快照裡的值本來就在一起。
感覺有點微妙。像是一直在找某個工具,找了半天才發現,那個問題本身已經不在了。
不過明天的 debounceTime,可就沒這麼幸運了。使用者打字的速度,React 可不會幫我處理。