加载、等待、失败:懒加载、悬念模式与错误边界如何应用
了解 React.lazy、Suspense 和错误边界如何分割代码、显示备用内容并防止崩溃,以及为获得更好的用户体验应将这些边界放置在哪里。
来源: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide
React 页面可能存在三种棘手的状态:代码尚未下载、内容尚未准备就绪,或者渲染过程中出现了错误。React.lazy 用于延迟加载代码,Suspense 则在内容加载期间显示占位符,而错误边界则会在子树出错时用备用内容替代它。一旦理解了它们之间的协作方式,就能合理地拆分代码包,并设置适当的边界以确保页面的其他部分仍能正常使用。
使用 React.lazy 延迟加载代码
懒加载意味着组件的代码只有在 React 首次需要渲染它时才会被获取,而非作为初始打包内容的一部分。这种差异在导入语句中显而易见:静态的 import Dashboard from "./Dashboard" 会立即加载该模块,而将动态导入包裹在 lazy() 中则能让打包工具生成独立的代码块。(在示例中,第二个导入仅用于对比。)
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
Instead of:
import Dashboard from "./Dashboard";
懒加载带来的好处
想象一个包含多个独立区域的应用程序:
Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment
刚进入首页的用户暂时还用不到这些功能:
Analytics
Admin
Payment
即便仍将其包含在内,也会增加初始下载量。而采用懒加载后,其他代码块会在需要时再被加载:
Initial Load
↓
Home + common code
↓
User opens Dashboard
↓
Download Dashboard chunk
↓
Render Dashboard
懒加载的实现原理
lazy() 接受一个返回 Promise 的函数,这个函数几乎总是通过动态的 import() 获取资源。目标模块必须有一个作为组件的默认导出项。其执行流程如下(该图为概念性示意图,并非实际代码):
const Dashboard = lazy(() => import("./Dashboard"));
Conceptually:
lazy()
↓
dynamic import()
↓
JavaScript chunk requested
↓
Promise pending
↓
Component becomes available
↓
React renders component
接下来有两条实用规则:首先,应将在模块顶层声明懒加载组件,绝不能放在其他组件内部,否则每次渲染都会创建新的组件类型并重置其状态;其次,由于数据块是异步加载的,因此必须有人来决定在此期间用户应看到什么内容,这就是 Suspense 的职责。
使用 Suspense 显示占位符
当任何子组件处于挂起状态、无法立即渲染时,Suspense 会渲染一个占位内容。只需将懒加载组件包裹起来,并传入占位元素即可:
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<p>Loading dashboard...</p>}>
<Dashboard />
</Suspense>
);
}
在数据块下载期间,React会找到最近的边界并渲染其备用内容:
Dashboard
↓
Not ready
↓
Suspense
↓
"Loading dashboard..."
当 Promise 解析后,React会再次尝试渲染,此时真实内容将替换掉备用内容:
Dashboard loaded
↓
React retries rendering
↓
Dashboard displayed
Suspense本身并不负责处理加载过程;真正触发加载操作的是lazy()(或支持 Suspense 的数据库)。Suspense仅用于标记可能出现等待状态的位置以及该状态的显示方式。
结合使用 lazy 和 Suspense
大多数实际代码都会将两者搭配使用,通常会提供比纯文本更丰富的备用内容:
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}
首次访问时的完整生命周期,同样以图表形式展示:
User opens Dashboard
↓
React tries to render Dashboard
↓
Dashboard chunk isn't available
↓
Dynamic import starts
↓
Suspense fallback shown
↓
Dashboard chunk loads
↓
React retries rendering
↓
Dashboard displayed
使用骨架屏而非旋转加载图标
纯文本备用内容虽然可行,但无法向用户传达任何关于即将显示内容的线索:
<Suspense fallback={<p>Loading...</p>}>
使用模拟最终布局的骨架组件通常是更好的选择:
<Suspense fallback={<DashboardSkeleton />}>
它能预留出大致合适的空间,减少布局偏移,并让等待时间显得更短:
┌─────────────────────────────┐
│ Dashboard │
├─────────────────────────────┤
│ ░░░░░░░ ░░░░░░░ │
│ ░░░░░░░ ░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────┘
嵌套 Suspense 边界
为整个应用设置一个边界往往并不符合需求。边界可以相互嵌套,每个处于挂起状态的组件都会被其上方的最近边界捕获:
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
不必让整个页面都变成单一的加载提示,
Entire page → Loading...
每个部分都可以独立显示:
Header ✅
Products Loading...
[Product Skeleton]
Recommendations Loading...
[Recommendation Skeleton]
使用错误边界处理故障
等待是一种情况,而出现故障则是另一种。假设某个组件在渲染过程中抛出了异常:
function Dashboard() {
throw new Error("Dashboard crashed!");
}
未捕获的渲染错误会导致整个 React 根组件被卸载,从而显示空白页面。错误边界是一种能够拦截其子组件抛出的错误,并展示备用 UI 的组件:
Dashboard
↓
Rendering Error
↓
Error Boundary
↓
Fallback UI
使用错误边界的方式与包裹其他组件类似,用户会看到“出错了”的提示而非空白屏幕:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
编写错误边界
错误边界仍然是类组件;React 本身没有对应的 Hook。有两个关键方法:静态的 getDerivedStateFromError 会在渲染过程中执行,返回用于切换到备用状态的值;componentDidCatch 则会在之后执行,可获取错误信息以及 info.componentStack,可用于记录日志或发送报告。
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, info) {
console.error(error);
console.error(info.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
其使用方式与前面的示例完全相同:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
这个最简版本没有重置机制;需要通过“再试一次”按钮将hasError的值重新设为false。
React如何定位错误边界
以某个profile组件周围的边界为例,
<ErrorBoundary>
<Profile />
</ErrorBoundary>
该组件会抛出错误:
function Profile() {
throw new Error("Profile failed");
}
React会沿着组件树向下渲染,直到出现错误:
ErrorBoundary
↓
Profile
↓
💥 Error
随后它会向上查找最近的错误边界:
Profile
↑
ErrorBoundary
该错误边界会进入错误状态,
{
hasError: true
}
并渲染备用内容。错误边界只能捕获其子组件的错误,无法捕获自身的错误;备用内容内部的错误会向上传播到更上层的边界。
错误边界能捕获的错误
错误边界用于处理 React 渲染过程中、生命周期方法中以及子组件构造函数中抛出的错误。一个典型的例子是在渲染时尝试读取 undefined 的属性:
function Profile() {
const user = undefined;
return <h1>{user.name}</h1>;
}
它们还能捕获被拒绝的懒加载导入。例如,如果在部署后旧文件已被删除导致数据块无法下载,错误会在最近的错误边界处出现,而不会在 Suspense 中显示。
错误边界无法捕获的错误
面试中常被问到:错误边界无法检测到事件处理函数中的错误,因为这些函数是在渲染之外运行的。
function Button() {
const handleClick = () => {
throw new Error("Click failed");
};
return (
<button onClick={handleClick}>
Click
</button>
);
}
应在错误发生的地方使用常规的错误处理机制来处理它们:
try {
// operation
} catch (error) {
// handle error
}
对于定时器或承诺链等异步回调,在渲染完成后抛出的错误也适用同样的处理方式:
setTimeout(() => {
throw new Error("Async error");
}, 1000);
如果出现此类错误需要显示边界处的默认内容,应捕获该错误并将其存储在状态中,然后在下一次渲染时再抛出它。
将三者结合使用
一种生产环境中的常见做法是将 Suspense 包装在错误边界内:
<ErrorBoundary>
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
通过延迟定义控制面板,
const Dashboard = lazy(() => import("./Dashboard"));
这样的组合流程能够覆盖所有可能的情况。第一种分支可理解为“代码缺失?”:如果是这种情况,React会开始导入并显示骨架屏;之后渲染要么成功完成,要么进入错误边界。
Dashboard
│
▼
Need the code?
│
┌────────┴────────┐
│ │
YES NO
│ │
Render normally dynamic import()
│
Pending
│
▼
Suspense
│
▼
Skeleton UI
│
Loaded
│
▼
Render again
│
┌───────┴───────┐
│ │
Success Error
│ │
▼ ▼
Dashboard Error Boundary
│
▼
Error UI
按路由拆分
由于每个页面都是一个独立的界面,因此路由是最自然的拆分点。应延迟声明每个页面:
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
然后将路由器包裹在错误边界中,这样在导航到未加载的页面时就会显示加载器:
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
明确哪些内容不应拆分
对小型组件使用懒加载反而适得其反:
const Button = lazy(() => import("./Button"));
每个懒加载的组件都会产生一次请求并进入加载状态,而按钮永远无法弥补这一成本。适合采用懒加载的场景包括大型控制面板、分析模块和管理员区域、富文本编辑器、地图、复杂的图表或第三方库、支付流程以及很少被访问的页面路径。应当拆分那些成本高昂或使用频率低的代码,而非所有内容。
刻意设置边界
悬念位置的设定决定了哪些内容会隐藏
如果边界设置在层级较高的位置,那么任何加载缓慢的组件都会用其备用内容替换掉大片区域:
<Suspense fallback={<FullPageLoader />}>
<EntireApplication />
</Suspense>
将边界设置在加载缓慢的部分周围,可以确保在加载期间页头和页脚依然可见且可用:
<Header />
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
<Footer />
错误边界的设置决定了哪些内容能保留
同样的逻辑也适用于错误处理。根级别的单个边界会将任何错误都转换为全页错误界面:
<ErrorBoundary>
<EntireApplication />
</ErrorBoundary>
将独立的部分封装起来可以将故障限制在特定范围内:
<App>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary>
<Recommendations />
</ErrorBoundary>
<Footer />
</App>
如果推荐功能出现故障,其他功能仍可正常运行:
Header ✅
Dashboard ✅
Recommendations ❌
Footer ✅
根级别的边界作为最后手段,对更细粒度的边界起到辅助作用。
一张图看懂思维模型
Lazy Loading
│
▼
Code needed?
│
▼
Suspense
│
┌──────┴──────┐
│ │
Loading Ready
│ │
Fallback ▼
Render
│
┌─────┴─────┐
│ │
Success Error
│ │
▼ ▼
UI Error Boundary
│
▼
Error UI
关键要点
- 结合动态
import()使用的React.lazy可将代码分割成多个块,在首次渲染时加载;该模块需要有一个默认导出。 Suspense本身并不加载任何内容,它只是定义了在子组件挂起时显示的备用内容。- 错误边界能够捕获渲染、生命周期及构造函数相关的错误,包括失败的懒加载导入,但无法处理事件处理器或后续的异步错误。
Suspense无法处理错误,因此需与Error Boundary配合使用。