iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

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

Day 18|一筆資料是 null,整個系統白掉

  • 分享至 

  • xImage
  •  

週五下午,user傳來一張截圖:一整片白。

沒有導覽列、沒有錯誤訊息、什麼都沒有。只是打開檔案列表頁,整個系統就白掉了。

打開 Console,紅字一行:

TypeError: Cannot read properties of null (reading 'size')

追下去,是 FileRow 裡的這行:

<span>{formatSize(file.metadata.size)}</span>

資料庫裡有一筆舊檔案,metadata 是 null。五百筆資料裡的一筆,讓整個系統白掉。

我第一個反應是:Angular 不會這樣啊。


一、Angular:記下來,然後繼續

同樣的錯誤放在 Angular,結果會是這樣:那一列可能顯示不完整,Console 多一行紅字,但頁面其他部分照常運作。導覽列還在、其他四百九十九列還在,使用者頂多覺得某一列怪怪的。

原因是 Angular 在元件裡拋出的錯誤,最後會交給一個全域的 ErrorHandler。它的預設行為很簡單:印到 Console,然後繼續。

大部分專案會自己換掉它,把錯誤送回後端記錄:

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  handleError(error: unknown) {
    logService.report(error);   // 送回伺服器
    console.error(error);
  }
}

// app.config.ts
providers: [{ provide: ErrorHandler, useClass: GlobalErrorHandler }]

一個 provider,全 app 的錯誤都經過這裡。

Angular 的策略可以濃縮成一句話:回報,但不停下來。


二、React:寧可全白,也不要半壞

React 的策略正好相反。

render 過程中只要有一個元件拋出錯誤,而且沒有人接住,React 就會把整棵元件樹卸載。 這就是那片白。

一開始我覺得這個設計很不合理:五百筆壞一筆,為什麼要賠上整個畫面?

後來看了 React 官方的說明,才懂它在擔心什麼。假設不是檔案列表,而是一個轉帳頁面。某個元件算錯了一半,畫面上顯示的金額是錯的,但其他部分看起來都很正常。使用者看著錯的數字按下確認。

一個看起來正常、但其實壞掉的畫面,比一片白還危險。 白屏至少讓人知道出事了。

所以 React 的預設是:寧可整個停下來,也不要讓使用者面對一個可能錯誤的畫面。

這兩種策略沒有對錯。Angular 假設大部分錯誤是局部的,繼續跑比較好;React 假設你沒辦法確定錯誤影響多大,停下來比較安全。

但 React 也知道「整個白掉」不是你要的。所以它給你一個工具,讓你自己決定錯誤要擋在哪裡。


三、Error Boundary:讓錯誤停在一道牆後面

Error Boundary 是一個元件,包住一塊畫面。這塊畫面裡任何地方拋出錯誤,就停在這道牆後面,換成一個備用畫面,外面不受影響。

奇怪的是,它只能用 class 元件寫——到現在都沒有對應的 hook:

