Skip to content

React 的 Suspense #73

@inkjuncom

Description

@inkjuncom

什么是 Suspense

Suspense 允许组件在渲染时“暂停”,告诉 React:“我还没准备好(还在加载数据或代码),先帮我显示个占位符(Fallback),等我好了你再回来渲染我。”

初识 Suspense —— 代码分割 (React 18 之前)

Suspense 在 React 16.6 就可以部分使用, 一开始通常用 Suspense 配合 React.lazy 来做代码分割(Code Splitting)

为什么要这么做?

现代前端应用体积越来越大。如果用户只访问首页,却被迫下载了“个人中心”、“后台管理”等所有页面的代码,首屏加载会非常慢。

React.lazy 允许我们将组件拆分成独立的文件,只有当组件真正需要渲染时,才去网络请求下载代码。而 Suspense 就负责在下载期间显示“加载中...”。

代码例子

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

// --- 模拟部分 ---

// 在真实项目中,这里通常是: const LazyComponent = lazy(() => import('./MyComponent'));
// 这里=手动构造一个 Promise 来模拟 2秒 的网络加载延迟
const LazyHeavyComponent = lazy(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        default: () => (
          <div>
            <b>🎉 加载成功!</b><br />
            我是体积很大的组件,但我只有在被需要时才会被下载。
          </div>
        )
      });
    }, 2000);
  });
});

// --- 主应用部分 ---
export default function StageOneDemo() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <p>点击下方按钮,模拟从服务器动态加载一个组件。</p>

      <button 
        onClick={() => setShow(true)}
      >
        🚀 点击加载组件
      </button>

      <div style={{ border: '2px dashed #ccc', padding: '20px', minHeight: '100px' }}>
        {/* 
          核心知识点:
          1. 当 show 为 true,React 尝试渲染 LazyHeavyComponent。
          2. 发现它还没加载好(Promise pending),React 会向上寻找最近的 Suspense。
          3. 渲染 Suspense 的 fallback 内容。
          4. Promise resolve 后,自动切换为真实组件。
        */}
        {show && (
          <Suspense fallback={<div style={{ color: 'blue' }}>⏳ 正在通过网络下载组件代码...</div>}>
            <LazyHeavyComponent />
          </Suspense>
        )}
      </div>
    </div>
  );
}

核心解析

  • lazy(...): 它定义了一个动态组件。React 在初次渲染时不会去执行里面的 import,直到该组件被放置在 JSX 中。
  • 抛出 Promise: 当 React 试图渲染 LazyHeavyComponent 时,发现它还在加载中,React 内部会“捕获”到这个未完成的状态(本质上是捕获了一个 Promise)。
  • fallback 上场: 因为捕获到了等待状态,React 暂停渲染子组件,转而渲染 Suspense 提供的 fallback UI。

Tip

Suspense 的**“聚合”**特性。

Suspense 会等待其边界内所有的异步操作(Promise)都 Resolve 之后,才会统一展示内容。只要其中有一个还在加载,用户看到的就是 fallback


进阶 Suspense —— 异步数据流 (React 18)

React 18 中, 要让普通的 fetch 支持 Suspense, 我们需要一个特殊的 Resource(资源)读取器。

原理是:当组件读取数据时,如果数据没好,就 throw 一个 Promise 出去(就像抛出错误一样),React 捕获到这个 Promise 后就会显示 Fallback。

代码例子

import { Suspense, useState } from 'react';

function fetchUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: 'GG Bond', role: 'Assistance' })
    }, 2000)
  })
}

function wrapPromise(promise) {
  let status = 'pending'
  let result

  let suspender = promise.then(
    (r) => {
      status = 'success'
      result = r
    },
    (e) => {
      status = 'error'
      result = e
    }
  )

  return {
    read() {
      if (status === 'pending') {
        throw suspender // 关键:抛出 Promise,让 Suspense 捕获!
      } else if (status === 'error') {
        throw result // 抛出错误
      } else {
        return result; // 返回数据
      }
    }
  }
}

