让React项目不会崩溃的处理

213 阅读11分钟

我们都希望我们的应用程序是稳定的,能够完美地工作,并满足每一个可以想象到的情况,但是!我们都是人啊,是人就会犯错,没有人敢说他写的代码一直都是完美的。不管我们有多小心,也不管我们写了多少自动化测试的脚本,总会有出错的情况发生。重要的是,当涉及到用户体验时,我们可以预测那个可怕的东西,尽可能地把它本地化,并以一种优雅的方式处理它,直到它能够被真正地修复。 所以今天,让我们来看看React中的错误处理:如果发生了错误,我们可以做什么,不同的错误捕捉方法有哪些注意事项,以及如何减轻它们。

为什么要在react中捕获错误呢?

为什么在React中拥有一些错误捕获解决方案是极其重要的呢? 答案很简单:从16版开始,在React生命周期中抛出的错误,如果不停止的话,将导致整个应用自行卸载。在此之前,组件会被保留在屏幕上,即使是一些错误的形态。现在,在UI中一些无关紧要的部分,甚至是一些你无法控制的外部库中,一个不幸的未被捕获的错误就可以导致整个页面白屏!这是我们开发的时候所无法接受的

如何在js中进行错误捕获?

我们可以使用try/catch在javascript中进行错误捕获处理

try {

	doSomething();

} catch (e) {

	console.log(e)

}

这也同样适用于异步函数

try {

	await fetch('/bla-bla');

} catch (e) {

	console.log(e)

}

或者我们在遇到promise时调用它的catch方法进行捕获

fetch('/bla-bla').then((result) => {

	// if a promise is successful, the result will be here

}).catch((e) => {

	console.log(e)

})

在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钩子时遇到麻烦。

如果我们用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的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;
}

发生这种情况是因为当我们写时,我们实际上并没有渲染这个组件。我们所做的是创建一个组件元素,这只不过是一个组件的定义。它只是一个包含必要信息的对象,比如组件的type和props,以后会被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 below with live example
    setHasError(true);
  }
}

当然,我们可以在这里直接返回一个错误组件,而不是设置状态。

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

但是,正如你所想象的,这有点麻烦,并且将迫使我们以不同的方式处理同一组件中的错误: useEffect 和 callback 的 state,以及其他所有内容直接 return。

// 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 component

为了减轻上面的限制,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;
  }
}

我们创建了一个普通的类组件,并实现了getDerivedStateFromError方法,将该组件变成一个适当的错误边界。

在处理错误时,另一件重要的事情是将错误信息发送到某个地方,让它能够唤醒所有正在值班的人。为此,错误边界给了我们componentDidCatch方法。

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

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

在建立了错误边界之后,我们可以对它进行任何我们想要的操作,就像任何其他组件一样。例如,我们可以让它更具可重用性,并将后备系统作为一个支柱:

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 component的限制

错误边界只捕获在 React 生命周期中发生的错误。在它之外发生的事情,如已resolve的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来处理这类错误。而且至少在这里我们可以安全地使用状态(或多或少):事件处理程序的回调正是我们通常设置状态的地方。所以从技术上讲,我们可以把两种方法结合起来,做这样的事情。

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和我们分享了一个很酷的黑客技术。大家最喜欢的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;
      })
    }
  }
}

这里的最后一步将是提取出公共的方法,所以我们不必在每个组件中创建随机状态。我们可以在这里发挥创意,做一个钩子,给我们一个异步错误抛出器。

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

这样就没有限制! 再也没有错误可以逃脱了。

让我们来总结一下以上的内容:

  • try/catch块不会捕捉像useEffect这样的钩子和任何子组件中的错误。
  • ErrorBoundary可以捕捉它们,但它不会捕捉异步代码和事件处理程序中的错误。
  • 不过,你可以让ErrorBoundary捕捉这些错误,你只需要先用try/catch捕捉它们,然后再把它们重新扔回React生命周期中。

相关链接:www.developerway.com/posts/how-t…