Skip to content
On this page

2023-02-如何处理react的异常完整指南

我们都希望我们的应用是稳健的,工作高效的,而且可预知每一个异常情况。不是吗?但是,令人沮丧的是,我们都只是普通人,我们总是写bug,而且不存在没有bug的代码。无论我们怎么努力或者做了多少次测试,总是有一些可怕bug出现。最重要的,对于用户体验来说,能优雅的处理bug产生的错误是一件很有挑战性的工作。尽可能定位bug的位置,尽最大努力处理它,直到它被彻底修复。 所以今天,我们来看一下react中怎么处理错误的情况。当一个错误出现时,我们应该怎么办;不同的错误捕捉方式有什么不同,怎么正确的选择。

1. 为什么我们要在React中捕获错误

首先第一件事,React中有一些错误捕获方式为什么很重要? 简单的回答是:从react16开始,当在react的生命周期中报错时,react会卸载掉整个app。在此之前,发生错误时所有的组件是在页面上展示的。但是现在,一个位置的ui上的错误,或者你依赖的一个第三方库的错误,都会使你的页面展示一张空白,像被销毁了一样。 在此之前,前端开发从来没有这么大的破坏力。

2. 还记的我们在js中怎么捕获异常吗

当我们在普通js中捕获那些令人讨厌的惊喜时,方式是非常简单的 当我们使用我们旧的方式try-catch时,它逻辑上是很清晰的,try里包裹可能出错的代码,catch中捕获那些错误。

javascript
try{
  doSomething();
}catch(e){

}

async函数中也同样适用

javascript
try{
  await fetch()
}catch(e){

}

promise函数也有catch方法,像下面这样

jsx
fetch()
  .then()
  .catch(()=>{

  })

这都是相同的概念,只是实现上有不同,所以接下来的文章,我们要用try-catch处理所有的错误。

3. React简单使用try-catch的注意事项

当我们捕获一个异常时,我们需要处理它,对吗?所以,正确的处理方式是什么?难道不是找个地方打印它吗?或者更确切的,难道我们就把这个空白大页面这样不友好的扔在那里吗? 最直观的解决方法就是在我们修复之前,展示点别的什么东西。幸运的是,我们可以在catch语句块中做任何事情,我们可以设置像下面一样的state.

jsx
const SomeComponent = () => {
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // oh no! the fetch failed, we have no data to render!
      setHasError(true);
    }
  })

  // something happened during fetch, lets render some nice error screen
  if (hasError) return <SomeErrorScreen />

  // all's good, data is here, let's render it
  return <SomeComponentContent {...datasomething} />
}

我们尝试发送一个fetch请求,如果它失败了,设置错误状态,如果error的状态是true,我们就render错误的页面给用户,再加一些其他信息,比如重试。 这种方式是非常简单高效的,这个例子也仅仅是fetch的例子。 如果你想捕获一个组件中所有可能发生的错误,你将面临一些挑战和局限。

3.1 局限1: 你将要处理useEffect的错误

如果我们用try-catch包括useEffect,它将不会捕获错误,像下面这样

jsx
try {
  useEffect(() => {
    throw new Error('Hulk smash!');
  }, [])
} catch(e) {
  // useEffect throws, but this will never be called
}

因为useEffect是异步执行的,try-catch里的代码执行时,useEffect并没有执行,其他代码不会报错,所以catch没有捕获错误。 我们可以改成这样

jsx
useEffect(() => {
 try {
   throw new Error('Hulk smash!');
 } catch(e) {
   // this one will be caught
 }
}, [])

这个是可行的,不仅仅是useEffect,其他任何异步的方式这样都是可以捕获异常的。但是,你必须把你的代码分成两块,而且每个hook都要这么写。

3.2 局限2: 子组件

try-catch不支持子组件的写法。假如child组件中报错,我们需要捕获,我们似乎可以写成这样:

jsx
const Component = () => {
  let child;

  try {
    child = <Child />
  } catch(e) {
    // useless for catching errors inside Child component, won't be triggered
  }

  return child;
}
jsx
const Component = () => {
  try {
    return <Child />
  } catch(e) {
    // still useless for catching errors inside Child component, won't be triggered
  }
}

上面两种都不会捕获到错误,因为我们写<Child />时,我们并不是真正的渲染它,我们只是创建了一个Element对象,它只是一个组件的定义,只是一个包含了type和props的组件定义。它之后会被react真正的执行渲染。所以这里的try-catch不会报错。

3.3 局限3: 渲染时改变状态是不可取的

如果我们想捕获useEffect外面的错误,像下面这样

jsx
const Component = () => {
  const [hasError, setHasError] = useState(false);

  try {
    doSomethingComplicated();
  } catch(e) {
    // don't do that! will cause infinite loop in case of an error
    setHasError(true);
  }
}

这样的代码会导致死循环,我们其实只需要在catch里返回一个降级页面

jsx
const Component = () => {
  try {
    doSomethingComplicated();
  } catch(e) {
    // this allowed
    return <SomeErrorScreen />
  }
}

但是,跟你想的一样,这只是冰山一角,我们需要在一个组件中用不同的方式处理错误。像下面这样

jsx
// while it will work, it's super cumbersome and hard to maitain, don't do that
const SomeComponent = () => {
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // can't just return in case of errors in useEffect or callbacks
      // so have to use state
      setHasError(true);
    }
  })

  try {
    // do something during render
  } catch(e) {
    // but here we can't use state, so have to return directly in case of an error
    return <SomeErrorScreen />;
  }

  // and still have to return in case of error state here
  if (hasError) return <SomeErrorScreen />

  return <SomeComponentContent {...datasomething} />
}

