3.8 错误边界与异常处理 本节摘要:React 应用有类错误处理机制,各自兜不同的场景:错误边界捕获渲染期错误并显示降级 UI,try/catch 兜事件处理与同步代码,Promise 处理管异步失败。本节先写一个可用的错误边界,再划清三类错误的边界,最后给出「分层兜底」的工程策略。 本节导读 阅读完本节,你应当能够: 编写并使用错误边界,理解 getDerivedStateFromError 与 componentDidCatch 的分工 说出错误边界捕获不到的三类错误 用 try/catch 处理事件处理与同步代码的错误 用 .
本节摘要:React 应用有类错误处理机制,各自兜不同的场景:错误边界捕获渲染期错误并显示降级 UI,try/catch 兜事件处理与同步代码,Promise 处理管异步失败。本节先写一个可用的错误边界,再划清三类错误的边界,最后给出「分层兜底」的工程策略。
阅读完本节,你应当能够:
React 组件在渲染时抛错,默认行为是什么?整个组件树从根开始卸载——白屏。一个无关紧要的角落组件(比如天气小组件)崩了,整个应用跟着瘫,这在生产环境是灾难。
为什么 React 不默认「局部兜住」?因为 React 无法替你决定「这里崩了该怎么显示」——是显示占位、显示错误信息、还是重试按钮?这个决定必须是开发者的。
所以 React 提供了错误边界:一个组件,捕获子组件树里的渲染错误,记录它,显示备用 UI。 它是「安全网」,但不是自动的——你得自己织这张网,并决定网的形状。
SOURCE 原文强调错误边界「在生产环境中充当安全网,防止整个应用因单个组件的错误而瘫痪」。这句话是本节的核心。
错误边界有两个硬性要求:
必须是类组件。 因为要用到生命周期方法捕获错误,函数组件没有这个能力(Hooks 里也没有对应的)。
实现两个方法。 getDerivedStateFromError 更新 state 显示降级 UI,componentDidCatch 记录错误。
import React from 'react'; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { // 更新 state,使下一次渲染显示降级 UI return { hasError: true, error }; } componentDidCatch(error, errorInfo) { // 记录错误:打日志或上报 console.error('捕获到错误:', error, errorInfo); } render() { if (this.state.hasError) { return ( <div className="error-fallback"> <h2>页面出错了</h2> <p>请刷新重试,如果问题持续请联系我们。</p> </div> ); } return this.props.children; } }
两个方法的分工:
把需要保护的组件包起来:
function App() { return ( <div> <h1>我的应用</h1> <ErrorBoundary> <WeatherWidget /> {/* 这个崩了,只影响它自己 */} </ErrorBoundary> <ErrorBoundary> <MainContent /> </ErrorBoundary> </div> ); }
WeatherWidget 渲染抛错时,错误边界捕获并显示降级 UI,MainContent 不受影响,应用主体正常运行。
一个错误边界只保护它的直接子树,所以「包多大」是个设计决策。两个极端都不好:
包得太小——每个组件包一个,代码里全是 ErrorBoundary 包裹,视觉噪音大,而且单个小组件崩了直接降级成「整块占位」,用户体验反而差。
包得太大——只在根包一个,一个角落组件崩了整页降级,等于放弃了「局部隔离」。
实用的中间态是按「功能区域」包:一个页面的导航、主内容、侧边栏、页脚各自一个边界。区域的粒度以「这个区域崩了,用户还能用其他区域吗」为判断标准——能,就该单独包。
还有一个技术细节值得知道:错误边界对「同一棵子树的反复崩溃」有天然保护——子组件抛错后,错误边界进入降级态;如果错误边界自身在渲染降级 UI 时又抛错,React 会向上一级查找错误边界。所以嵌套多层边界时,每一层都是下一层的兜底。这个「向上冒泡」的机制和事件冒泡类似,设计边界层级时可以借这个直觉。
错误边界不是万能的。它只捕获渲染期错误——子组件的渲染过程、生命周期方法、构造函数里抛出的错误。三类错误它抓不住:
事件处理程序中的错误:
<button onClick={() => { throw new Error('按钮点击出错'); }}>点击</button>
事件处理里的错误发生在渲染之外,错误边界管不着,要用 try/catch。
异步代码中的错误:setTimeout、Promise、请求回调里的错误。这些错误发生时渲染已经结束,错误边界无从捕获。
错误边界自身的错误:错误边界自己抛错,它管不了自己。
| 错误类型 | 谁兜底 |
|---|---|
| 渲染期/生命周期/构造函数 | 错误边界 |
| 事件处理 | try/catch |
| 异步(Promise/setTimeout) | .catch / async-await try/catch |
| 错误边界自身 | 无法兜底,代码要稳 |
try/catch 是 JavaScript 原生的错误处理机制,React 里主要用于事件处理和同步代码块:
function MyComponent() { const handleClick = () => { try { // 可能抛错的同步操作 const result = someFunctionThatMightThrow(); console.log(result); } catch (error) { console.error('操作失败:', error); // 给用户反馈 } }; return <button onClick={handleClick}>执行</button>; }
try/catch 的局限:它只能捕获同步错误。异步操作(Promise、setTimeout 回调)里的错误会逃逸出 try 块。异步错误要回到 Promise 的机制里去处理。
异步请求失败是前端最常发生的错误。两种处理姿势:
fetchData() .then(data => { console.log('数据:', data); }) .catch(error => { console.error('请求失败:', error); });
async function loadData() { try { const response = await fetch('/api/data'); const data = await response.json(); return data; } catch (error) { console.error('加载失败:', error); throw error; // 可以重新抛出,让上层处理 } } function MyComponent() { useEffect(() => { loadData().catch(error => { // 在组件层处理展示层的错误 console.log('组件层捕获:', error); }); }, []); return <div>...</div>; }
async/await 让异步代码读起来像同步,配合 try/catch 处理错误。注意 .catch 与「try/catch 包 await」的区别:前者适合链式 Promise,后者适合 async 函数。两种都行,选团队风格一致的——混合用最容易漏:一条链上有 .then 没 .catch,错误就静默流失了。
兜底网的最后一道:监听全局的 unhandledrejection,至少把漏网的错误记下来,别让它静默消失:
window.addEventListener('unhandledrejection', event => { console.error('未处理的 Promise rejection:', event.reason); // 上报到监控平台 event.preventDefault(); // 阻止默认的处理行为 });
但这不是偷懒的理由——每个 Promise 都显式处理 rejection,才是正解。全局监听只是保险,不是替代品。
错误处理有个容易被忽略的哲学问题:错误该「快速失败」还是「优雅失败」?
快速失败(Fail Fast):错误发生的第一时间抛出来、暴露出来,别吞掉。开发阶段尤其重要——一个被 catch 后「啥也不干」的错误,等于把问题埋进地里,上线后才在用户那边爆。
优雅失败(Fail Graceful):对用户展示友好的降级内容,别让用户看到报错堆栈。
两条原则看似矛盾,其实是分层的:面向开发者的错误要快、要显(日志、上报),面向用户的错误要慢、要雅(降级 UI、重试按钮)。错误边界里 getDerivedStateFromError 负责「对用户优雅」,componentDidCatch 负责「对开发者显形」,两个方法的分工正好对应这两条原则。
把本节知识串成一个「会崩但不吓人」的组件:
function ProfilePage() { return ( <ErrorBoundary> <UserInfo userId={123} /> </ErrorBoundary> ); } function UserInfo({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetchUser(userId) .then(setUser) .catch(error => { // 异步错误:展示给用户 setUser({ loadError: true }); }); }, [userId]); if (user?.loadError) { return <p>用户信息加载失败,请稍后重试。</p>; } if (!user) return <p>加载中...</p>; return <h1>{user.name}</h1>; }
UserInfo 内部的异步错误自己兜(显示加载失败提示);它渲染时的错误(比如 user.name 访问了一个 null)由外层 ErrorBoundary 兜(显示降级页)。两层各司其职,用户永远看到的是「合理的反馈」,而不是白屏或报错堆栈。
💡 关键直觉:三类错误的兜底思路可以想成「消防三层」:渲染层着火(渲染错误),错误边界是消防队,把火隔离在局部;交互层着火(事件错误),try/catch 是灭火器,当场灭掉;异步层着火(请求失败),Promise 的 catch 是自动喷淋,失败触发告警。三层各管各的,别指望一层兜全部。
⚠️ 常见坑:把错误边界当「万能兜底」,指望它接住所有错误。它只管渲染,事件和异步错误要用别的机制。另一个坑是错误边界只包裹最外层——如果包在最外层,一个子组件崩了会降级整页,等于没发挥「局部隔离」的价值。
一套实用的配置:
第一层:根级错误边界。 包在最外层,兜住「漏网的渲染错误」,显示整页降级 UI——总比白屏强。
第二层:关键区域错误边界。 导航栏、主内容区、侧边栏各自包裹,一个区域崩了不影响其他区域。第 3.5 节讲的「容器组件」在这里有另一个用途——它们也是天然的错误边界包裹单位。
第三层:异步与事件兜底。 请求统一走封装好的请求函数(内部 catch 所有错误),事件处理里可能抛错的逻辑包 try/catch。
监控与上报。 错误边界、全局 rejection 监听都把错误上报到监控平台。前端错误不可见,不记录等于没发生——上线后应用「看起来正常」但功能坏了一片的情况,全靠上报数据才能发现。
错误边界的降级 UI 不是装饰品。设计得好的降级 UI 告诉用户三件事:发生了什么、能不能恢复、怎么联系。一个「页面出错了,点此重试」的按钮,比重启浏览器友好得多。这也是为什么错误边界要你自定义——React 不知道你的产品该以什么姿态面对错误。
「错误边界能捕获事件处理器里的错误吗?」 不能,这是它最常见的误解。错误边界只捕获「渲染期间」的错误——组件渲染函数、生命周期方法、构造函数里抛出的错误。事件处理器(onClick 里抛错)不在渲染链路里,错误边界接不住。这类错误要用 try/catch 就地处理,或借助错误监控库(它会全局监听 error 和 unhandledrejection 事件)兜住漏网之鱼。分清楚「哪类错误走哪条路」,是错误处理的第一步。
「渲染降级 UI 时自己又抛错怎么办?」 React 会把这个错误抛给上一层错误边界——错误边界的错误是「向上冒泡」的。所以多层边界嵌套时,每一层都是下一层的兜底,直到最外层。这带来一个实践含义:最外层的边界要写得「足够简单」——降级 UI 尽量不用复杂组件、不依赖可能出错的数据,否则它自己也崩。一个「纯静态文本 + 刷新按钮」的兜底 UI,比一个「要渲染用户头像」的兜底 UI 稳得多。
「重试按钮怎么实现?」 重试的要点是「让边界回到未出错状态再重新渲染子树」。做法是把边界组件的 key 作为重试开关:父组件给边界换一个 key,React 会卸载重建整个子树(包括边界本身),子组件重新走一遍渲染流程,错误状态自然清零。如果边界内部自己管理重试,需要能「重置 hasError 状态」的方法。工程上更常见的是「换 key 重建」——代码少、语义清晰,不用给边界加内部状态。
「错误监控上报怎么做?」 上报的入口有三类:错误边界的 componentDidCatch、全局的 window error 事件、全局的 unhandledrejection 事件。实践上不是每个错误都上报——把「用户可见的降级」和「可恢复的临时失败」区分开,前者是产品 bug 应该上报,后者可能是网络抖动,报多了只会淹没真正的 bug。上报时带上下文(组件名、URL、state 快照、错误堆栈),排查才有线索。很多团队用现成的错误监控服务,核心逻辑不变:收集 → 聚合 → 去重 → 告警。
下一节看动画——错误处理让应用「不崩」,动画让应用「好看」。CSS 过渡、动画库、物理动画从轻到重的方案,以及它们各自的性能代价。动画不是可有可无的装饰,它直接影响用户对产品品质的感知。