如何处理React中的错误

148 阅读8分钟

背景

为什么在 React 中有一些错误捕获解决方案至关重要呢?答案很简单:从版本16开始,React 生命周期中抛出的错误,如果没有被捕获,将导致整个应用程序崩溃。在此之前,即使组件存在问题,错误也会被保留在屏幕上。现在,UI中某个不重要的部分或者你无法控制的外部库中发生了未捕获的错误,都有可能破坏整个页面并白屏。因此在编写逻辑代码的时候,捕获错误是我们要考虑的。

那么接下来会讨论如何在 React 中捕获和处理错误。使用 try/catchErrorBoundary 的用法、模式和注意事项,了解什么是可能的、什么是不可能的,以及如何使用 ErrorBoundary 捕获所有错误,包括来自异步操作和事件处理程序的错误。

如何在 JavaScript 捕获错误异常

当涉及到捕获JavaScript中的异常时,方法相当简单。我们可以使用 try/catch 语句,就能捕获这个错误并做一些事情来降低它的影响。

try {
  doSomethingWrong();
} catch (e) {
  //
}

当然,它同样适用于异步函数

try {
  await fetch('/wrong');
} catch (e) {
  //
}

针对于 Promise,我们有特别的 catch 方法。

fetch('/wrong').then((result) => {
  // do something
}).catch((e) => {
  //
})

这些概念大同小异,只是实现略有不同,因此在本文的其余部分,将使用try/catch 语法来处理所有错误。

React 中的简单 try/catch

当捕获到错误时,我们需要对其进行处理。那么,除了在某个地方记录日志之外,我们还能做什么呢?或者更准确地说:我们能为我们的用户做些什么?让他们面对一个白屏或一对错误的页面并不是很友好。最显而易见和直观的答案是在等待修复时呈现一些内容。在 catch 语句中,我们可以做任何我们想做的事情,包括设置状态。

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

  useEffect(() => {
    try {
      // something wrong
    } catch(e) {
      setHasError(true);
    }
  }, [])

  if (hasError) return <ErrorScreen />

    return <ComponentContent {...data} />
  }

当我们发送一个 fetch 请求如果失败了,就设置错误状态,则呈现一个友好的页面信息给用户。这种方法非常直接简单,适用于简单、可预测和功能性较弱的用例,例如捕获失败的 fetch 请求。但是,如果你想捕获组件中可能发生的所有错误,多多少少会有一些限制和挑战。

限制1:无法捕获 useEffect 的错误

如果我们用 try/catch 包装 useEffect,它就不会正常捕获:

try {
  useEffect(() => {
    throw new Error('wrong');
  }, [])
} catch(e) {
  // nothing happens
}

这是因为 useEffect 在渲染后异步调用,而 try/catch 只能捕获同步的错误。我们可以从下面的代码看出端倪:

function f1() {
  try {
    Promise.reject('wrong');
  } catch(e) {
    // nothing happens
  }
}

async function f2() {
  try {
    await Promise.reject('wrong');
  } catch(e) {
    // wrong, do something
  }
}

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

useEffect(() => {
  try {
    throw new Error('wrong');
  } catch(e) {
    // do something
  }
}, [])

这适用于使用 useEffect 或任何异步操作的任何 hooks。因此,痛苦的是需要将一个包含所有内容的 try/catch 拆分成多个块:每个 hook 都需要一个。

限制2:try/catch 无法捕获发生在子组件内部的任何错误

const Component = () => {
  let child;

  try {
    child = <Child />
      } catch(e) {
    // nothing happens
  }

  return child;
}

这是因为当我们写 <Child /> 时,实际上并没有渲染这个组件。这里所做的是创建一个组件元素,它只是一个包含必要信息(如组件类型和 props)的对象,然后会被 React 自身使用,触发这个组件的渲染。而这会在 try/catch 块成功执行后发生,与 PromiseuseEffect hook 的情况完全相同。

限制3:禁止在渲染期间设置状态

如果试图在 useEffect 和各种回调之外捕获错误(即在组件渲染期间),那么正确处理这些错误就不再那么简单了:在渲染期间不允许进行状态更新。例如,像这样的简单代码,如果发生错误,将会导致无限的重新渲染循环:

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

  try {
    doSomething();
  } catch(e) {
    setHasError(true);
  }
}

当然,我们可以只在此处返回错误显示组件而不是设置状态

const Component = () => {
  try {
    doSomething();
  } catch(e) {
    return <ErrorScreen />
      }
}

但是,正如想象的那样,这有点麻烦,并且会迫使我们以不同的方式处理同一组件中的错误:useEffect 和回调的状态,以及其他所有内容的直接返回。

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

useEffect(() => {
    try {
        // do something wrong
    } catch(e) {
        setHasError(true);
    }
})

try {
    // do something wrong
} catch(e) {
    return <ErrorScreen />;
}

if (hasError) return <ErrorScreen />

    return <ComponentContent {...data} />
}

小结

如果我们仅仅依赖 React 中的 try/catch,我们要么会错过大部分错误,要么会将每个组件变成一团无法理解的代码,很可能会自行导致错误。但也不是说没有办法,就是 React ErrorBoundary

React ErrorBoundary

为了缓解上述限制,React 提供了所谓的 ErrorBoundary : 一种特殊的 API,可以将常规组件转换为 try/catch 语句,仅适用于 React 声明式代码。如下所示

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

