React错误处理 - 超全指南来了
一、为何报错会导致渲染异常?
在 React 中,未捕获错误会导致DOM被卸载, 浏览器无法渲染。 为何 React 选择完全移除错误的DOM呢,我们可以看看官网中的这段话:
未捕获错误(Uncaught Errors)的新行为
这一改变具有重要意义,自 React 16 起,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载。
我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类应用而言,显示错误的金额也比不呈现任何内容更糟糕。
从我的开发经验看来,出现 bug 的原因主要有以下两点:
① 后端返回数据异常,前端代码未兼容完全;
② 前端程序逻辑错误;
如果项目上线后,页面无法正常打开,无法执行其他操作甚至一片空白,用户的体验感是非常不好的。
因此,我们有必要采取一些措施来预防和处理异常/错误,避免整个页面崩溃。
二、解决方案:预防和补救
(1) “防 bug 于未然”: 对后端数据进行预处理
正常情况下,前端小伙伴与后端提前沟通好状态码和数据结构,根据状态码做出不同响应即可。但是,当后端数据异常(如返回undefined, null)时,前端直接调用数组的某些方法或者对象的某些属性时就会报错。
- 前端小伙伴谨记, “不要完全相信后端的数据”。
在使用后端数据前,最好先赋默认值。
举个 🌰:
// ① 解构时赋默认值 (注意: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”) | √ | √ |
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);
}
};
- 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;
- addEventListener(’error')
当资源加载失败或无法使用时,会在Window
对象触发error
事件。例如:script 执行时报错。
window.addEventListener(
"error",
(event) => {
console.log("捕获到异常", event);
return false;
},
true
); // 捕获阶段
- addEventListener(‘unhandledrejection’)
当 Promise
被 reject 且没有 reject 处理器的时候,会触发unhandledrejection
事件。
// 捕获未处理的 promise 异常
window.addEventListener("unhandledrejection", (event) => {
console.warn(`UNHANDLED PROMISE REJECTION: ${event.reason}`);
});
小结
① try...catch
最为灵活,通过使用一些小技巧,可以捕获绝大部分异常,捕获到错误后可以在 catch 中进行更多处理;
② addEventListener('error')
事件监听 js 运行时错误事件,会比 window.onerror 先触发,与 onerror 的功能大体类似,但可以全局捕获资源加载异常的错误;
③ addEventListener('error')
结合 addEventListener('unhandledrejection')
,几乎可以捕获程序中的所有错误,但主要只是提供了错误堆栈信息;
④ 当使用以上四种常规手段捕获到错误后,我们可以做许多事情。例如,在开发环境中,可将错误信息打印在浏览器控制台上、可抛出异常通知下游, 方便开发调试;在生产环境中,可上报错误日志进行错误监控,而在修复 bug 的过程中,我们可以做更多——结合错误边界(Error Boundary)为用户渲染一些有用的内容。
(3) “亡羊补牢”之使用 Error Boundary
上文提到,try…catch 特别好用,但是它无法直接捕获到 react 组件中的所有可能发生的错误,如子组件中的错误。
此时,Error Boundary 就必须得闪亮登场了~
- 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 的影响范围。
- Error Boundary 可用场景和不可用场景
① 错误边界起作用的场景:
- 发生在整个子组件树的渲染期间、生命周期方法以及构造函数中的错误
② 错误边界不起作用的场景:
- 组件外的报错、异步代码的报错、事件函数中的报错、错误边界自身抛出的错误、错误边界的父组件报错、 函数组件被卸载,触发 useEffect 的销毁。
- 怎么让 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为用户渲染有用的信息,避免白屏,提升用户体验感。