灏天阁

React错误处理 - 超全指南来了

· Yin灏

一、为何报错会导致渲染异常?

在 React 中,未捕获错误会导致DOM被卸载, 浏览器无法渲染。 为何 React 选择完全移除错误的DOM呢,我们可以看看官网中的这段话:

未捕获错误(Uncaught Errors)的新行为

这一改变具有重要意义,自 React 16 起,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载。

我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类应用而言,显示错误的金额也比不呈现任何内容更糟糕。

从我的开发经验看来,出现 bug 的原因主要有以下两点:

① 后端返回数据异常,前端代码未兼容完全;

② 前端程序逻辑错误;

如果项目上线后,页面无法正常打开,无法执行其他操作甚至一片空白,用户的体验感是非常不好的。

image.png

因此,我们有必要采取一些措施来预防和处理异常/错误,避免整个页面崩溃。

二、解决方案:预防和补救

image.png

(1) “防 bug 于未然”: 对后端数据进行预处理

正常情况下,前端小伙伴与后端提前沟通好状态码和数据结构,根据状态码做出不同响应即可。但是,当后端数据异常(如返回undefined, null)时,前端直接调用数组的某些方法或者对象的某些属性时就会报错。

image.png

  • 前端小伙伴谨记, “不要完全相信后端的数据”。

在使用后端数据前,最好先赋默认值。

举个 🌰:

// ① 解构时赋默认值 (注意:arr为null时,无法赋值成功)
// ② 使用逻辑或
const {arr = []} = data || {};

// ③ 使用可选运算符
const names = _arr?.map((item = {})=>(item?.name))).filter(Boolean);
// ...

在复杂的场景下,你甚至可以做更多——例如,先将后台数据进行预处理(与业务逻辑无关的数据处理),转为自身需要的结构和类型,让业务组件/逻辑更加纯粹地处理业务的同时,减少 bug 出现的概率。

(当然,有很多 bug 是前端代码自身的问题,在此不赘述预防措施了,大家可以自行思考 🤔。)

然而人无完人,bug 总是防不胜防,那么如何减小 bug 的影响呢?

(2) “亡羊补牢”之使用常规手段捕获异常;

对于 javascript 而言,执行的事件主要有以下五种:同步方法、异步方法、资源加载、Promise、async…await,事件执行失败意味着程序出现 bug。

幸运的是,这些异常均可通过框架(react/vue/angular 等)之外的常规手段捕获到。

方法汇总