现在,如果在渲染期间这些组件中的任何一个或其子组件出现问题,错误将被捕获并处理。

但是 React 并没有给我们组件本身,它只是给了我们一个实现它的工具。最简单的实现是这样的:

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

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

  render() {
    if (this.state.hasError) {
      return <>Ops!!!</>
    }

    return this.props.children;
  }
}

我们创建一个常规的类组件(没有可用于 ErrorBoundaryhooks),并实现 getDerivedStateFromError 方法 - 这将使组件成为一个适当的错误边界组件。

我们也可以通过 componentDidCatch 做一些错误日志上报,我们再来优化一下,将整个组件抽象成通用组件。

class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

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

componentDidCatch(error: any, errorInfo: any) {
  log(error, errorInfo);
}

render() {
  if (this.state.hasError) {
    return this.props.fallback;
  }

  return this.props.children;
}
}

const Component = () => {
  return (
    <ErrorBoundary fallback={<>Ops!!!</>}>
    <ChildComponent1 />
    <ChildComponent2 />
    </ErrorBoundary>
  )
}

不过,它也不是万能的能够捕获所有的错误。

ErrorBoundary 组件的限制

ErrorBoundary 只能捕获在 React 生命周期中发生的错误。发生在 React 生命周期之外的事情,比如 Promise、使用 setTimeout 的异步代码、各种回调和事件处理函数,如果不显式处理,就会无效。

js

复制折叠

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

  const onClick = () => {
    // 这个错误将会消失
    throw new Error('Error');
  }

  useEffect(() => {
    // 假如请求失败,那么同样也会消失
    fetch('/wrong')
  }, [])

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

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

常见的建议是对这种错误使用常规的 try/catch。至少在这里,我们可以安全地使用状态(或多或少):事件处理程序的回调通常是我们设置状态的地方。因此,从技术上讲,我们可以将两种方法结合起来,像这样做:

// 这个组件的错误,除了catch之外,其余的将会被 ErrorBoundary 捕获
const Component = () => {
  const [hasError, setHasError] = useState(false);

  const onClick = () => {
    try {
      // 被 catch 捕获
      throw new Error('Error');
    } catch(e) {
      setHasError(true);
    }
  }

  if (hasError) return 'Wrong!';

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

const ComponentWithBoundary = () => {
  return (
    <ErrorBoundary fallback={"Ops!"}>
    <Component />
    </ErrorBoundary>
  )
}

但是,我们回到了起点:每个组件都需要维护其 “错误” 状态,更重要的是,要决定如何处理它。当然,我们可以不在组件级别处理这些错误,而是通过 propsContext 将它们传播到具有 ErrorBoundary 的父组件。这样,我们至少可以在一个地方拥有一个“回退”组件:

const Component = ({ onError }) => {
  const onClick = () => {
    try {
      throw new Error('Error');
    } catch(e) {
      // 这里只需要调用一个prop,而不是维护状态
      onError();
    }
  }

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

const ComponentWithBoundary = () => {
  const [hasError, setHasError] = useState();
  const fallback = 'Ops!';

  if (hasError) return fallback;

  return (
    <ErrorBoundary fallback={fallback}>
    <Component onError={() => setHasError(true)} />
    </ErrorBoundary>
  )
}

但这样会增加很多额外的代码!我们需要为渲染树中的每个子组件都这样做。更不用说我们现在基本上要维护两个错误状态:在父组件中和在ErrorBoundary 本身中。而且 ErrorBoundary 已经具备了自动传播错误的机制,我们这样做就是做了重复的工作。我们能否只是用 ErrorBoundary 来捕获异步代码和事件处理程序中的错误?

使用 ErrorBoundary 捕获异步错误

只需要一些骚操作,我们可以使用 ErrorBoundary 来捕获所有异步代码和事件处理程序中的错误!

来自 Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react · GitHub 的分享。

setState(() => {
  throw new Error('hi')
})

这里的技巧是首先使用 try/catch 捕获这些错误,然后在 catch 语句中触发普通的 React 执行重新渲染,然后将这些错误重新抛回到重新渲染生命周期中,这样 ErrorBoundary 就可以像处理其他错误一样捕获它们。

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

  const onClick = () => {
    try {
      throw new Error('Error')
    } catch (e) {
      setState(() => {
        throw e;
      })
    }
  }
}

最后一步是将这个 hack 抽象出来,这样我们就不必在每个组件中创建随机状态了。

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

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

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

  useEffect(() => {
    fetch('/wrong').then().catch((e) => {
      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 wrong
  }

  const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

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

对于那些不喜欢重复造轮子或者更喜欢使用已经解决问题的库的人,有一个非常好的库实现了一个灵活的 ErrorBoundary 组件 GitHub - bvaughn/react-error-boundary: Simple reusable React error boundary component

总结

如果你的应用程序出现了问题,你能够轻松优雅地处理这种情况。记住:

  • try/catch 块不能捕获像 useEffect这样的钩子内部发生的错误,也不能捕获子组件中的错误;
  • ErrorBoundary 可以捕获它们,但它不能捕获异步代码和事件处理函数中的错误;
  • 不过,你可以让 ErrorBoundary 捕获这些错误,只需要先用 try/catch捕获它们,然后重新将它们抛回到 React 生命周期中即可。