週五下午,user傳來一張截圖:一整片白。
沒有導覽列、沒有錯誤訊息、什麼都沒有。只是打開檔案列表頁,整個系統就白掉了。
打開 Console,紅字一行:
TypeError: Cannot read properties of null (reading 'size')
追下去,是 FileRow 裡的這行:
<span>{formatSize(file.metadata.size)}</span>
資料庫裡有一筆舊檔案,metadata 是 null。五百筆資料裡的一筆,讓整個系統白掉。
我第一個反應是: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 的策略正好相反。
render 過程中只要有一個元件拋出錯誤,而且沒有人接住,React 就會把整棵元件樹卸載。 這就是那片白。
一開始我覺得這個設計很不合理:五百筆壞一筆,為什麼要賠上整個畫面?
後來看了 React 官方的說明,才懂它在擔心什麼。假設不是檔案列表,而是一個轉帳頁面。某個元件算錯了一半,畫面上顯示的金額是錯的,但其他部分看起來都很正常。使用者看著錯的數字按下確認。
一個看起來正常、但其實壞掉的畫面,比一片白還危險。 白屏至少讓人知道出事了。
所以 React 的預設是:寧可整個停下來,也不要讓使用者面對一個可能錯誤的畫面。
這兩種策略沒有對錯。Angular 假設大部分錯誤是局部的,繼續跑比較好;React 假設你沒辦法確定錯誤影響多大,停下來比較安全。
但 React 也知道「整個白掉」不是你要的。所以它給你一個工具,讓你自己決定錯誤要擋在哪裡。
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 裡出錯setTimeout 裡出錯道理很簡單:使用者按下按鈕的那一刻,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。
ErrorBoundaryReact 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。那是最後一道牆——任何沒被接住的錯誤,最後都會落到這裡。有了它,最壞的情況就不再是一片白,而是一個寫著「系統發生錯誤,請重新整理」的頁面。
ErrorHandlerAngular 的 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 也存在,只是那邊的預設行為讓我從來不用回答。