首页 / 文章

加载、等待、失败:懒加载、悬念模式与错误边界如何应用

了解 React.lazy、Suspense 和错误边界如何分割代码、显示备用内容并防止崩溃,以及为获得更好的用户体验应将这些边界放置在哪里。

发布者:REACTAPP.TOP阅读约 9 分钟1968 词

来源: 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配合使用。
  • 应在路由层级以及那些体积较大、使用频率较低的功能处进行拆分,而非在小型组件上操作。
  • Error Boundary的放置位置属于用户体验层面的决策:应将这两种边界都设置在有意义且独立的区域周围。