from NADIA MAKAREVICH
/
本文介紹了 React 中處理錯誤的兩種方式:使用 try/catch 和 ErrorBoundary,以及它們各自的限制。使用 try/catch 時,它無法捕獲 hooks 和子組件中的錯誤。使用 ErrorBoundary 時,它無法捕獲異步代碼 (async code) 和事件處理程序 (event handler) 中的錯誤。但是,可以通過在 try/catch 中捕獲錯誤,然後將錯誤重新抛出到 React 的生命週期中,使 ErrorBoundary 能夠捕獲這些錯誤。
Why do we need to catch errors in React?
為什麼我們需要在 React 中捕獲錯誤呢?答案是因為從 React 16 開始,如果沒有捕獲錯誤,React 會直接在生命週期中抛出錯誤,停止整個應用程序,並跳出錯誤頁面。因此,即使錯誤發生在 UI 中的某個不重要的部分,或者是某個你無法控制的第三方庫中,如果沒有捕獲錯誤,整個頁面都會被銷毀,並且向使用者顯示錯誤頁面。
Review: Catching Errors in JavaScript
在 JavaScript 中,可以使用 try/catch 來捕獲錯誤。如果在 try 中的代碼中發生錯誤,則可以捕獲錯誤,並在 catch 中處理錯誤。這個方法適用於同步程式碼,也適用於非同步程式碼。我們也可以使用 Promise.then() 和 Promise.catch() 來捕獲非同步的錯誤,但這篇文章以 try/catch 為主。
try {
doSomethingWrong();
} catch (error) {
// handle error
}
try {
await fetch("/bla-bla");
} catch (error) {
// handle error
}
Problems of try/catch in React
在 React 中,我們可以使用 try/catch 來捕獲錯誤。但是,以下是三種錯誤的使用方式:
1. Catch errors in useEffect
將 useEffect 整個放在 try/catch 中是錯誤的。由於 useEffect 是在 render 後非同步執行,所以對於 try/catch 來說永遠都是成功執行,也就永遠抓不到 useEffect 中的錯誤。
const Component = () => {
try {
useEffect(() => {
doSomethingWrong();
}, []);
} catch (error) {
// never triggered
}
return <div />;
};
2. Catch errors in children
將 children 整個放在 try/catch 中是錯誤的。不管是在 try/catch 中定義 children,還是直接在 try/catch 中返回 children,都是錯誤的。這是因為當程式執行到 <Child /> 時,它並不是真正的渲染元件,而是創建了一個元件元素。這個元素只是一個包含元件類型和屬性的對象,而且會在 try/catch 執行成功後,在 React 中被使用,與 useEffect 的情況類似,所以也是無法捕獲錯誤的。
const Component = () => {
let child;
try {
child = <Child />;
} catch (e) {
// never triggered
}
return child;
};
const Component = () => {
try {
return <Child />;
} catch (e) {
// never triggered
}
};
3. Catch errors in render and Change state
在 render 中使用 try/catch 來捕獲錯誤,並在 catch 中改變 state,這是錯誤的。這是因為 render 是同步執行的,所以 try/catch 會捕獲到錯誤,但是在 catch 中改變 state 會導致無限循環,因為 render 會再次被調用,進而再次觸發 try/catch。
const Component = () => {
const [hasError, setHasError] = useState(false);
try {
doSomethingComplicated();
} catch (e) {
// will cause infinite loop
setHasError(true);
}
};
ErrorBoundary in React
為了解決上述問題,React 提供了 ErrorBoundary 這個「概念」 來捕獲錯誤。我們必須使用舊版的 class component 自行實現一個 ErrorBoundary,然後使用它來包住可能會發生錯誤的元件。這個 ErrorBoundary 元件包含了幾個生命週期方法,例如
constructor會初始化一個state,用來存放是否發生錯誤static getDerivedStateFromError會在render中發生錯誤時被調用,並將state中的hasError設置為true,我們就可以根據hasError來渲染錯誤訊息。這個方法是產生 ErrorBoundary 的必要方法componentDidCatch可以將錯誤訊息加以利用,例如發送到後端,或者使用Sentry來捕獲錯誤
另外,我們也可以自定義 ErrorBoundary 的 fallback,這個 fallback 會在 render 中發生錯誤時被渲染。
ErrorBoundary with Asynchronous Error
上面我們所寫的 ErrorBoundary 有一些限制,例如我們不能透過 ErrorBoundary 來捕獲非同步的錯誤,例如 setTimeout、Promise;我們也無法透過 ErrorBoundary 來捕獲 Event Handler, Callback 中的錯誤,例如 onClick、onSubmit。
const onClick = () => {
// this error will disappear into the void
throw new Error("Hulk smash!");
};
useEffect(() => {
//the error will also disappear
fetch("/bla");
}, []);
不過在 Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react. 中,Dan Abramov 提供了一個解決方案。我們可以先在 try/catch 中捕獲錯誤,然後在 catch 中觸發一次 React 重新渲染,最後再將錯誤丟回重新渲染的生命週期中。這樣 ErrorBoundary 就可以像其他錯誤一樣捕獲它們。由於 useState 的更新是觸發重新渲染的方式,而 useState 的設置函數可以接受一個更新函數作為參數,所以我們可以使用這個更新函數來重新丟出錯誤。
Conclusion
在 React 中,我們無法透過 try/catch 包住整個 useEffect 或是子元件 children 來捕獲錯誤,但我們可以使用 ErrorBoundary 來達成。不過 ErrorBoundary 也有一些限制,例如它無法捕獲非同步的錯誤,例如 setTimeout、Promise;它也無法捕獲 Event Handler, Callback 中的錯誤,例如 onClick、onSubmit。不過我們可以使用一個自定義的 useThrowAsyncError 來解決這個問題。我們可以在 catch 中使用 useThrowAsyncError 強制觸發 React 重新渲染,最後再將錯誤丟回重新渲染的生命週期中。
另外,我們也可以考慮使用 bvaughn/react-error-boundary 這個第三方套件來解決這個問題。這個套件提供了 <ErrorBoundary> 以及 useErrorHandler,讓我們更方便的使用 ErrorBoundary 的方式捕捉錯誤。