我们总结一下,如果我们在react中使用try-catch,我们将在捕获了错误的同时,写下了大量的冗余代码。 幸运的是,我们有其他方式

4. React ErrorBoundary

为了解决上面的麻烦,react给我们提供了ErrorBoundary,一个特殊的API在react代码里,在某种程度上替换了try-catch。

jsx
const Component = () => {
  return (
    <ErrorBoundary>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}

现在,只要在他们组件或者他们子组件的render方法里报错,我们都可以捕获并处理他们。 但是react本身并没有提供组件,它只是告诉我们怎么实现他,简单的实现大概如下

jsx
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // initialize the error state
    this.state = { hasError: false };
  }

  // if an error happened, set the state to true
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    // if error happened, return a fallback component
    if (this.state.hasError) {
      return <>Oh no! Epic fail!</>
    }

    return this.props.children;
  }
}

我们创建了一个常规的class组件,实现了getDerivedStateFromError方法,这个方法把这个组件变成了ErrorBoundary。 另一件重要的事情是,我们要处理这个错误,把这个错误发给那个解决它的人。所以,react还提供了componentDidCatch方法。

jsx
class ErrorBoundary extends React.Component {
  // everything else stays the same

  componentDidCatch(error, errorInfo) {
    // send error to somewhere here
    log(error, errorInfo);
  }
}

在设置好这些之后,我们可以再优化一下ErrorBoundary,把fallback当做props传入

jsx
render() {
  // if error happened, return a fallback component
  if (this.state.hasError) {
    return this.props.fallback;
  }

  return this.props.children;
}

使用的时候

jsx
const Component = () => {
  return (
    <ErrorBoundary fallback={<>Oh no! Do something!</>}>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}

但是当我们在一个按钮点击事件上发生错误时,它捕获不到。

javascript
const add = () => {
  throw new Error('事件error');
}

这就告诉我们,ErrorBoundary不是万能的。

5. ErrorBoundary的局限

ErrorBoundary只能捕获React生命周期中的错误,一些生命周期之外的比如promise、异步代码、回调函数和事件处理,如果我们不处理,会怎么样呢?

jsx
const Component = () => {
  useEffect(() => {
    // this one will be caught by ErrorBoundary component
    throw new Error('Destroy everything!');
  }, [])

  const onClick = () => {
    // this error will just disappear into the void
    throw new Error('Hulk smash!');
  }

  useEffect(() => {
    // if this one fails, the error will also disappear
    fetch('/bla')
  }, [])

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () => {
  return (
    <ErrorBoundary>
      <Component />
    </ErrorBoundary>
  )
}

常规的推荐做法是用try-catch捕获这些错误。如下

jsx
const Component = () => {
  const [hasError, setHasError] = useState(false);

  // most of the errors in this component and in children will be caught by the ErrorBoundary

  const onClick = () => {
    try {
      // this error will be caught by catch
      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,如下

jsx
const Component = ({ onError }) => {
  const onClick = () => {
    try {
      throw new Error('Hulk smash!');
    } catch(e) {
      // just call a prop instead of maintaining state here
      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做异步错误的捕获吗?

6. 用ErrorBoundary做异步错误捕获

有趣的是,我们可以用ErrorBoundary捕获这些,大众偶像Dan Abramov分享给我们一个hack方法

Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react.

这个方法首先还是用try-catch捕获异常,然后在catch中触发react的渲染,在渲染中抛出异常。这样ErrorBoundary就可以在任何地方捕获错误了。 而且由于state更新是触发重新渲染的原因,setState函数可以接受一个函数作为参数,我们可以写成这样

jsx
const Component = () => {
  // create some random state that we'll use to throw errors
  const [state, setState] = useState();

  const onClick = () => {
    try {
      // something bad happened
    } catch (e) {
      // trigger state update, with updater function as an argument
      setState(() => {
        // re-throw this error within the updater function
        // it will be triggered during state update
        throw e;
      })
    }
  }
}

最后一步我们需要提取这种hack方式,我们不必在每个组件中都创建一个state,我们可以用hook自定义

jsx
const useThrowAsyncError = () => {
  const [state, setState] = useState();

  return (error) => {
    setState(() => throw error)
  }
}

这样使用

jsx
const Component = () => {
  const throwAsyncError = useThrowAsyncError();

  useEffect(() => {
    fetch('/bla').then().catch((e) => {
      // throw async error here!
      throwAsyncError(e)
    })
  })
}

我们也可以封装接受函数的hook

jsx
const useCallbackWithErrorHandling = (callback) => {
  const [state, setState] = useState();

  return (...args) => {
    try {
      callback(...args);
    } catch(e) {
      setState(() => throw e);
    }
  }
}

使用如下

jsx
const Component = () => {
  const onClick = () => {
    // do something dangerous here
  }

  const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

  return <button onClick={onClickWithErrorHandler}>click me!</button>
}

这样,既满足你的期望,又达到了程序的要求,而且所有的错误都会被捕获到。

7. 我们可以用react-error-boundary替代吗

对于那些讨厌重新发明轮子的人,那些只是希望第三库来解决他们问题的人,[react-error-boundary](GitHub - bvaughn/react-error-boundary)确实是一个不错的方案。

8. 原文地址

https://www.developerway.com/posts/how-to-handle-errors-in-react

Released under the MIT License.