灏天阁

如何在react中处理报错

· Yin灏

我们都希望我们应用能稳定、完美运行,并且能够考虑到每一个边缘情况。但是现实情况是,我们是人,是人就会犯错,并且也不存在没有 bug 的代码。无论我们多么小心或者编写了多少自动化测试,总会有出现严重错误的情况。重要的是,当涉及到用户体验时,要预测那可怕的事情,尽可能地定位它,并以优雅的方式处理它,直到它真正被修复。

所以今天,让我们来看看 React 中的错误处理:如果发生错误,我们可以做什么,不同的错误捕捉方法的注意事项是什么,以及如何减小错误的影响。

为何要捕获 react 中的错误

那么第一件事:为什么在 React 中拥有一些错误捕获解决方案是极其重要的?

这个答案很简单:从 16 版开始,在 React 生命周期中抛出的错误,如果不停止的话,将导致整个应用自行卸载。在此之前,组件会被保留在屏幕上,即使是样式错误和交互错误的。现在,在 UI 的一些无关紧要的部分,甚至是一些你无法控制的外部库中,一个未被捕获的错误也可以使整个页面挂掉,给用户呈现白屏。

在此之前,前端开发人员从来没有过这样的破坏力。

还记得在 js 中是如何捕获错误信息的吗?

在 javascript 里如何捕获错误?众所周知,我们可以使用 try/catch 语句:在 try 里做一些事情,在它们执行失败的时候 catch 这些错误来减少影响

try {
  // 如果执行的出现错误,这里会抛出错误
  doSomething();
} catch (e) {
  // 如果错误发生了,捕获它并且在不阻止应用的时候处理它
  // 比如,发送这个错误到打点服务
}

相同的语法也适用于 async 函数

try {
  await fetch("/bla-bla");
} catch (e) {
  // fetch失败,我们可以一些事情
}

如果我们正在使用旧的 promises 规范,它有专门的方法来捕获错误。我们可以基于 promise 的 API 来重写 fetch 例子,像下面这样:

fetch("/bla-bla")
  .then((result) => {
    // 如果承诺成功,结果将在这里
    // 我们可以用它做一些有用的事情
  })
  .catch((e) => {
    // 请求失败了,我们应做一些事情来处理它
  });

以上两种是相同的概念,只是实现方式稍有不同,因此在接下来的文章中,我将只对 try/catch 错误使用语法。

在 React 中的 try/catch:如何操作和注意事项

当一个错误被捕获时,我们需要对它做些什么?除了把它记录在某个地方之外,我们还能做什么?更准确地说:我们能为我们的用户做什么?仅仅让他们面对一个空屏幕或者一个不友好的界面。
最明显和最直观的答案是在等待我们修复的时候渲染一些东西。幸运的是,我们可以在这个 catch 语句中做任何我们想做的事情,包括设置状态。所以我们可以做一些事情像这样:

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

  useEffect(() => {
    try {
    } catch (e) {
      setHasError(true);
    }
  });

  if (hasError) return <SomeErrorScreen />;

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

我们试图发送一个获取请求,如果请求失败了–设置错误状态,如果错误状态为真,那么我们就渲染一个错误反馈的 UI,为用户提供一些额外的信息,比如支持联系号码。

这种方法非常简单,非常适合简单、可预测且范围狭窄的用例,例如捕获失败的 fetch 请求。

但是如果你想捕捉一个组件中可能发生的所有错误,你将面临一些挑战和严格的限制。

限制 1:你会在使用 useEffect 钩子时遇到困难

如果我们用 try/catch 包住 useEffect,hook 就失效了。

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

发生这种情况是因为 useEffect 是在渲染后被异步调用的,所以从 try/catch 的角度来看,一切都很顺利。这和任何 Promise 都是一样的:如果我们不等待结果,那么 javascript 就会继续它的工作,在承诺完成后返回,并且只执行 useEffect(或 Promise)中的内容。

为了让在 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 包裹所有代码,而是将其拆分到每个 hook 中

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

可以看一下这个例子codesandbox.io/s/try-catch…