class ErrorBoundary extends Component<Props, { hasError: boolean }> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };          // 出錯了,切換成備用畫面
  }

  componentDidCatch(error: Error) {
    logService.report(error);           // 回報
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

這個系列寫了十七天,第一次寫 class 元件,竟然是為了接錯誤。

實務上,大家幾乎都用現成的 react-error-boundary,它還多了「重試」的功能:

import { ErrorBoundary } from 'react-error-boundary';

function RowFallback({ resetErrorBoundary }: FallbackProps) {
  return (
    <div className="row-error">
      這筆資料無法顯示
      <button onClick={resetErrorBoundary}>重試</button>
    </div>
  );
}

{files.map(file => (
  <ErrorBoundary key={file.id} FallbackComponent={RowFallback}>
    <FileRow file={file} />
  </ErrorBoundary>
))}

這樣那筆 metadata 是 null 的檔案,只會在自己那一列顯示「這筆資料無法顯示」,其他四百九十九列照常運作。

牆要蓋在哪裡

這是 Error Boundary 真正要你做的決定。

  • 包太大(只在最外層包一個):一個小元件出錯,整頁換成錯誤畫面,跟白屏差不多。
  • 包太小(每個按鈕都包):程式碼很亂,而且錯誤被藏起來,反而不容易發現。

我最後的原則是:能獨立壞掉的區塊,各自一道牆。 儀表板上的每張卡片、側邊欄、檔案列表的每一列。空間用量那張卡片壞了,檔案列表應該照樣能用。

Day 16 我蓋牆是為了擋住別人伸手進來;今天蓋牆,是為了擋住錯誤擴散出去。


四、牆擋不到的地方

Error Boundary 只接得住render 過程中拋出的錯誤。以下這些,它通通接不到:

  • 事件處理函式:onClick 裡出錯
  • 非同步程式:Promise、setTimeout 裡出錯
  • 它自己:Error Boundary 本身出錯,要靠外面那一層

道理很簡單:使用者按下按鈕的那一刻,React 不在 render。事件處理函式出錯,畫面本身並沒有壞,所以 React 不會把它當成需要卸載畫面的狀況。

所以事件裡的錯誤,還是得自己用 try/catch:

async function handleDelete() {
  try {
    await api.deleteFile(file.id);
  } catch {
    toast.error('刪除失敗,請稍後再試');
  }
}

如果真的想把這種錯誤丟給最近的 Error Boundary,react-error-boundary 提供了一個 hook:

const { showBoundary } = useErrorBoundary();

api.deleteFile(file.id).catch(showBoundary);

至於 TanStack Query,它預設不會拋錯,而是把錯誤放進回傳的 error 裡(Day 13),讓元件自己決定怎麼顯示。想讓它直接交給 Error Boundary,可以設定 throwOnError: true。


五、路由層級的錯誤:React Router 的 ErrorBoundary

React Router 把這件事做得更好用。每個路由檔案都可以 export 一個 ErrorBoundary——就是 Day 10 那張約定好的 export 名稱表裡的那一個:

// routes/files.$id.tsx
import { data, isRouteErrorResponse } from 'react-router';

export async function loader({ params }: Route.LoaderArgs) {
  const file = await api.getFile(params.id);
  if (!file) {
    throw data('找不到這個檔案', { status: 404 });
  }
  return { file };
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error) && error.status === 404) {
    return <p>這個檔案不存在,可能已經被刪除了。</p>;
  }
  return <p>載入檔案時發生錯誤。</p>;
}

它接得住三種錯誤:loader 裡的、action 裡的、還有這個路由元件 render 時的。

而且它跟巢狀路由配合得很好。錯誤只會讓發生錯誤的那一層換成備用畫面,外層的 layout 不受影響:

root.tsx              ← 導覽列、側邊欄,照常顯示
  routes/files.tsx    ← 檔案列表的框架,照常顯示
    routes/files.$id  ← 只有這裡換成「找不到這個檔案」

這一點,是我覺得 React 最接近 Angular 那種「壞一塊、其他照跑」的地方。

最後,root.tsx 一定要放一個 ErrorBoundary。那是最後一道牆——任何沒被接住的錯誤,最後都會落到這裡。有了它,最壞的情況就不再是一片白,而是一個寫著「系統發生錯誤,請重新整理」的頁面。


六、全域回報:React 版的 ErrorHandler

Angular 的 ErrorHandler 是一個集中回報的地方。React 19 也加了類似的東西,在建立 root 的時候傳進去:

hydrateRoot(document, <HydratedRouter />, {
  onCaughtError: (error) => logService.report(error),     // 被 Error Boundary 接住的
  onUncaughtError: (error) => logService.report(error),   // 沒被接住、導致白屏的
});

在 React Router 的框架模式,這段程式碼在 entry.client.tsx 裡。這個檔案預設是隱藏的,要用 npx react-router reveal 才會出現。

這樣不管錯誤在哪道牆後面被接住,都會經過同一個地方回報——跟 Angular 的 ErrorHandler 一樣,只是要多做一步才找得到它。


七、對照一下

Angular React
預設行為 記錄到 Console,繼續執行 卸載整棵樹,白屏
設計哲學 回報,但不停下來 寧可停下,也不要顯示錯誤的畫面
局部隔離 不需要特別做 Error Boundary(只能用 class 寫)
路由層級 自己處理 路由檔案 export ErrorBoundary
404 等狀態 在元件或 resolver 裡處理 loader 裡 throw data(..., { status })
全域回報 ErrorHandler provider onCaughtError、onUncaughtError
事件裡的錯誤 一樣進 ErrorHandler 要自己 try/catch

今天的結論

Angular 跟 React 對錯誤的態度完全相反。

Angular 說:錯誤通常只是局部的,記下來,其他照常跑。 所以我寫了三年,幾乎不需要思考「錯誤會擴散到哪裡」。

React 說:你沒辦法確定錯誤影響了多少,所以全部停下來最安全。 然後交給你一個工具,讓你自己決定牆要蓋在哪。

那片白讓我第一次認真想:系統裡哪些區塊應該能獨立壞掉?哪些錯誤寧可整頁停下來,也不能讓使用者看到半壞的資料?

這些問題在 Angular 也存在,只是那邊的預設行為讓我從來不用回答。


上一篇
Day 17|表單一大就卡?用什麼比較快
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言