One, foreword
Catching and handling front-end component exceptions is an important and necessary operation. React is usually implemented with ErrorBoundary. Today, let’s build an ErrorBoundary wheel with high scalability
background
As an experienced front-end, have you ever encountered the problem of blank screen? Every time you encounter it, please send me a picture or tell me the scene, and then repeat the bug. One of the situations of this problem is that some variables or resources cannot be found.
Although it is sometimes returned by the back-end interface, the front end should not have a blank screen, which may cause bad visual effects to users. It is recommended to use the “Error Boundary convenient feature” provided by React to handle the problem.
Let’s take a look at how the authorities have done this. Right
Official Document Address
class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) {// Update state so that the next render can display the degraded UI return {hasError: true}; } componentDidCatch(error, errorInfo) {// You can also report error logs to logErrorToMyService(error, errorInfo); } render() {if (this.state.haserror) {// You can customize the UI and render return <h1>Something went wrong. } return this.props.children; }}Copy the code
The way to use it is that you can use it as a regular component
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
Copy the code
Error boundaries work like JavaScript’s Catch {}, except that they only apply to React components. Only class components can be error bound components. In most cases, you only need to declare the error boundary component once and use it throughout the application.
Note that an error boundary can only catch errors in its children, not in itself. If an error boundary cannot render an error message, the error bubbles to the nearest upper error boundary, similar to how catch {} works in JavaScript.
conclusion
- Wrap business components that may go wrong with ErrorBoundary
- When a business component reports an error, it calls the logic in the componentDidCatch hook, sets hasError to true, and displays it directly
- ErrorBounday is a good way to get things done. If you really want to make a good wheel, you shouldn’t just write return
Ii. Expand the ErrorBoundary of their own business
My goal is to display error content of Something went wrong, similar to Fallback in React Suspense.
- Change hasError to error and Boolean to error. It is useful to get more error information.
- Add multiple props configurations, such as fallback, FallbackComponent, and provide multiple methods to pass in the presentation fallback to make its ErrorBoundary more flexible.
So our MyErrorBoundary can accept two props
import React, { FC, Component, ComponentType, PropsWithChildren, ReactElement } from 'react'; / / error display after the element type of the type FallbackElement = React. ReactElement < unknown, string | | FC typeof Component > | null; // Props export interface FallbackProps {error: error; } // This component ErrorBoundary props interface ErrorBoundaryProps {fallback? : FallbackElement; defaultError? : FallbackElement | string onError types / / components the default display error? : (error: Error, info: string) => void; } interface ErrorBoundaryState { error: Error | null; } // initialState const initialState: ErrorBoundaryState = {Error: null, } class MyErrorBoundary extends React.Component<React.PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> { state = initialState; static getDerivedStateFromError(error: Error) { return {error}; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { if (this.props.onError) { this.props.onError(error, errorInfo.componentStack); } } render() { const { fallback, defaultError = 'Something went wrong.' } = this.props; const {error} = this.state; if (error ! == null) { if (React.isValidElement(fallback)) { return fallback; } // Return the default return <div>{defaultError}</div>} return this.props. } } export default MyErrorBoundaryCopy the code
The above provides three props, which is more flexible than the official website ErrorBoundary and can be used as follows:
const App = () => {
return (
<MyErrorBoundary fallback={<div>error</div>}>
<Demo />
</MyErrorBoundary>
)
}
Copy the code
However, some people prefer to use the fallback rendering function as a component, so for those people, we can add a FallbackComponent.
2.1 Errors can be retried
// Reset the component state and set error to null reset = () => {this.setState(initialState); ResetErrorBoundary = () => {if (this.props. OnReset) {this.props. OnReset (); } this.reset(); } render: () { if (error ! == null) { const fallbackProps: FallbackProps = { error, resetErrorBoundary: this.resetErrorBoundary, } if (needTry) { if (typeof tryFallback === 'function') { return tryFallback(fallbackProps); } return tryFallback ? <div onClick={this.resetErrorBoundary}>{tryFallback}</div> : <RetryComponent onClick={this.resetErrorBoundary} /> } if (React.isValidElement(fallback)) { return fallback; } if (FallbackComponent) { return <FallbackComponent {... FallbackProps} />} // Return the default return <div>{defaultError}</div>}}Copy the code
conclusion
- Add onReset to implement the reset logic.
- Add tryFallback to trigger retry
2.2 Listening to Render
The reset logic above is simple and useful, but sometimes it has limitations: the action that triggers the reset can only be in the Fallback.
Can we update the current component by adding different dependencies like useEffect?
In componentDidupdate to do resetDeps listener, as long as the component has render to see whether resetDeps inside the element is changed, changed will reset.
Const inspectChangedDeps = (preResetDeps: Array<unknown> = [], resetDeps: Array<unknown> = []) => { return preResetDeps.length ! == resetDeps.length || preResetDeps.some((item, index) => ! Object.is(item, resetDeps[index])); } // componentDidUpdate(prevProps: Readonly<PropsWithChildren<ErrorBoundaryProps>>) {const {error} = this.state; const { resetDeps, onResetDepsChange } = this.props; if (error ! == null && ! this.updatedWithError) { this.updatedWithError = true; return; } if (error ! == null && inspectChangedDeps(prevProps.resetDeps, resetDeps)) { if (onResetDepsChange) { onResetDepsChange(prevProps.resetDeps, resetDeps); } this.reset(); }}Copy the code
- Use updatedWithError as a flag to determine if render/update has been raised due to error;
- If there is no current error, it will not reset anyway;
- Render /update: set updatedWithError= true; render/update: set updatedWithError= true;
- Each update: there is an error, and if updatedWithError is true, it has been updated due to error. Future updates will be reset whenever something in resetDeps changes.
Conclusion:
- Add two props: resetDeps and onResetDepsChange to enable developers to automatically reset their props for listening to value changes.
- In componentDidUpdate, as long as not due to error caused component rendering or update, and resetDeps has changed, then directly reset the component state to achieve automatic reset;
2.3 Final code output
This component does the following:
- Enrich the ErrorBoundary function
- Provides custom component capabilities to display error content
- Added retry and retry custom component configuration ‘
- Resets the listener array, similar to dependencies in useEffect
- Access is simple, and there are two ways: MyErrorBoundary and withErrorBoundary
import React, { FC, Component, ComponentType, PropsWithChildren, ReactElement } from 'react'; Import {RetryComponent} from './Operation' // FallbackElement = ReactElement<unknown, string | FC | typeof Component> | null; // Props export interface FallbackProps {error: error; resetErrorBoundary: () => void; } // This component ErrorBoundary props interface ErrorBoundaryProps {fallback? : FallbackElement; defaultError? : FallbackElement | string FallbackComponent? : ComponentType<FallbackProps>; onError? : (error: Error, info: string) => void; needTry? : boolean; tryFallback? : (props: FallbackProps) => FallbackElement | FallbackElement; onReset? : () => void; resetDeps? : Array<unknown>; onResetDepsChange? : ( preResetDeps: Array<unknown> | undefined, resetDeps: Array<unknown> | undefined, ) => void; } / / this component ErrorBoundary props interface ErrorBoundaryState {error: error | null; } const inspectChangedDeps = (preResetDeps: Array<unknown> = [], resetDeps: Array<unknown> = []) => { return preResetDeps.length ! == resetDeps.length || preResetDeps.some((item, index) => ! Object.is(item, resetDeps[index])); } // initialState const initialState: ErrorBoundaryState = {error: null, } class MyErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> { state = initialState; updatedWithError = false; Static getDerivedStateFromError(error: error) {return {error}; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { if (this.props.onError) { this.props.onError(error, errorInfo.componentStack); } } componentDidUpdate(prevProps: Readonly<PropsWithChildren<ErrorBoundaryProps>>) { const { error } = this.state; const { resetDeps, onResetDepsChange } = this.props; if (error ! == null && ! this.updatedWithError) { this.updatedWithError = true; return; } if (error ! == null && inspectChangedDeps(prevProps.resetDeps, resetDeps)) { if (onResetDepsChange) { onResetDepsChange(prevProps.resetDeps, resetDeps); } this.reset(); } } reset = () => { this.updatedWithError = false; this.setState(initialState); } resetErrorBoundary = () => { if (this.props.onReset) { this.props.onReset(); } this.reset(); } render() { const { fallback, FallbackComponent, needTry = true, tryFallback, defaultError = 'Something went wrong.' } = this.props; const { error } = this.state; if (error ! == null) { const fallbackProps: FallbackProps = { error, resetErrorBoundary: this.resetErrorBoundary, } if (needTry) { if (typeof tryFallback === 'function') { return tryFallback(fallbackProps); } return tryFallback ? <div onClick={this.resetErrorBoundary}>{tryFallback}</div> : <RetryComponent onClick={this.resetErrorBoundary} /> } if (React.isValidElement(fallback)) { return fallback; } if (FallbackComponent) { return <FallbackComponent {... FallbackProps} />} // Return the default return <div>{defaultError}</div>} return this.props. }} /** ** @param Component * @param errorBoundaryProps error props */ function withErrorBoundary<T>( Component: ComponentType<T>, errorBoundaryProps: ErrorBoundaryProps ): ComponentType<T> { const Wrapped: ComponentType<T> = props => { return ( <MyErrorBoundary {... errorBoundaryProps}> <Component {... props} /> </MyErrorBoundary> ) } return Wrapped; } export { MyErrorBoundary, withErrorBoundary, }Copy the code
Third, what is the wrong boundary
The article also has a prize at the beginning, here again to emphasize. The official concept is as follows:
JavaScript errors in part of the UI should not crash the entire app, and React 16 introduced a new concept called error boundaries to address this issue. The error boundary is a React component that catches JavaScript errors that occur anywhere in the child component tree and prints those errors while displaying the degraded UI without rendering the child component tree that crashed. Error bounds catch errors during rendering, in lifecycle methods, and in constructors throughout the component tree.
3.1 An error was reported during rendering
The following situations are common:
- A null pointer error is reported when an object property is read with undefined or null
- A variable that does not exist is declared
function Demo(props) {
// Uncaught ReferenceError: xxx is not defined
console.log(aaa)
return <div>demo</div>;
}
function App() {
return (
<ErrorBoundary>
<Demo/>
</ErrorBoundary>
);
}
Copy the code
3.2 Life cycle error reporting
ComponentDidMount, componentDidUpdate, componentWillUnmount
3.2.1 componentDidMount
class Demo extends Component { componentDidMount() { // Uncaught ReferenceError: xxx is not defined console.log('componentDidMount'); console.log(aaaa); } render() { return <div>Demo</div> } } export default function App() { return ( <ErrorBoundary fallback={<div>error ComponentDidMount </div>} </ErrorBoundary>); }Copy the code
3.2.2 useEffect
// one function Demo() { useEffect(() => { console.log('useEffect'); console.log(eee); } []); return <div>Demo</div>; } export default function App() {return (<ErrorBoundary fallback={<div>error demo2 </div>}> <Demo /> </ErrorBoundary> ); }Copy the code
Function Demo({count}) {useEffect() => {return () => {console.log('useEffect destroy'); console.log(aaaa); } }, [count]); return <div>Demo</div>; } function App() { const [hide, setHide] = useState(false) const [count, SetCount] = useState(0) return (<ErrorBoundary fallback={<div>error demo3 </div>}> <div><button onClick={() => SetCount (count + 1)} > click + 1 < / button > < / div > < div > < button onClick = {() = > setHide (true)} > click uninstall < / button > < / div > {! hide && <Demo count={count}/>} </ErrorBoundary> ); }Copy the code
Count increases, there is render out the standby UI 关于 React Errorbundary > image2022-3-29_19-28-17.png”>
When I click uninstall, I find that the standby UI is not rendered. The result is as follows
Why can’t the latter be caught by the error boundary? Let’s look at the destruct code:
function safelyCallDestroy( current: Fiber, nearestMountedAncestor: Fiber | null, destroy: () => void, ) { try { destroy(); } useEffect destruct () {// The useEffect destruct () function is invalid. Render alternate UI captureCommitPhaseError(current, nearestMountedAncestor, error); }}Copy the code
We found that there was a catch, but it is important to note that for the Demo component to be uninstalled, because useEffect is scheduled asynchronously, When the destruction function is executed, it is found that the return(pointing to the parent fiber) of the Child component has been set to NULL. For details, you can check the commitDeletion and detachFiberMutation functions of the source code
3.2.3 useLayoutEffect
UseLayoutEffect calls the callback in the Layout phase and is executed synchronously. Because both callbacks and destructors are synchronous, errors are caught.
3.3 Cases where error boundaries do not work
3.2.1 An Error Is reported Outside the component
There is no catch in this case, so error bounds don’t work
console.log(xxx)
function Demo() {
return <div>Demo</div>
}
Copy the code
3.2.2 Asynchronous Code error
Asynchronous code is used in the lifecycle, useEffect, and useLayoutEffect. When an error is reported during the execution of a callback, it is no longer in the scope of the catch, so it cannot be caught
function Demo() { useLayoutEffect(() => { setTimeout( () => { console.log('useLayoutEffect'); console.log(xxx); }}), []); return <div>Demo</div>; } export default function App() { return ( <ErrorBoundary> <Demo /> </ErrorBoundary> ) }Copy the code
3.2.3 useEffect uninstall
Errors are also not caught when useEffect is uninstalled. You’ll see it in the demo.
3.2.4 Error boundary parent component reported an error
According to our analysis above, a component throwing an error will look up the error boundary, but if it is the parent of the error boundary, it will not find the error boundary no matter how hard it looks up.
3.2.5 Error bounds themselves throw errors
When ErrorBoundary itself reported an error, it could not capture it
3.2.6 Error in event function
const Demo = () => {
return (
<Button onClick={() => console.log(aaaa)}>Demo</Button>
);
}
export default function App() {
return (
<ErrorBoundary>
<Demo />
</ErrorBoundary>
);
}
Copy the code
3.4 Simple source code analysis
The idea is to use the Fiber. return list to find out if the parent class component implements the componentDidCatch method. CreateClassErrorUpdate internally calls the logError method, which prints the component tree, again using fiber.return upward to collect the component’s displayName.
The core source
3.4.1 Error capture
We can start by looking at the piece of code that builds the Fiber tree to catch errors when they occur in the workflow
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
Copy the code
When our code executes to console.log(aaa) and throws an error, we catch it and go to handleError.
The COMMIT phase involves a lot of work, such as:
-
componentDidMount/Update
To perform, useEffect/useLayoutEffect``callback
withdestroy
perform
This work is performed as follows, with errors being handled by captureCommitPhaseError:
Try {/ /... } Catch (error) {captureCommitPhaseError(fiber, fiber.return, error); }Copy the code
3.4.2 constructs the callback
After the above steps, we see that even without ErrorBundary, these errors are caught by React, so the logic should read:
- If ErrorBundary exists, the corresponding API is executed
- The React prompt message is displayed
- If ErrorBundary does not exist, an uncaught error is thrown
Therefore, no matter handleError or captureCommitPhaseError, both start from the parent node of the node where the Error occurred and go up layer by layer to find the nearest Error Boundary.
CreateClassErrorUpdate createClassErrorUpdate createClassErrorUpdate createClassErrorUpdate createClassErrorUpdate createClassErrorUpdate
- The payload getDerivedStateFromError
- The callback componentDidCatch
// For example, payload is {hasError: true} return getDerivedStateFromError(error); }; const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { const error = errorInfo.value; update.payload = () => { return getDerivedStateFromError(error); }; update.callback = () => { if (__DEV__) { markFailedErrorBoundaryForHotReloading(fiber); } logCapturedError(fiber, errorInfo); }; }Copy the code
Rule 3.4.3 execute callback
When is the constructed callback executed
React has two apis that perform user – defined callbacks
- for
ClassComponent
, have Errorboundary,this.setState(newState, callback)
In thenewState
andcallback
All parameters can pass Function as callback, which is equivalent to initiating an update.
This.setstate (() => {// Callback for getDerivedStateFromError}, () => {// Callback for executing componentDidCatch and callback for throwing React prompt})Copy the code
- For the root node, execute
ReactDOM.render(element, container, callback)
In thecallback
The argument can pass Function as callback, which executes the following Function
Reactdom.render (Element, container, () => {// callback to throw "uncaught error" and "React prompt"})Copy the code
So ErrorBundary’s implementation can be said to be a function implemented by React using existing apis.
3.4.1 track thinking
It is often asked: why do Hooks not have Error Boundary?
It can be seen that the implementation of Error Boundary makes use of the feature that this.setState can transmit callback, and useState cannot completely calibrate for the time being.
If you are interested in the source code, you can peruse it for yourself.
Four,
This paper has realized the basic components of ErrorBoundary, please refer to Chapter 3 for specific use. I also explained the application conditions and inapplicable scenarios of ErrorBoundary. I hope this paper can help people better understand the principle of ErrorBoundary. At the same time, I also want to know that ErrorBoundary is not universal, it also has the scope of application, and the wrong use will lead to the failure of ErrorBoundary.