中文

精通 React Suspense 数据获取。学习如何声明式地管理加载状态,通过过渡(transitions)改善用户体验,并使用错误边界(Error Boundaries)处理错误。

React Suspense 边界:深入剖析声明式加载状态管理

在现代 Web 开发的世界里,创造无缝且响应迅速的用户体验至关重要。开发者面临的最持久的挑战之一就是管理加载状态。从获取用户个人资料数据到加载应用程序的新部分,等待的时刻都至关重要。从历史上看,这涉及到一堆纠缠不清的布尔标志,如 isLoadingisFetchinghasError,散布在我们的组件中。这种命令式的方法使我们的代码变得混乱,逻辑复杂化,并且是竞态条件等错误的常见来源。

React Suspense 应运而生。它最初是为配合 React.lazy() 进行代码分割而引入的,但随着 React 18 的发布,其功能已大大扩展,成为处理异步操作(尤其是数据获取)的强大的一等公民机制。Suspense 允许我们以声明式的方式管理加载状态,从根本上改变了我们编写和思考组件的方式。我们的组件不再需要问“我正在加载吗?”,而是可以简单地说:“我需要这些数据来渲染。在我等待的时候,请显示这个后备 UI。”

这份全面的指南将带您踏上一段旅程,从传统的状态管理方法过渡到 React Suspense 的声明式范 paradigm。我们将探讨什么是 Suspense 边界,它们如何用于代码分割和数据获取,以及如何编排复杂的加载 UI,从而取悦用户而非让他们感到沮丧。

旧方式:手动管理加载状态的繁琐工作

在我们能够完全欣赏 Suspense 的优雅之前,了解它所解决的问题至关重要。让我们看一个使用 useEffectuseState 钩子获取数据的典型组件。

想象一个需要获取并显示用户数据的组件:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 为新的 userId 重置状态
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('网络响应不正常');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // 当 userId 改变时重新获取

  if (isLoading) {
    return <p>正在加载个人资料...</p>;
  }

  if (error) {
    return <p>错误: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>邮箱: {user.email}</p>
    </div>
  );
}

这种模式是可行的,但它有几个缺点:

React Suspense 登场:一次范式转移

Suspense 将这种模式彻底颠覆。组件不再是内部管理加载状态,而是直接向 React 传达它对异步操作的依赖。如果它需要的数据尚不可用,组件就会“挂起”渲染。

当一个组件挂起时,React 会沿着组件树向上寻找最近的 Suspense 边界。Suspense 边界是您使用 <Suspense> 在树中定义的组件。这个边界随后会渲染一个后备 UI(如加载指示器或骨架屏),直到其内部的所有组件都解决了它们的数据依赖关系。

核心思想是将数据依赖与其需要的组件放在一起,同时将加载 UI 集中在组件树中更高层次的位置。这清理了组件逻辑,并让您能够强有力地控制用户的加载体验。

组件如何“挂起”?

Suspense 背后的魔力在于一种起初可能看起来不寻常的模式:抛出一个 Promise。一个支持 Suspense 的数据源的工作方式如下:

  1. 当一个组件请求数据时,数据源会检查它是否缓存了该数据。
  2. 如果数据可用,它会同步返回数据。
  3. 如果数据不可用(即,它正在被获取中),数据源会抛出代表正在进行的获取请求的 Promise

React 会捕获这个被抛出的 Promise。它不会让您的应用崩溃。相反,它将其解释为一个信号:“这个组件还没准备好渲染。暂停它,并向上查找一个 Suspense 边界来显示后备 UI。”一旦 Promise 被解析,React 将重试渲染该组件,该组件现在将接收到其数据并成功渲染。

<Suspense> 边界:你的加载 UI 声明器

