likes
comments
collection
share

在 React 中如何处理异常

作者站长头像
站长
· 阅读数 21

原文地址:medium.com/@adevnadia/…

原文作者:Nadia Makarevich

我们都希望我们的应用能够保持稳定,没有 bug,并且满足任何可以想象的边缘场景,不是吗?但现实是我们都是人(至少这是我的设想),我们都会犯错,并不存在没有 bug 的代码。无论我们多小心,无论我们写了多少自动化测试,总会存在出现严重问题的情况。重要的是,当涉及到用户体验时,预测那可能发生的糟糕场景,尽可能的本地复现出,并以优雅的方式处理它,直到它能够真正修复。

所以今天,我们来看下在 React 中如何处理错误:当异常发生时我们可以做什么,错误捕获的不同方法有哪些限制,该如何去减少这些限制。

为什么我们需要在 React 中捕获异常

首先:为什么在 React 中捕获异常是十分重要的?

答案很简单:从 16 版本开始,在 React 生命周期中被抛出的异常如果没有被捕获会导致整个应用卸载。在那之前,即使是布局或者行为有误,组件也会在屏幕中展示。现在,在一些无关紧要的 UI 部分,甚至是你无法控制的某些外部库中出现的未捕获异常,都有可能导致整个页面渲染空白。

前端开发者从未拥有如此强大的破坏力😅

回忆下在 JavaScript 中是如何捕获异常的

在常规的 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!
}

除此之外,如果我们使用老派的 promise,我们有专门针对这种 api 的 catch 方法。所以如果我们使用基础的 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 {
      // 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} />
}

在例子中我们尝试去发送一个请求,当请求失败时设置一个错误态,如果错误态为 true,我们就渲染一个错误提示给用户,像是联系电话。

这个方法是非常直接的,很适用于简单的、可预测的、有局限的用例,例如捕获失败的 fetch 请求。

但如果你想要捕获组件中可能发生的全部异常,你可能会遇到一些挑战和严重的限制。

限制 1:在 useEffect hook 中不适用

如果我们使用 try/catch 包裹住 useEffect,它并不会生效:

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

因为 useEffect 是在渲染后异步执行的,所以从 try/catch 的视角来看,一切都很成功。这样的事情在 Promise 中也是一样的:如果我们不等待 Promise 返回结果,JavaScript 会继续执行,等到 promise 完成后再返回结果,并且只执行 useEffect(或者是 Promise 的 then 方法) 中的内容。 try/catch 将会执行并且那个时候早已消失。

为了在 useEffect 内部捕获异常,try/catch 也应该放到内部:

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

这适用于任何使用了 useEffect 的钩子或者任何其他的异步钩子。结果,你需要将其拆分成多个块:每个钩子一个,而不是仅使用一个 try/catch 包裹一切。

限制 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
  }
}

出现这个的原因是因为当我们写下 <Child /> 时我们并不是真的在渲染这个组件。我们只是在创建一个组件元素,它只不过是一个组件的定义。它只不过是一个包含了 React 后续渲染这个组件时会使用到的必要信息,像是组件类型和参数的对象。而这将会在 try/catch 块成功执行完成才执行,和 promises 、useEffect 钩子是一样的。

如果你对元素和组件是怎么运作的感兴趣,推荐这篇文章给你:The mystery of React Element, children, parents and re-renders

限制 3:在渲染时设置状态是不允许的

如果你尝试在 useEffect 和各种回调(例如:在组件渲染阶段)外捕获异常,那么正确的处理异常就不再那么简单了:在渲染阶段更新状态是不允许的。

下面的代码在异常出现时将会导致无限重渲染:

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

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

当然,我们可以仅返回错误提示而不是设置状态:

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

但是可以想象这样的处理是笨重的,并且会迫使我们在同个组件中使用不同的异常处理方式:useEffect 和回调的状态,以及其他所有内容的直接返回。

// 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,我们要么会遗漏大部分的错误,要么会将组件变得难以理解,可能会自行导致错误。

