iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 10 篇

Day 10|少了 forkJoin,我的頁面開始排隊

  • 分享至 

  • xImage
  •  

三個 await,排成了一道階梯

儀表板要載入三樣東西:檔案清單、空間用量、最近開啟。

這是我第一次在正式專案裡大量寫 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 }) => { ... });

三件事:

  • 同時出發。 訂閱的瞬間,三個請求一起送出。
  • 全部完成才給結果。 最慢的那個回來,才會 emit 一次。
  • 一個失敗,整個失敗。 任何一個出錯,整包直接進 error。

這個 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,每個請求的成敗都交給你自己判斷。

還有一個 Day 9 留下的坑

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 可不會幫我處理。


上一篇
Day 9|switchMap 不見後,才知道它一直在幫我擋什麼
下一篇
Day 11|沒有防抖debounce,請求像愛如潮水
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言