function UserProfile({ resource }) {
  const user = resource.read()

  return (
    <div>
      <h3>用户档案</h3>
      <p>姓名: {user.name}</p>
      <p>角色: {user.role}</p>
    </div>
  )
}

export default function Demo() {
  const [resource] = useState(() => wrapPromise(fetchUser()))

  return (
    <div>
      <Suspense fallback={<div style={{ color: 'orange' }}>📡 正在从服务器获取用户数据...</div>}>
        <UserProfile resource={resource} />
      </Suspense>
    </div>
  )
}

核心解析

  1. 声明式编程:

以前我们写 if (loading) return <Spinner />

现在 UserProfile 组件里完全没有加载状态的判断逻辑,它假定数据已经存在。

  1. throw Promise:

这是 Suspense 的黑魔法。resource.read() 在数据没回来时,像抛出异常一样抛出了一个 Promise。React 捕获到它,就知道:“哦,这个组件需要暂停”,于是去渲染最近的 Suspense fallback。

Tip

Suspense 数据获取的一个核心原则:Render-as-you-fetch(边渲染边获取)

在 React 18 中,我们需要保证 resource 是稳定的

function UserProfile() {
  // ❌ 错误写法:每次渲染都创建一个新的请求
  const resource = wrapPromise(fetchUser()); 
  const user = resource.read();
  return <div>{user.name}</div>;
}

这种写法会造成死循环

第一次渲染:

  1. React 执行 UserProfile。
  2. 执行 fetchUser(),发起一个新的网络请求(请求 A)。
  3. 执行 resource.read()。因为请求 A 刚发出去,状态是 pending。
  4. read() 抛出 Promise。
  5. React 捕获 Promise,**挂起(Suspend)**该组件,显示 Fallback。

Promise 解决(Resolve):

  1. 2秒后,请求 A 完成。
  2. React 收到通知:“嘿,刚才那个让组件挂起的 Promise 解决了!”
  3. React 决定:重新渲染 UserProfile 组件,试图显示真实内容。

第二次渲染(死循环开始):

  1. React 再次执行 UserProfile 函数。
  2. 🚨 关键点:因为变量定义在函数内部,代码再次执行 const resource = wrapPromise(fetchUser())。
  3. 发起了一个全新的网络请求(请求 B)!
  4. 执行 resource.read()。因为请求 B 是新的,状态又是 pending。
  5. read() 再次抛出新的 Promise。
  6. React 再次挂起组件...

结果:请求完成 -> 重渲染 -> 发起新请求 -> 挂起 -> 请求完成 -> 重渲染... 无限循环。

正确写法汇总

正确的写法 A:在组件外部定义(仅适用于全局静态数据)

如果数据是固定的,可以在组件外部定义。但在实际应用中很少见,因为数据通常是动态的。

// 可行,但仅限于非动态数据
const resource = wrapPromise(fetchUser());

function Parent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserComponent resource={resource} />
    </Suspense>
  );
}

正确的写法 B:在 useEffect 中设置的 State

function Parent() {
  // 1. 初始化为空
  const [resource, setResource] = useState(null);

  useEffect(() => {
    // 2. 这里只执行一次
    const newResource = wrapPromise(fetchUser());
    setResource(newResource);
  }, []); // <--- 空数组依赖,保证了只在组件挂载(Mount)时执行
  
  // ...
}

这是由 useEffect 的机制决定的。

  • 依赖数组 []:告诉 React,“这段代码只在组件第一次出现在屏幕上时运行”。
  • 后续渲染:当父组件因为其他原因(比如 props 变化)重新渲染时,React 会检查 useEffect 的依赖数组。发现依赖没变(还是空的),React 就直接跳过 useEffect 里面的代码。
  • State 的保持:useState 会从 React 的内部存储(Fiber Node)中取出之前存进去的 resource 对象返回给你,而不是重新生成。