异常类型 同步方法 异步方法 资源加载 Promise async…await
try…catch
window.onerror
addEventListener(’error')
addEventListener(“unhandledrejection”)
  1. try…catch
  • try...catch 语句标记要尝试的语句块,并指定一个出现异常时抛出的响应。

举些 🌰:

① 处理同步错误;

  • MDN Web Docs 中的例子:
try {
  nonExistentFunction();
} catch (error) {
  console.error(error);
  // Expected output: ReferenceError: nonExistentFunction is not defined
  // (Note: the exact output may be browser-dependent)
}

② 处理异步错误

通常,若 try 中的异步模块产生了错误,catch 是捕获不到的。但是我们可以把 try-catch 放到异步代码中。

  • try-catch 放到 setTimeout 内部
setTimeout(() => {
  try {
    throw new Error("error in setTimeout");
  } catch (err) {
    console.error("catch error", err);
  }
}, 200);
  • try-catch 放到 then内部
Promise.resolve().then(() => {
  try {
    throw new Error("error in Promise.then");
  } catch (err) {
    console.error("catch error", err);
  }
});

// 正常情况下,使用Promse自带的catch捕获异常即可
Promise.resolve()
  .then(() => {
    throw new Error("error in Promise.then");
  })
  .catch((err) => {
    console.error("Promise.catch error", err);
  });

③ 处理 async-await 的异常

  • try 放在 async 之后
const request = async () => {
  try {
    const { code, data } = await somethingThatReturnsAPromise();
  } catch (err) {
    console.error("request error", err);
  }
};
  1. window.onerror

当 JavaScript 运行时错误(包括语法错误)发生时,会执行 window.onerror 方法。

function onError(msg, url, lineNo, columnNo, error) {
  /*
   * message:错误信息(字符串)。可用于HTML onerror=""处理程序中的event。
   * source:发生错误的脚本URL(字符串)
   * lineno:发生错误的行号(数字)
   * colno:发生错误的列号(数字)
   * error:Error对象
   */

  // 没有返回值或者返回值为false的时候,异常信息会通过 console.error 的方式在控制台打印
  return false;
}
window.onerror = onError;
  1. addEventListener(’error')

当资源加载失败或无法使用时,会在Window对象触发error事件。例如:script 执行时报错。

window.addEventListener(
  "error",
  (event) => {
    console.log("捕获到异常", event);
    return false;
  },
  true
); // 捕获阶段

image.png

  1. addEventListener(‘unhandledrejection’)

当  Promise  被 reject 且没有 reject 处理器的时候,会触发unhandledrejection事件。

// 捕获未处理的 promise 异常
window.addEventListener("unhandledrejection", (event) => {
  console.warn(`UNHANDLED PROMISE REJECTION: ${event.reason}`);
});

小结

做笔记.gif

try...catch 最为灵活,通过使用一些小技巧,可以捕获绝大部分异常,捕获到错误后可以在 catch 中进行更多处理;

addEventListener('error')  事件监听 js 运行时错误事件,会比 window.onerror 先触发,与 onerror 的功能大体类似,但可以全局捕获资源加载异常的错误;

addEventListener('error') 结合 addEventListener('unhandledrejection'),几乎可以捕获程序中的所有错误,但主要只是提供了错误堆栈信息;

④ 当使用以上四种常规手段捕获到错误后,我们可以做许多事情。例如,在开发环境中,可将错误信息打印在浏览器控制台上、可抛出异常通知下游, 方便开发调试;在生产环境中,可上报错误日志进行错误监控,而在修复 bug 的过程中,我们可以做更多——结合错误边界(Error Boundary)为用户渲染一些有用的内容。

(3) “亡羊补牢”之使用 Error Boundary

image.png

上文提到,try…catch 特别好用,但是它无法直接捕获到 react 组件中的所有可能发生的错误,如子组件中的错误。

此时,Error Boundary 就必须得闪亮登场了~

  1. Error Boundary 概念

错误边界是一种 React 组件,这种组件可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UI,而并不会渲染那些发生崩溃的子组件树。

错误边界最基本的实现:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(error) {    // 更新 state 使下一次渲染能够显示降级后的 UI    return { hasError: true };  }
  componentDidCatch(error, errorInfo) {    // 你同样可以将错误日志上报给服务器    logErrorToMyService(error, errorInfo);  }
  render() {
    if (this.state.hasError) {      // 你可以自定义降级后的 UI 并渲染      return <h1>Something went wrong.</h1>;    }
    return this.props.children;
  }
}

使用

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

tips, 日常开发中,我们需要为不同粒度的组件运用错误边界组件,尽量减小 bug 的影响范围。

  1. Error Boundary 可用场景和不可用场景

① 错误边界起作用的场景:

  • 发生在整个子组件树的渲染期间、生命周期方法以及构造函数中的错误

② 错误边界不起作用的场景:

  • 组件外的报错、异步代码的报错、事件函数中的报错、错误边界自身抛出的错误、错误边界的父组件报错、 函数组件被卸载,触发 useEffect 的销毁。
  1. 怎么让 errorBoundary 处理在生命周期之外的错误?

机智的小伙伴会发现,错误边界不能处理的许多错误,比如 promise、异步代码、各种回调和事件处理程序中的错误,可以使用常规 try…catch 来处理。

因此,我们先用 try…catch 捕获这些错误,然后在 catch 语句内触发正常的 React 重新渲染,然后 将这些错误重新抛出到重新渲染生命周期中

  • ① 定义异步错误抛出工具:
// 定义
import { useState } from "react";

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

  return (e: any) => {
    setState(() => {
      throw e;
    });
  };
};

export default useThrowAsyncError;

// 使用示例
const Component = () => {
  const throwAsyncError = useThrowAsyncError();

  useEffect(() => {
    fetch("/bla")
      .then()
      .catch((e) => {
        // throw async error here!
        throwAsyncError(e);
      });
  });
};
  • ② 为回调函数做额外处理:
// 定义
import { useState } from "react";

const useCallbackWithErrorHandler = (
  callback: (...args: any[]) => any,
  useErrorBoundary: boolean = false
) => {
  const [, setState] = useState();

  return async (...args: any[]) => {
    try {
      await callback(...args);
    } catch (e) {
      useErrorBoundary &&
        setState(() => {
          throw e;
        });
    }
  };
};

export default useCallbackWithErrorHandler;

// 使用示例
const Component = () => {
  const onClick = () => {
    // do something dangerous here
  };
  const onClickWithErrorHandler = useCallbackWithErrorHandler(onClick);

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

三、总结

希望看到这里的小伙伴,可以从容而优雅地处理程序中出现的 bug。

本文就错误处理做了详细的解析,主要内容如下:

必要性:因为未被错误边界捕获的异常会导致整个 react 组件树被卸载,微不足道的错误都有可能导致整个页面受到破坏,并为用户渲染出一个白屏,所以预防并处理异常是必要的。

预防措施:尽量减少 bug 出现的概率,除了减少前端自身代码的问题,最好对后端数据进行预处理再使用;

事后补救方案 1:使用常规手段(如try…catch、window.onerror、addEventListener(’error’)、addEventListener(‘unhandledrejection’))捕获异常并做进一步处理,如错误上报、更新状态渲染降级 UI 等;

事后补救方案 2:结合Error Boundary为用户渲染有用的信息,避免白屏,提升用户体验感。

四、参考与感谢

thanks.gif

  1. 见鬼,为何我的 ErrorBoundary(错误边界)不起作用

  2. 译文:React 错误处理:最佳实践

    原文:How to handle errors in React: full guide

  3. 喂,你的页面白了!阿里解决「前端白屏」的方案

  4. React,优雅的捕获异常

  5. 前端中 try-catch 捕获不到哪些异常和错误

  6. window.onerror 和 window.addEventListener(’error’)的区别

  7. Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react

- Book Lists -