幸运的是还有另一种处理方式。

React 的 ErrorBoundary 组件

为了减少上面提到的限制,React 提供了“ErrorBoundary”:一个特殊的 API 用于将常规的组件转换成 try/catch 表达式,注意仅适用于 React 声明的代码。经典用法是这样的:

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

现在,如果其他组件或者子组件在渲染阶段出现异常,异常也都能被捕获和解决。

但是 React 本身并没有提供这样的组件,它只是提供工具去实现这样能力的组件。最简单的实现方式是这样的:

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;
  }
}

我们创建了一个常规的类组件(在这里使用老派的方式,因为没有 hook 支持实现 ErrorBoundary)并使用 getDerivedStateFromError 方法 - 这个方法会将组件转换成适当的 ErrorBoundary。

处理异常另一件重要的事情是将这些异常信息暴露给值班人员。为了这,ErrorBoundary提供了componentDidCatch 方法:

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

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

在 ErrorBoundary 建立后,我们就可以做任何我们想做的事情了,其他组件也是一样的处理方式。例如,将这个组件变得更加通用,还可以将兜底组件作为参数进行传递:

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

  return this.props.children;
}

像这样使用:

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

或者其他我们需要的东西,如点击按钮重置状态,区分异常类型,或者将异常推送到某处的上下文。

不过,在这个没有错误的世界里有一个警告:它并不能捕捉到所有东西。

ErrorBoundary 组件的限制

ErrorBoundary 仅能捕获发生在 React 生命周期内的错误。除此之外的错误,如解决态的 promise,带有 setTimeout、各种回调和事件处理器的异步代码,如果不明确处理就会消失。

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 来处理这类异常。但至少在这里我们可以安全地使用 state:事件回调正是我们通常设置状态的地方。事实上,我们可以结合这两种方法做这样的事情:

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 或者 context 抛给具有 ErrorBoundary 的父组件进行处理,而不是在组件级别处理这些异常。通过这种方式至少我们在一个地方有兜底组件:

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 捕获那些来自异步代码和事件处理的异常吗?

使用 ErrorBoundary 捕获异步代码

十分有趣的是我们可以使用 ErrorBoundary 来捕获这些异常!

大家都喜爱的 Dan Abramov 和我们分享了一个很酷但是 hack 的方式去实现:Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react

这里的技巧是首先使用 try/catch 捕获异常,然后在 catch 表达式中触发正常的 React 重渲染,然后重新抛出那些异常到重渲染的生命周期中。通过这种方式 ErrorBoundary 可以将它们捕获为任何其他异常。由于状态更新会触发重渲染,并且状态设置函数可以接收更新函数作为参数,因此解决方案是纯粹的魔法:

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 的方式进行抽象,这样我们就不需要在每个组件中都创建随机的状态。我们可以发挥创意,制作一个钩子,给我们一个异步错误抛出器:

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>
}

或者任何你认为值得并且应用程序需要的东西。没有限制!任何异常都不会再消失。

我可以仅使用 react-error-boundary 吗?

对于不喜欢重复造轮子或者喜欢使用已经解决了这个问题的库的人来说,这有一个很不错的实现灵活 ErrorBoundary 组件并具有一些类似上述的实用工具的工具:GitHub - bvaughn/react-error-boundary: Simple reusable React error boundary component

是否使用这个工具只是个人取向、编程风格和组件中独特情况的问题。


这就是全部内容啦,希望从现在起当你的应用中出现异常时,你都可以轻松并优雅的进行处理。

最后请记住:

  • try/catch 块不会捕获像 useEffect 这类钩子和子组件内部的异常
  • ErrorBoundary 可以捕获这类异常,但是它不能捕获异步代码和事件处理器中的异常
  • 尽管如此,你可以使用 ErrorBoundary 去捕获这些异常,你只需要先使用 try/catch 捕获它们,然后将这些异常重新抛回 React 生命周期中