如何在 react 中處理報錯
本文爲 360 奇舞團前端工程師翻譯
原文地址:https://www.developerway.com/posts/how-to-handle-errors-in-react
我們都希望我們的應用能穩定、完美運行,並且能夠考慮到每一個邊緣情況。但是現實情況是,我們是人,是人就會犯錯,並且也不存在沒有 bug 的代碼。無論我們多麼小心或者編寫了多少自動化測試,總會有出現嚴重錯誤的情況。重要的是,當錯誤影響到用戶體驗時,儘可能地定位它,並能以優雅的方式處理它,直到它真正被修復。
所以今天,讓我們來看看 React 中的錯誤處理:如果發生錯誤,我們可以做什麼,不同的錯誤捕捉方法的注意事項是什麼,以及如何減小錯誤的影響。
爲何要捕獲 react 中的錯誤
那麼第一件事:爲什麼在 React 中擁有一些錯誤捕獲解決方案是極其重要的?
這個答案很簡單:從 16 版開始,在 React 生命週期中拋出的錯誤,如果不停止的話,將導致整個應用自行卸載。在此之前,組件會被保留在屏幕上,即使是樣式錯誤和交互錯誤的。現在,在 UI 的一些無關緊要的部分,甚至是一些你無法控制的外部庫中,一個未被捕獲的錯誤也可以使整個頁面掛掉,給用戶呈現白屏。
在此之前,前端開發人員從來沒有過這樣的破壞力。
還記得在 js 中是如何捕獲錯誤信息的嗎?
在 javascript 裏如何捕獲錯誤?衆所周知,我們可以使用 try/catch 語句:在 try 裏做一些事情,在它們執行失敗的時候 catch 這些錯誤來減少影響
try {
// if we're doing something wrong, this might throw an error
doSomething();
} catch (e) {
// if error happened, catch it and do something with it without stopping the app
// like sending this error to some logging service
}
相同的語法也適用於 async 函數
try {
await fetch('/bla-bla');
} catch (e) {
// oh no, the fetch failed! We should do something about it!
}
如果我們正在使用舊的 promises 規範,它有專門的方法來捕獲錯誤。我們可以基於 promise 的 API 來重寫 fetch 例子,像下面這樣:
fetch('/bla-bla').then((result) => {
// if a promise is successful, the result will be here
// we can do something useful with it
}).catch((e) => {
// oh no, the fetch failed! We should do something about it!
})
以上兩種是相同的概念,只是實現方式稍有不同,因此在接下來的文章中,我將只對 try/catch 錯誤使用語法。
在 React 中的 try/catch:如何操作和注意事項
當一個錯誤被捕獲時,我們需要對它做些什麼?除了把它記錄在某個地方之外,我們還能做什麼?更準確地說:我們能爲我們的用戶做什麼?僅僅讓他們面對一個空屏幕或者一個不友好的界面。
最明顯和最直觀的答案是在等待我們修復的時候渲染一些東西。幸運的是,我們可以在這個 catch 語句中做任何我們想做的事情,包括設置狀態。所以我們可以做一些事情像這樣:
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
} catch(e) {
setHasError(true);
}
})
if (hasError) return <SomeErrorScreen />
return <SomeComponentContent {...datasomething} />
}
我們試圖發送一個獲取請求,如果請求失敗了 -- 設置錯誤狀態,如果錯誤狀態爲真,那麼我們就渲染一個錯誤反饋的 UI,爲用戶提供一些額外的信息,比如支持聯繫號碼。
這種方法非常簡單,非常適合簡單、可預測且範圍狹窄的用例,例如捕獲失敗的 fetch 請求。
但是如果你想捕捉一個組件中可能發生的所有錯誤,你將面臨一些挑戰和嚴格的限制。
限制 1:你會在使用 useEffect 鉤子時遇到困難
如果我們用 try/catch 包住 useEffect,hook 就失效了。
try {
useEffect(() => {
throw new Error('Hulk smash!');
}, [])
} catch(e) {
// useEffect throws, but this will never be called
}
發生這種情況是因爲 useEffect 是在渲染後被異步調用的,所以從 try/catch 的角度來看,一切都很順利。這和任何 Promise 都是一樣的:如果我們不等待結果,那麼 javascript 就會繼續它的工作,在承諾完成後返回,並且只執行 useEffect(或 Promise)中的內容。
爲了讓在 useEffect 中的錯誤被捕獲,try/catch 應該被放在裏面。
useEffect(() => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// this one will be caught
}
}, [])
看一下這個例子就知道了:https://codesandbox.io/s/try-catch-and-useeffect-28h3ux?from-embed
這適用於所有使用 useEffect 的鉤子或異步事情的場景。因此,你不能用一個 try/catch 包裹所有代碼,而是將其拆分到每個 hook 中
限制 2:子組件。
try/catch 不能捕捉子組件內發生的任何事情。你不能像下面這樣做:
const Component = () => {
let child;
try {
child = <Child />
} catch(e) {
// useless for catching errors inside Child component, won't be triggered
}
return child;
}
甚至是這樣
const Component = () => {
try {
return <Child />
} catch(e) {
// still useless for catching errors inside Child component, won't be triggered
}
}
可以看一下這個例子 https://codesandbox.io/s/try-catch-for-children-doesnt-work-5elto1?from-embed
發生這種情況是因爲當我們寫時,我們實際上並沒有渲染這個組件。我們所做的是創建一個組件元素,這只是一個組件的定義。它只是一個包含必要信息的對象,比如組件類型和道具,以後會被 React 本身使用,它將實際觸發這個組件的渲染。它將在 try/catch 塊成功執行後發生,與 promises 和 useEffect 鉤子的情況完全一樣。
如果你想更詳細地瞭解元素和組件的工作原理,這裏有一篇文章適合你:React 元素、子代、父代和重排的奧祕(https://www.developerway.com/posts/react-elements-children-parents)
限制 3:在渲染過程中設置 state 是不可取的
如果你想在 useEffect 和各種回調之外捕獲錯誤 (也就是說在組件的渲染過程中),那麼正確處理它們就不再簡單了,因爲渲染過程中的狀態更新是允許的。
比如像這樣簡單的的代碼,如果發生錯誤,就會導致重新渲染無限循環。
const Component = () => {
const [hasError, setHasError] = useState(false);
try {
doSomethingComplicated();
} catch(e) {
setHasError(true);
}
}
當然,我們可以在這裏直接返回錯誤組件,而不是設置錯誤狀態。
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
return <SomeErrorScreen />
}
}
但是,正如你想的,這有點麻煩,而且會迫使我們對同一組件的錯誤進行不同的處理:對 useEffect 和回調進行狀態處理,而對其他的直接返回錯誤組件
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
// do something like fetching some data
} catch(e) {
setHasError(true);
}
})
try {
//
} catch(e) {
return <SomeErrorScreen />;
}
if (hasError) return <SomeErrorScreen />
return <SomeComponentContent {...datasomething} />
}
總結一下本節的內容:如果在 React 中僅僅依靠 try/catch,要麼會錯過大部分的錯誤,要麼會把每個組件變成難以理解的混亂代碼而造成錯誤
幸運的是,還有其他方法。
React ErrorBoundary component
爲了減輕上面的限制,React 給我們提供了 “錯誤邊界”:一種特殊的 API,它以某種方式將普通組件轉換爲 try/catch 語句,但是僅適用於 React 聲明的代碼。你可以在下面的示例中看到的經典用法,包括 React 文檔。
const Component = () => {
return (
<ErrorBoundary>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
現在,如果這些組件或者他們的子組件在渲染中出現錯誤,這個錯誤會被捕獲並處理。
但是 React 並沒有提供原生組件,只是給我們提供了一個工具來實現它。最簡單的實現是這樣子的:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// 初始化error的狀態
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <>Oh no! Epic fail!</>
}
return this.props.children;
}
}
我們創建了一個普通的類組件,並實現了 getDerivedStateFromError 方法 -- 這個方法可以讓組件擁有錯誤邊界。
在處理錯誤時,另一個重要的事情是將錯誤信息傳遞到某個地方,讓它能夠觸發所有監聽者。爲此,錯誤邊界提供了 componentDidCatch 方法
class ErrorBoundary extends React.Component {
componentDidCatch(error, errorInfo) {
log(error, errorInfo);
}
}
在錯誤邊界設置後,我們可以像使用其他組件一樣使用它。比如,我們可以將其優化得更便於重用,並將fallback
做爲 props 來傳遞
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
可以像下面這樣使用:
const Component = () => {
return (
<ErrorBoundary fallback={<>Oh no! Do something!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
或者其他我們可能需要的東西,比如點擊按鈕時重置狀態,區分錯誤類型,或者將錯誤傳遞到某個上下文中。查看完整的例子:https://4ldsun.csb.app/
不過,這裏有一個注意事項:它並不能捕獲一切錯誤
錯誤邊界組件的限制
錯誤邊界只捕捉髮生在 React 生命週期中的錯誤。在生命週期之外發生的事情,如resolved promise
、帶有setTimeout
的異步代碼、各種回調和事件監聽函數,如果沒有被不明確處理,將不能捕獲。
const Component = () => {
useEffect(() => {
throw new Error('Destroy everything!');
}, [])
const onClick = () => {
throw new Error('Hulk smash!');
}
useEffect(() => {
fetch('/bla')
}, [])
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary>
<Component />
</ErrorBoundary>
)
}
這裏的建議是使用常規的 try/catch 來處理這類錯誤。而且至少在這裏我們可以安全地使用 state:事件監聽函數的回調正是我們通常setState
的地方。所以從技術上講,我們可以把兩種方法結合起來,做這樣的事情。
const Component = () => {
const [hasError, setHasError] = useState(false);
const onClick = () => {
try {
throw new Error('Hulk smash!');
} catch(e) {
setHasError(true);
}
}
if (hasError) return 'something went wrong';
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary fallback={"Oh no! Something went wrong"}>
<Component />
</ErrorBoundary>
)
}
但是。我們又回到了原點:每個組件都需要維持它的 "錯誤" 狀態,更重要的是 -- 決定如何處理它。
當然,我們可以不在組件層面上處理這些錯誤,而只是通過 props 或 Context 將它們傳遞到擁有 ErrorBoundary 的父級。這樣的話,我們只需要在一個地方設置一個 "fallback" 組件。
const Component = ({ onError }) => {
const onClick = () => {
try {
throw new Error('Hulk smash!');
} catch(e) {
onError();
}
}
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
const [hasError, setHasError] = useState();
const fallback = "Oh no! Something went wrong";
if (hasError) return fallback;
return (
<ErrorBoundary fallback={fallback}>
<Component onError={() => setHasError(true)} />
</ErrorBoundary>
)
}
但這裏有很多冗餘代碼,我們必須對渲染樹的每一個子組件都這樣做。更不用說我們現在還要維護兩個錯誤狀態:父組件,以及 ErrorBoundary 本身。而 ErrorBoundary 已經實現了一套捕獲錯誤的機制,我們在這裏做了重複的工作。
那麼,我們就不能用 ErrorBoundary 從異步代碼和事件處理程序中捕捉這些錯誤嗎?
ErrorBoundary 捕捉異步錯誤
有趣的是 -- 我們可以用 ErrorBoundary 把它們都捕獲!大家最喜歡的 Dan Abramov 與我們分享了一個很酷的黑客技術。正是爲了實現這一點:Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react. (https://github.com/facebook/react/issues/14981#issuecomment-468460187)
這裏的技巧是先用 try/catch 捕捉這些錯誤,然後在 catch 語句中觸發正常的 React 重渲染,然後把這些錯誤重新拋回重渲染的生命週期。這樣,ErrorBoundary 就可以像捕獲其他錯誤一樣捕捉它們。由於 state 變化是觸發重新渲染的方式,而 setState 實際上可以接受一個更新函數作爲參數,這個解決方案是純粹的黑魔法。
const Component = () => {
const [state, setState] = useState();
const onClick = () => {
try {
// something bad happened
} catch (e) {
setState(() => {
throw e;
})
}
}
}
完整例子在這裏:https://codesandbox.io/s/simple-async-error-in-error-boundary-r8l22g?from-embed
這裏的最後一步將其抽象化,所以我們不必在每個組件中創建隨機狀態。我們可以在這裏發揮創意,實現一個鉤子用來將異步錯誤拋出。
const useThrowAsyncError = () => {
const [state, setState] = useState();
return (error) => {
setState(() => throw error)
}
}
像這樣使用它:
const Component = () => {
const throwAsyncError = useThrowAsyncError();
useEffect(() => {
fetch('/bla').then().catch((e) => {
// throw async error here!
throwAsyncError(e)
})
})
}
或者,我們可以像下面這樣爲回調做一層封裝:
const useCallbackWithErrorHandling = (callback) => {
const [state, setState] = useState();
return (...args) => {
try {
callback(...args);
} catch(e) {
setState(() => throw e);
}
}
}
像下面這樣使用它:
const Component = () => {
const onClick = () => {
// do something dangerous here
}
const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);
return <button onClick={onClickWithErrorHandler}>click me!</button>
}
完整的例子在這裏:https://codesandbox.io/s/simple-async-errors-utils-for-error-boundary-fzg5zv?from-embed
可以用 react-error-boundary 來代替嗎?
對於那些討厭重新造輪子的人,或者喜歡用庫來解決已經解決的問題的人,有一個很好的庫,它實現了一個靈活的 ErrorBoundary 組件,並且有一些類似於上述的有用的工具:https://github.com/bvaughn/react-error-boundary
是否使用它,只是個人喜好、編碼風格和組件特殊性的問題。
今天就說到這裏,希望從現在開始,當你的應用程序發生了報錯,你都能夠輕鬆而優雅地處理這些情況。
請記住:
-
try/catch 塊不會捕獲像 useEffect 這樣的鉤子和任何子組件中的錯誤。
-
ErrorBoundary 可以捕捉它們,但它不會捕捉異步代碼和事件處理回調中的錯誤。
-
不過,你可以讓 ErrorBoundary 捕捉這些錯誤,你只需要先用 try/catch 捕捉它們,然後再把它們重新傳遞到 React 生命週期中。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/pTroZRX21ixJyMeg_SzvWQ