<Suspense> 组件是这种模式的核心。它使用起来非常简单,只接受一个必需的属性:fallback


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>我的应用程序</h1>
      <Suspense fallback={<p>正在加载内容...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

在这个例子中,如果 SomeComponentThatFetchesData 挂起,用户将看到“正在加载内容...”的消息,直到数据准备好。后备内容可以是任何有效的 React 节点,从一个简单的字符串到一个复杂的骨架屏组件。

经典用例:使用 React.lazy() 进行代码分割

Suspense 最成熟的用途是代码分割。它允许您延迟加载组件的 JavaScript,直到它真正被需要时为止。


import React, { Suspense, lazy } from 'react';

// 这个组件的代码不会包含在初始的包中。
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>一些立即加载的内容</h2>
      <Suspense fallback={<div>正在加载组件...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

在这里,React 只有在第一次尝试渲染 HeavyComponent 时才会去获取它的 JavaScript。在获取和解析的过程中,会显示 Suspense 的后备 UI。这是改善初始页面加载时间的一项强大技术。

现代前沿:使用 Suspense 进行数据获取

虽然 React 提供了 Suspense 机制,但它并没有提供特定的数据获取客户端。要使用 Suspense 进行数据获取,您需要一个与之集成的数据源(即,一个在数据待处理时会抛出 Promise 的数据源)。

像 Relay 和 Next.js 这样的框架对 Suspense 有内置的一等公民支持。流行的数据获取库如 TanStack Query(前身为 React Query)和 SWR 也提供了实验性或完整的支持。

为了理解这个概念,让我们围绕 fetch API 创建一个非常简单的、概念性的包装器,使其与 Suspense 兼容。注意:这是一个用于教育目的的简化示例,并非生产就绪。它缺乏适当的缓存和复杂的错误处理机制。


// data-fetcher.js
// 一个用于存储结果的简单缓存
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // 这就是魔法所在!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`请求失败,状态码 ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

这个包装器为每个 URL 维护一个简单的状态。当 fetchData 被调用时,它会检查状态。如果状态是 pending,它就抛出 promise。如果成功,它就返回数据。现在,让我们用这个来重写我们的 UserProfile 组件。


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// 实际使用数据的组件
function ProfileDetails({ userId }) {
  // 尝试读取数据。如果数据还没准备好,这里会挂起。
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>邮箱: {user.email}</p>
    </div>
  );
}

// 定义加载状态 UI 的父组件
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>正在加载个人资料...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

看看这其中的区别!ProfileDetails 组件干净利落,只专注于渲染数据。它没有 isLoadingerror 状态。它只是请求它需要的数据。显示加载指示器的责任被移交给了父组件 UserProfile,它声明式地规定了在等待时显示什么。

编排复杂的加载状态

当您构建具有多个异步依赖的复杂 UI 时,Suspense 的真正威力才会显现出来。

使用嵌套 Suspense 边界实现分阶段 UI

您可以嵌套 Suspense 边界来创造更精细的加载体验。想象一个仪表盘页面,有侧边栏、主内容区和最近活动列表。每一个部分可能都需要自己的数据获取。


function DashboardPage() {
  return (
    <div>
      <h1>仪表盘</h1>
      <div className="layout">
        <Suspense fallback={<p>正在加载导航...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

有了这种结构:

这使您能够尽快向用户展示有用的内容,极大地提高了感知性能。

避免 UI“爆米花效应”

有时,分阶段加载的方式可能会导致一种突兀的效果,即多个加载指示器快速连续地出现和消失,这种效应通常被称为“爆米花效应 (popcorning)”。为了解决这个问题,您可以将 Suspense 边界移到组件树的更高层级。


function DashboardPage() {
  return (
    <div>
      <h1>仪表盘</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

在这个版本中,会显示一个单一的 DashboardSkeleton,直到所有子组件(SidebarMainContentActivityFeed)的数据都准备就绪。然后整个仪表盘会一次性出现。在嵌套边界和单个高层边界之间进行选择是一个用户体验设计决策,而 Suspense 使其实现变得轻而易举。

使用错误边界处理错误

Suspense 处理了 promise 的待处理 (pending) 状态,但拒绝 (rejected) 状态呢?如果一个组件抛出的 promise 被拒绝(例如,网络错误),它将被视为 React 中的任何其他渲染错误。

解决方案是使用错误边界 (Error Boundaries)。错误边界是一个类组件,它定义了一个特殊的生命周期方法 componentDidCatch() 或一个静态方法 getDerivedStateFromError()。它可以捕获其子组件树中任何地方的 JavaScript 错误,记录这些错误,并显示一个后备 UI。

这是一个简单的错误边界组件:


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: error };
  }

  componentDidCatch(error, errorInfo) {
    // 您也可以将错误记录到错误报告服务
    console.error("捕获到一个错误:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 您可以渲染任何自定义的后备 UI
      return <h1>出错了,请重试。</h1>;
    }

    return this.props.children; 
  }
}

然后,您可以将错误边界与 Suspense 结合起来,创建一个能够处理所有三种状态的健壮系统:待处理、成功和错误。


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>用户信息</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>加载中...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

通过这种模式,如果 UserProfile 内部的数据获取成功,则显示个人资料。如果它处于待处理状态,则显示 Suspense 的后备 UI。如果失败,则显示错误边界的后备 UI。这种逻辑是声明式的、可组合的,并且易于理解。

过渡(Transitions):非阻塞式 UI 更新的关键

这个谜题还有最后一块。考虑一个触发新数据获取的用户交互,比如点击“下一个”按钮查看另一个用户个人资料。使用上面的设置,在按钮被点击并且 userId 属性改变的那一刻,UserProfile 组件将再次挂起。这意味着当前可见的个人资料将消失,并被加载后备 UI 所取代。这可能会让人感觉突兀和干扰。

这就是过渡 (transitions) 发挥作用的地方。过渡是 React 18 中的一个新功能,它允许您将某些状态更新标记为非紧急。当一个状态更新被包裹在过渡中时,React 会在后台准备新内容的同时,继续显示旧的 UI(过时的内容)。只有当新内容准备好显示时,它才会提交 UI 更新。

为此,主要的 API 是 useTransition 钩子。


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        下一个用户
      </button>

      {isPending && <span> 正在加载新个人资料...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>正在加载初始个人资料...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

现在会发生什么:

  1. userId: 1 的初始个人资料加载,显示 Suspense 后备 UI。
  2. 用户点击“下一个用户”。
  3. setUserId 调用被包裹在 startTransition 中。
  4. React 开始在内存中用新的 userId(值为 2)渲染 UserProfile。这导致它挂起。
  5. 关键是,React 不会显示 Suspense 后备 UI,而是将旧的 UI(用户 1 的个人资料)保留在屏幕上。
  6. useTransition 返回的 isPending 布尔值变为 true,允许我们在不卸载旧内容的情况下显示一个不显眼的、内联的加载指示器。
  7. 一旦用户 2 的数据被获取并且 UserProfile 可以成功渲染,React 就会提交更新,新的个人资料便无缝地出现。

过渡提供了最后一层控制,使您能够构建复杂且用户友好的加载体验,而不会感到突兀。

最佳实践与全局考量

结论

React Suspense 不仅仅是一个新功能;它是我们处理 React 应用程序中异步性方式的根本性演进。通过摆脱手动的、命令式的加载标志,并拥抱声明式模型,我们可以编写更清晰、更健壮、更易于组合的组件。

通过结合用于待处理状态的 <Suspense>、用于失败状态的错误边界,以及用于无缝更新的 useTransition,您就拥有了一套完整而强大的工具集。您可以用最少、可预测的代码来编排从简单的加载指示器到复杂的、分阶段的仪表盘展示的一切。当您开始将 Suspense 集成到您的项目中时,您会发现它不仅改善了应用程序的性能和用户体验,还极大地简化了您的状态管理逻辑,让您能够专注于真正重要的事情:构建出色的功能。