程序员学习英语之翻译路-01

162 阅读11分钟

www.developerway.com/posts/how-t…

如何在react中处理异常:指南

研究如何在React中捕获和处理异常。

try/catch和ErrorBoundary的用法、模式和注意事项,什么是可能的,什么是不可能的。如何用ErrorBoundary组件捕获所有的异常:包括异步以及事件处理程序。

为什么我们需要在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
}

相同的语法也适用于异步函数:

try {
  await fetch('/bla-bla');
} catch (e) {
  // oh no, the fetch failed! We should do something about it!
}

或者,如果我们用Promise,我们有专门针对他们的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:操作方法和注意事项

当发现错误时,我们需要对其采取一些措施,对吗?那么除了将其记录在某个地方外, 我们还能做什么呢?或者更准确的说:我们可以为用户做什么?留一个空白的页面或者异常的界面对用户非常不友好。

最明显最直观的是在我们修复之前,渲染一些其他的内容。我们可以在异常的时候做任何我们想做的,包括设置状态。所以我们可以这样做:

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请求,如果失败,设置错误状态。如果错误状态被设置成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的情况一样:如果我们不等结果,javascript会继续执行其他任务,当Promise完成时返回它,并且只执行在useEffect(或者Promise)内部的内容。try/catch块会被执行并且早已消失。

为了捕获useEffect里的错误,try/catch也应该放在里面:

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

尝试一下这个例子来看看它:

codesandbox.io/s/try-catch…

这适用于任何使用useEffect的钩子或任何异步的东西。因此,你必须将他分成多个块:每个钩子一个,而不是仅用一个try/catch包裹所有。

限制2:children组件。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
  }
}

尝试一下这个例子来看它:

codesandbox.io/s/try-catch…

发生这种情况是当我们写组件时我们并没有实际渲染这个组件。我们在做的是创建一个组件元素,它只是组件的定义。它只是一个包含必要信息如组件类型和参数的对象,稍后将由React本身使用,这时才会实际触发组件的渲染。它将在try/catch块成功执行时发生,与Promise和useEffect hook的情况完全一样。

如果你想学更多信息关于元素和组件如何工作,这是为你准备的文章:The mystery of React Element, children, parents and re-renders

限制3:在渲染期间设置state是禁忌

如果你尝试捕获useEffect和各种回调之外的错误(即在组件渲染期间),那么合适的处理它们不再那么简单:渲染期间不运行更新state。

例如:如果发生错误,像这样的简单代码会造成无限渲染。

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 below with live example
    setHasError(true);
  }
}

codesandbox

当然我们可以只返回异常界面而不是设置state:

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为我们提供了所谓的错误边界:一个特殊的API以某种方式把一个常规组件转化成try/catch声明,仅适用于React声明式代码。你在每个示例(包括 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;
  }
}

我们创建一个普通Class组件(这里是老式的,没有可用的错误边距hooks)并实现getDerivedStateFromError方法-将组件转换位合适的错误边界。

处理错误时要做的另一件重要的事情是将错误信息发送到可以唤醒每个on-call人员的地方。为此,错误边界提供了componentDidCatch方法:

class ErrorBoundary extends React.Component {
  // everything else stays the same
 
  componentDidCatch(error, errorInfo) {
    // send error to somewhere here
    log(error, errorInfo);
  }
}

设置错误边界后,我们可以用它做任何我们想做的事情,就像任何其他组件一样。 例如,我们可以使其更具可复用性,将fallback作为 prop 传递:

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

或者我们需要的任何其他。例如在按钮点击时重置状态、区分错误类型或者把错误提交到某个上下文。

完整的例子如下:

codesandbox.io/s/simple-er…

不过,在这个无错误的世界中,有一个告警:它并不能捕获所有内容。

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传到它们的父组件。这种方式至少我们可以在一个地方有兜底组件:

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 与我们分享了一个很酷的技巧来实现这一目标: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;
      })
    }
  }
}

完整例子在这里:

codesandbox.io/s/simple-er…

这里的最后一步是将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>
}

或者你内心想要的和应用程序需要的任何其他内容。 没有限制!并且不会再出现任何错误。

完整的示例如下:

codesandbox.io/s/simple-er…

我可以只使用react-error-boundart代替吗?

对于那些讨厌重复造轮子或者只是更喜欢使用已经解决的三方库的人来说,有一个很好的库它实现了灵活的ErrorBoundary组件并且有一些类似于上面描述的有用的实用程序:GitHub - bvaughn/react-error-boundary。是否使用它只是个人喜好、编码风格和组件中的独特情况的问题。


这就是今天分享的所有内容,希望从现在开始如果你的app出现问题,你会轻松且优雅的处理这些情况。

记住:

  1. try/catch块不会捕获useEffect等钩子内以及任何子组件内的错误
  2. ErrorBoundary可以捕获它们,但是它不会捕获异步代码以及事件处理程序中的错误
  3. 不过,你可以让 ErrorBoundary 捕获这些,你只需要先用 try/catch 捕获它们,然后将它们重新扔回 React 生命周期