正确写法 C: “惰性初始化”(Lazy Initialization)

function Parent() {
  // ✅ 正确:传入一个函数 () => ...
  const [resource] = useState(() => {
    return wrapPromise(fetchUser());
  });

  // ...
}

这是 useState 的特有机制:

  • 首次渲染:React 发现你传给 useState 的是一个函数。React 会调用这个函数,拿到返回值(resource),并将其存入内存。
  • 后续渲染:父组件再次执行 Parent() 函数。代码依然会走到这一行。但是,React 内部逻辑是:“我已经有这个 State 的值了,我不需要也不会再次调用那个初始化函数”。React 直接把内存里存好的那个 resource 返回给你。

正确写法 D: 最佳实践:Render-as-you-fetch (在事件或路由中触发)

React 团队最推荐的模式是:尽早开始获取数据。通常是在用户交互(点击按钮)或路由跳转时,就开始创建 resource,然后将其传递给组件。

// 模拟一个路由或页面容器
const initialResource = wrapPromise(fetchUser());

function App() {
  const [resource, setResource] = useState(initialResource);
  const [isPending, startTransition] = useTransition();

  function handleNextUser() {
    startTransition(() => {
      // ✅ 在交互发生时立即开始获取新数据,并更新 state
      setResource(wrapPromise(fetchNextUser()));
    });
  }

  return (
    <div>
      <button onClick={handleNextUser} disabled={isPending}>
        Next User
      </button>
      
      {/* resource 被传递给子组件 */}
      <Suspense fallback={<div>Loading...</div>}>
        <UserComponent resource={resource} />
      </Suspense>
    </div>
  );
}

// 子组件只负责“读取”
function UserComponent({ resource }) {
  const user = resource.read(); // 如果没准备好,这里会 throw promise
  return <h1>{user.name}</h1>;
}

补充说明:为什么写法 D 优于 B 和 C?

虽然 B (useEffect) 和 C (lazy init) 都能工作,但它们都属于组件加载后/加载时才发起请求。

  • 写法 B (useEffect):

父组件 Render -> 2. 父组件 Mount -> 3. 开始请求 -> 4. 等待数据 -> 5. 子组件 Render
(这是最慢的,有明显的瀑布流)

  • 写法 D (Best Practice):

点击按钮/路由跳转 -> 2. 开始请求 (同时) -> 3. 父组件 Render -> 4. 子组件 Render (Suspend)
(请求和渲染是并行的,用户体验最好)

Note

瀑布流 (Waterfall) = 排队。A 做完 B 才能做。

在图表上,这看起来就像楼梯台阶一样,一级一级往下掉,形状非常像瀑布。

请求 A:  [==========]
请求 B:              [==========]
请求 C:                          [==========]
总耗时:  A + B + C (非常慢)

Tip

如果你写成了下面这样,虽然用了 useState,但依然会造成重复请求:

function Parent() {
 // ❌ 错误:直接把函数调用结果传进去
 // JavaScript 语法决定了:在调用 useState 之前,必须先执行 wrapPromise(fetchUser())
 // 以便拿到结果作为参数传给 useState。
 const [resource] = useState(wrapPromise(fetchUser())); 

 // ...
}

为什么这是错的?

虽然 React State 本身没有变(React 会忽略后续渲染传入的初始值),但是:

JS 执行顺序:在进入 useState 内部逻辑之前,wrapPromise(fetchUser()) 已经在每一轮渲染中都被执行了。

后果:这意味着你每一次渲染都在发起新的网络请求(fetchUser 被调用了)。虽然 React 没有使用这个新请求的结果来更新 State,但请求已经发出去了,这极其浪费资源。

进阶 Suspense —— 错误边界 (ErrorBoundary)

既然 Suspense 处理了“加载中”的状态,那“加载失败”怎么办?比如:网断了,或者 API 返回 500 错误。

在 Suspense 模式下,数据获取的错误也是通过 throw 抛出来的(还记得 wrapPromise 里的 throw result 吗?)。