发生这种情况是因为当我们写时,我们实际上并没有渲染这个组件。我们所做的是创建一个组件元素,这只是一个组件的定义。它只是一个包含必要信息的对象,比如组件类型和道具,以后会被 React 本身使用,它将实际触发这个组件的渲染。它将在 try/catch 块成功执行后发生,与 promises 和 useEffect 钩子的情况完全一样。

如果你想更详细地了解元素和组件的工作原理,这里有一篇文章适合你:React 元素、子代、父代和重排的奥秘

限制 3:在渲染过程中设置 state 是不可取的

如果你想在 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 和回调进行状态处理,而对其他的直接返回错误组件(—–)

// 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 {
    // 在渲染时,做一些事情
  } catch (e) {
    return <SomeErrorScreen />;
  }

  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); // 初始化error的状态
    this.state = { hasError: false };
  } // 当error发生,设置这个状态为true

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    // 如果error发生,返回一个回退组件
    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);
  }
}

在错误边界设置后,我们可以像使用其他组件一样使用它。比如,我们可以将其优化得更便于重用,并将fallback做为 props 来传递

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

或者其他我们可能需要的东西,比如点击按钮时重置状态,区分错误类型,或者将错误传递到某个上下文中。查看完整的例子:4ldsun.csb.app/

不过,这里有一个注意事项:它并不能捕获一切错误

错误边界组件的限制

错误边界只捕捉发生在 React 生命周期中的错误。在生命周期之外发生的事情,如resolved promise、带有setTimeout的异步代码、各种回调和事件监听函数,如果没有被不明确处理,将不能捕获。

const Component = () => {
  useEffect(() => {
    // 这个错误将会被错误边界组件捕获
    throw new Error("Destroy everything!");
  }, []);

  const onClick = () => {
    // 这个错误将不能被捕获处理
    throw new Error("Hulk smash!");
  };

  useEffect(() => {
    // 这个错误将不能被捕获处理
    fetch("/bla");
  }, []);

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

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

这里的建议是使用常规的 try/catch 来处理这类错误。而且至少在这里我们可以安全地使用 state:事件监听函数的回调正是我们通常setState的地方。所以从技术上讲,我们可以把两种方法结合起来,做这样的事情。

const Component = () => {
  const [hasError, setHasError] = useState(false); // 通过错误边界,组件和子组件的错误绝大部分都会被捕获

  const onClick = () => {
    try {
      // 这里的错误将会被捕获
      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 的父级。这样的话,我们只需要在一个地方设置一个 “fallback"组件。

const Component = ({ onError }) => {
  const onClick = () => {
    try {
      throw new Error("Hulk smash!");
    } catch (e) {
      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. (github.com/facebook/re…

这里的技巧是先用 try/catch 捕捉这些错误,然后在 catch 语句中触发正常的 React 重渲染,然后把这些错误重新抛回重渲染的生命周期。这样,ErrorBoundary 就可以像捕获其他错误一样捕捉它们。由于 state 变化是触发重新渲染的方式,而 setState 实际上可以接受一个更新函数作为参数,这个解决方案是纯粹的黑魔法。

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

  const onClick = () => {
    try {
      // something bad happened
    } catch (e) {
      setState(() => {
        throw e;
      });
    }
  };
};

完整例子在这里:codesandbox.io/s/simple-as…

这里的最后一步将其抽象化,所以我们不必在每个组件中创建随机状态。我们可以在这里发挥创意,实现一个钩子用来将异步错误抛出。

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-as…

可以用 react-error-boundary 来代替吗?

对于那些讨厌重新造轮子的人,或者喜欢用库来解决已经解决的问题的人,有一个很好的库,它实现了一个灵活的 ErrorBoundary 组件,并且有一些类似于上述的有用的工具:github.com/bvaughn/rea…

是否使用它,只是个人喜好、编码风格和组件特殊性的问题。

今天就说到这里,希望从现在开始,当你的应用程序发生了报错,你都能够轻松而优雅地处理这些情况。

请记住:

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

- Book Lists -