普通的 try/catch 无法捕获组件渲染过程中的错误,我们需要使用 Error Boundary(错误边界)。

代码例子

import { Suspense, Component } from 'react';

// --- 1. 模拟一个必定失败的请求 ---
function fetchBadData() {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('服务器爆炸了'))
    }, 1500)
  })
}

function wrapPromise(promise) {
  let status = 'pending'
  let result

  let suspender = promise.then(
    r => {
      status = 'success'
      result = r
    },
    e => {
      status = 'fail'
      result = e
    }
  )

  return {
    read() {
      if (status === 'pending') {
        throw suspender
      }
      if (status === 'fail') {
        throw result
      }
      if (status === 'success') {
        return result
      }
    }
  }
}

const resource = wrapPromise(fetchBadData())

// --- 2. 错误边界组件 (目前必须用 Class 组件) ---
class ErrorBoundary extends Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  // 静态方法:从错误中更新 state
  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }

  // 可以在这里打印日志
  componentDidCatch(error, errorInfo) {
    console.log('捕获到错误', error, errorInfo)
  }

  render() {
    // 自定义降级 UI
    if (this.state.hasError) {
      return (
        <div>
          <h3>⚠️ 哎呀,出错了!</h3>
          <p>{this.state.error.message}</p>
        </div>
      )
    }
    return this.props.children;
  }
}

// --- 3. 数据组件 ---
function BadComponent() {
  resource.read(); // 这里会抛出 Error
  return <div>这也永远不会显示</div>;
}

export default function Demo() {
  return (
    <div>
      <p>模拟请求失败的场景:</p>
      
      <div>
        {/* 
          层级结构:
          ErrorBoundary (处理失败)
            -> Suspense (处理加载中)
              -> Component (正常显示)
        */}
        <ErrorBoundary>
          <Suspense fallback={<div style={{ color: 'blue' }}>⏳ 正在尝试连接服务器...</div>}>
            <BadComponent />
          </Suspense>
        </ErrorBoundary>
      </div>
    </div>
  );
}

核心原理解析

这构成了 React 异步 UI 的完整形态:

  • Pending (加载中) -> 由 Suspense 处理。
  • Error (失败) -> 由 ErrorBoundary 处理。
  • Success (成功) -> 组件自身渲染。

这种结构将 UI 逻辑与状态处理逻辑彻底解耦,代码非常干净。

Tip

在普通的 React 组件交互中,我们习惯看到 this.setState(...) 来触发更新。但在 Error Boundary(错误边界)中,状态的更新机制略有不同。

这两个值的更新来源是代码中的这个静态生命周期方法:

static getDerivedStateFromError(error) {
  // 👇 这里返回的对象,会被 React 自动合并到 this.state 中
  return { hasError: true, error }
}

它是如何工作的?

React 内部有一套专门处理错误的机制,流程如下:

  1. 子组件抛出错误:
    BadComponent 执行 resource.read(),抛出了一个异常(Error)。
  2. React 捕获异常:
    React 框架捕获到这个异常,发现父组件 ErrorBoundary 定义了 getDerivedStateFromError 方法。
  3. 自动调用静态方法:
    React 自动调用 这个静态方法,并将捕获到的 error 作为参数传进去。
    注意:这是由 React 调用的,不是你手动调用的。
  4. 状态合并(关键点):
    这个方法返回了一个对象 { hasError: true, error }。
    React 会拿到这个返回的对象,自动执行类似 setState 的操作,将其合并到组件的当前 state 中。
  5. 触发重新渲染:
    由于 state 变了(hasError 变成了 true),React 会触发 ErrorBoundary 的 render 方法重新执行。
  6. 显示降级 UI:
    在 render 中,if (this.state.hasError) 判断为真,于是渲染了报错提示 UI。

Tip

在实际开发中,绝大多数开发者会使用 react-error-boundary 这个库。它在内部封装好了 Class 组件,但对外提供了非常现代化的、基于 Props 的 API,让你在函数组件中使用起来感觉像是在用原生 Hook 一样自然。

import { ErrorBoundary } from "react-error-boundary";

function FallbackComponent({ error, resetErrorBoundary }) {
 return (
   <div role="alert">
     <p>Something went wrong:</p>
     <pre>{error.message}</pre>
     <button onClick={resetErrorBoundary}>Try again</button>
   </div>
 );
}

function App() {
 return (
   <ErrorBoundary
     FallbackComponent={FallbackComponent}
     onReset={() => {
       // 重置应用状态
     }}
   >
     <MyComponent />
   </ErrorBoundary>
 );
}

拥抱未来 —— React 19 的新特性 (use API)

在 React 18 中,我们必须自己写 wrapPromise 或者依赖第三方库。React 19 引入了一个新的 Hook:use。

use API 可以直接在组件内部“解包”一个 Promise。如果 Promise 还没完成,它会自动触发 Suspense;如果失败了,它会自动触发 ErrorBoundary。

最大的改变:你再也不用写那个复杂的 wrapPromise 函数了!代码量直接减半。

正确写法汇总

在 React 19 中,use API 的引入极大地简化了 Suspense 的使用,但它并没有改变一个核心原则:你仍然需要自己管理 Promise 的生命周期(创建与缓存)。

推荐模式 A:Promise 存放在 State 中 (手动管理)

import { Suspense, useState, use } from 'react';

// 1. 定义数据获取函数(返回 Promise)
const fetchUser = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: 'Tom' })
    }, 2000)
  })
}


function UserProfile({ userPromise }) {
  // 3. 使用 `use` 解包 Promise
  // 如果 Promise 还没 resolve,这里会自动 Suspend
  // 如果 reject,这里会自动 throw Error (给 ErrorBoundary)
  const user = use(userPromise)
  return <h1>{user.name}</h1>
}

export default function Demo() {
  const [userPromise, setUserPromise] = useState(null)

  const handleLoad = () => {
    // 2. 点击时创建 Promise,并存入 State
    // 注意:这里存的是 Promise 对象本身,而不是数据结果!
    setUserPromise(fetchUser(1));
  }

  return (
    <div>
      <button onClick={handleLoad}>Load User</button>
      {/* 只有当 promise 存在时才渲染子组件 */}
      {
        userPromise && (
          <Suspense fallback={<p>Loading</p>}>
            <UserProfile userPromise={userPromise} />
          </Suspense>
        )
      }
    </div>
  )
}

推荐模式 B:Promise 来自 Props (RSC 混合模式)

如果你在使用 Next.js (App Router) 等支持 RSC 的框架,这是最常见的模式。

  • 服务端组件负责创建 Promise。
import { Suspense } from 'react'
import UserProfile from './user'

export default function Page() {
  // 在服务端创建一个 Promise, 但是不 await 它
  const userPromise = new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: 'Tom' })
    }, 2000)
  })

  return (
    <Suspense fallback={<p>Loading</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}
  • 客户端组件负责用 use 消费它。
'use client'

import { use } from 'react'

export default function UserProfile({ userPromise }) {
  const user = use(userPromise)
  return <div>{user.name}</div>
}

推荐模式 C:使用第三方库 (TanStack Query / SWR)

在生产环境的纯客户端应用(CSR)中,手动管理 Promise 的缓存(如模式 A)是非常痛苦的(你需要处理竞态条件、缓存失效、重复请求等)。

虽然 React 19 有了 use,但TanStack Query 或 SWR 依然是最佳选择。React 19 的 use 更多是给库作者用的底层原语。

import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchTodos, type Todo } from './api'

function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
  const data = React.use(query.promise)

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

export function App() {
  const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

  return (
    <>
      <h1>Todos</h1>
      <React.Suspense fallback={<div>Loading...</div>}>
        <TodoList query={query} />
      </React.Suspense>
    </>
  )
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions