大多数人对 Suspense 的理解是:

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

组件没数据 → 显示 Loading → 数据到了 → 显示真实内容。

这个理解没有错,但它是 Suspense 最浅的一层。

Suspense 的真正设计意图是:

协调"数据什么时候到"和"UI 什么时候揭示"这两个独立问题。

它不是 loading 状态管理。它是 React 并发模型的核心原语之一——和 Fiber、Scheduler、Lane 同等重要。


一、Thenable 协议:Suspense 的捕获机制

Suspense 的捕获机制非常简单:组件 throw 一个 thenable,Suspense 边界捕获它。

function UserProfile({ userId }) {
  const data = fetchData(userId) // 返回 thenable 或真实数据

  if (!data) {
    throw fetch(userId) // throw thenable → suspend
  }

  return <div>{data.name}</div>
}

React 不关心你的 thenable 从哪来。它只检查:

// React 内部的判断逻辑
if (typeof value === 'object' && value !== null && typeof value.then === 'function') {
  // 这是一个 thenable,组件在 suspend
}

任何实现了 .then() 方法的对象都可以:

// Promise
throw fetch('/api/user')

// 自定义 thenable
throw {
  then(resolve, reject) {
    setTimeout(() => resolve(data), 1000)
  },
}

// React Query 的 promise
throw queryClient.fetchQuery({ queryKey: ['user', id], queryFn: fetchUser })

这就是为什么 Suspense 能和任何数据源配合——React 只定义协议,不绑定实现。


二、Fiber 层:suspend 发生了什么

当组件 throw thenable 时,React 在 Fiber 层做了什么?

2.1 捕获与标记

// React 内部(简化)
function throwException(root, returnFiber, sourceFiber, value) {
  // value 是 thenable
  sourceFiber.flags |= Incomplete

  // 找到最近的 Suspense 边界
  let suspenseBoundary = getNearestSuspenseBoundary(returnFiber)

  if (suspenseBoundary) {
    // 挂起:把 thenable 挂到 Suspense Fiber 上
    suspenseBoundary.memoizedState = {
      dehydrated: null,
      retries: { lanes: NoLanes, pingCache: null },
      // 核心:pending 请求列表
      pendingRequests: createThenableState(value),
    }
  }
}

关键点:thenable 被记录在 Suspense Fiber 的 memoizedState,不是组件自己的 Fiber。

2.2 暂停分支

Root
├── App
│   ├── Header                    ← 继续渲染
│   └── Suspense (fallback={<Loading />})
│       └── UserProfile           ← throw thenable
│           └── (暂停,不继续向下)

React 暂停 Suspense 边界内的整个子树。不是只暂停 throw 的那个组件,而是整个子树

为什么?因为子树里的其他组件可能依赖这个数据。如果继续渲染,会产生不一致的 UI。

2.3 渲染 fallback

暂停边界内的子树后,React 渲染 Suspense 的 fallback:

Root
├── App
│   ├── Header                    ← 正常渲染
│   └── Suspense
│       └── Loading               ← 渲染 fallback

用户看到的是:Header 正常显示,内容区域显示 Loading。


三、恢复机制:从 Promise resolve 到 UI 切换

thenable resolve 后,React 怎么知道要重新渲染?

3.1 注册回调

// React 内部
function trackUsedThenable(thenableState, thenable) {
  // 1. 记录 thenable
  // 2. 注册 .then 回调
  thenable.then(
    (value) => {
      // resolve 时触发
      pingSuspendedRoot(root, thenableState)
    },
    (error) => {
      // reject 时触发(进入 Error Boundary)
    },
  )
}

3.2 pingSuspendedRoot

function pingSuspendedRoot(root, thenableState) {
  // 获取之前暂停时记录的 lane
  const pingCache = root.pingCache
  const threadIDs = pingCache.get(thenable)

  // 用之前记录的 lane 重新触发更新
  markRootPinged(root, pingedLanes)

  // 确保 root 有 work
  ensureRootIsScheduled(root)
}

关键:用暂停时记录的 lane 重新触发更新。这意味着恢复时的优先级和暂停时一样——如果是 Transition 触发的 suspend,恢复也是 Transition 优先级。

3.3 microtask 还是 macrotask?

Promise.resolve().then() 是微任务。React 的恢复回调也是通过微任务触发的。

Promise resolve

.then() 回调(微任务)

pingSuspendedRoot

确保 root 有 work

Scheduler 调度(宏任务)

重新渲染

为什么用微任务?因为 Promise resolve 后,微任务在当前任务结束后立即执行,不会被其他宏任务打断。这保证了恢复是即时的——数据一到,UI 就更新。


四、边界语义:嵌套与独立

4.1 Suspense 是"数据边界",不是"错误边界"

<ErrorBoundary fallback={<Error />}>
  {' '}
  {/* 捕获 throw Error */}
  <Suspense fallback={<Loading />}>
    {' '}
    {/* 捕获 throw thenable */}
    <UserProfile />
  </Suspense>
</ErrorBoundary>

两个边界,各管各的:

  • throw Error → ErrorBoundary 捕获
  • throw thenable → Suspense 捕获
  • 如果 Suspense 内部 throw Error → 穿透 Suspense,被 ErrorBoundary 捕获

4.2 嵌套 Suspense = 渐进式揭示

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
  <Suspense fallback={<CommentsSkeleton />}>
    <Comments />
  </Suspense>
</Suspense>

每个边界独立工作:

时间线:

T0: 开始渲染
    → Header 立即显示
    → MainContent suspend → 显示 ContentSkeleton
    → Comments suspend → 显示 CommentsSkeleton

T1: MainContent 数据到了
    → MainContent 切换到真实内容
    → Comments 仍然显示 skeleton(不受影响)

T2: Comments 数据到了
    → Comments 切换到真实内容

一个慢了不阻塞另一个。这就是 Progressive Disclosure

4.3 最近边界原则

<Suspense fallback={<Outer />}>
  <Suspense fallback={<Inner />}>
    <Component /> {/* suspend → 显示 Inner,不是 Outer */}
  </Suspense>
</Suspense>

React 向上找最近的 Suspense 边界。嵌套时内层优先。


五、并发交互:Transition 里的 Suspense

这是 Suspense 最精妙的设计之一。

5.1 普通 setState 触发的 Suspense

function SearchResults() {
  const [query, setQuery] = useState('react')

  return (
    <div>
      <input onChange={(e) => setQuery(e.target.value)} />
      <Suspense fallback={<Loading />}>
        <Results query={query} />
      </Suspense>
    </div>
  )
}

用户输入 → setQuery → Results suspend → 显示 Loading → 旧结果消失。

用户体验:每次输入都闪一下 Loading。

5.2 Transition 触发的 Suspense

function SearchResults() {
  const [query, setQuery] = useState('react')
  const [isPending, startTransition] = useTransition()

  function handleSearch(e) {
    // 紧急:输入框立即响应
    setQuery(e.target.value)

    // 非紧急:搜索结果可以等
    startTransition(() => {
      setResults(search(e.target.value))
    })
  }

  return (
    <div>
      <input onChange={handleSearch} />
      <Suspense fallback={<Loading />}>
        <Results />
      </Suspense>
    </div>
  )
}

关键:startTransition 里的 setState 触发的 Suspense 不会显示 fallback

T0: 用户输入 "abc"
    → setQuery("abc")           → 输入框立即更新
    → startTransition → setResults(suspend)
    → Results 仍然显示旧结果(不显示 Loading)

T1: 搜索结果数据到了
    → Results 切换到新结果

旧结果一直在,直到新结果准备好。没有闪烁。

5.3 为什么 Transition 能做到这个?

因为 Suspense 边界有一个内部状态:fallbackprimary

普通更新触发 suspend:
    边界状态 → fallback
    用户看到 → Loading

Transition 触发 suspend:
    边界状态 → 保持 primary
    用户看到 → 旧内容

React 在 Transition 模式下告诉 Suspense 边界:"先别切换,等等看数据能不能很快到。"

如果数据很快到了(一帧内),直接切换,用户看不到任何中间状态。 如果数据很慢,React 会根据 isPending 让你决定是否显示 loading indicator。

5.4 useTransition vs useDeferredValue

两者都和 Suspense 配合,但切入点不同:

// useTransition:控制 setState 的优先级
startTransition(() => {
  setResults(search(query)) // 这个 setState 是 Transition 优先级
})

// useDeferredValue:创建一个"延迟"版本的值
const deferredQuery = useDeferredValue(query)
// deferredQuery 是 query 的延迟版本
// React 在空闲时用 deferredQuery 渲染

useDeferredValue 适合你控制不了触发更新的场景(比如 props 传进来的值)。

两者本质相同:告诉 React "这个更新不紧急,Suspense 别急着切换 fallback"。


六、RSC 交互:Suspense 与流式渲染

Suspense 不只是客户端机制。在 RSC 场景下,它是服务端流式渲染的协调器

6.1 传统 SSR 的问题

服务端:
  渲染整棵树(必须等最慢的数据查询完成)

  一次性发送 HTML

客户端:
  接收完整 HTML → 显示
  Hydrate → 可交互

问题:一个慢查询阻塞整棵树。用户看到白屏直到所有数据都准备好。

6.2 RSC + Suspense 的流式渲染

服务端:
  <App>
    <Header />                    ← 立即序列化发送
    <Suspense fallback={<Skeleton />}>
      <SlowContent />             ← 数据还没到,发占位符
    </Suspense>
  </App>

客户端(第一帧):
  Header 显示
  SlowContent 区域显示 Skeleton

服务端(数据到了):
  Progressive JSON 补发 SlowContent 的 JSX

客户端(收到后):
  Suspense 切换 → SlowContent 显示真实内容

一个慢查询不阻塞其他部分。用户立即看到 Header,SlowContent 区域先显示骨架屏,数据到了再切换。

6.3 Progressive JSON

RSC 不发送 HTML,发送的是渐进式 JSON:

第一帧(外壳立刻到达):
  { header: "$1", content: "$2", comments: "$3" }

第二帧(content 结构出来了):
  /* $2 */ { body: "$4", sidebar: "$5" }

第三帧(comments 填充完毕):
  /* $5 */ ["$6", "$7", "$8"]

占位符 $2 是一个 Promise,数据到了自动填充。一个慢的部分不阻塞其他部分。

Suspense 控制用户看到什么,和数据传输是解耦的。


七、缓存交互:Suspense 本身无缓存

Suspense 的一个常见误解:它提供缓存。

不。Suspense 本身不提供任何缓存。

// 没有缓存的情况
function UserProfile({ userId }) {
  const data = fetchUser(userId) // 每次渲染都 fetch
  return <div>{data.name}</div>
}

// 用户离开再回来 → 又 suspend → 又 fetch → 又显示 Loading

Suspense 只负责"throw thenable → 显示 fallback → thenable resolve → 切换回来"。缓存是你的责任。

7.1 与 React Query 的配合

function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    suspense: true, // 启用 Suspense 模式
  })

  return <div>{data.name}</div>
}

React Query 提供缓存 + Suspense 支持。它内部的逻辑:

// 简化
function useQuery(options) {
  const cached = queryCache.get(options.queryKey)

  if (cached && !cached.isStale) {
    return { data: cached.data } // 缓存命中,不 suspend
  }

  // 缓存未命中或过期
  const promise = fetchAndCache(options)
  throw promise // suspend
}

第一次:缓存未命中 → throw promise → suspend → 显示 Loading 第二次:缓存命中 → 直接返回数据 → 不 suspend

7.2 自己实现 cache-first Suspense

const cache = new Map()

function UserProfile({ userId }) {
  const cached = cache.get(userId)

  if (cached) {
    return <div>{cached.name}</div>
  }

  // 没有缓存,throw thenable
  throw fetchUser(userId).then((data) => {
    cache.set(userId, data) // 缓存结果
    return data
  })
}

关键是:cache 在 Suspense 组件外部。组件重新渲染时检查 cache,命中就不 suspend。


八、实战模式

8.1 搜索框:Transition + Suspense

function SearchPage() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()

  return (
    <div>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value) // 紧急:输入框立即响应
          startTransition(() => {
            // Transition 触发的 suspend 不会显示 fallback
            // 旧结果保持显示,直到新结果准备好
          })
        }}
      />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<Spinner />}>
          <SearchResults query={query} />
        </Suspense>
      </div>
    </div>
  )
}

isPending 告诉你"有 transition 正在进行"。你可以用它做视觉反馈(比如降低透明度),但不会闪 Loading。

8.2 表单提交:useOptimistic + Suspense

function CommentForm({ postId }) {
  const [optimisticComments, addOptimistic] = useOptimistic(comments, (prev, newComment) => [
    ...prev,
    { ...newComment, pending: true },
  ])

  async function handleSubmit(formData) {
    addOptimistic({ text: formData.get('text') }) // 乐观更新

    startTransition(async () => {
      await submitComment(postId, formData)
      // 乐观值自动替换为真实值
    })
  }

  return (
    <>
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentList comments={optimisticComments} />
      </Suspense>
      <form action={handleSubmit}>
        <textarea name="text" />
        <button type="submit">发送</button>
      </form>
    </>
  )
}

乐观更新立即显示,不需要等服务端响应。如果失败,自动回滚。

8.3 数据预加载

// 预加载:用户 hover 时就开始 fetch
function UserLink({ userId }) {
  return (
    <Link
      href={`/user/${userId}`}
      onMouseEnter={() => {
        // 预加载数据,不 suspend
        queryClient.prefetchQuery({
          queryKey: ['user', userId],
          queryFn: () => fetchUser(userId),
        })
      }}
    >
      User {userId}
    </Link>
  )
}

// 目标页面:数据已经在缓存里,不 suspend
function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    suspense: true,
  })

  return <div>{data.name}</div>
}

用户 hover 时数据就开始加载。点击进入页面时,数据已经在缓存里,不 suspend,直接显示。


九、未来方向:Activity / Offscreen

React 19+ 引入了 <Activity> 组件(之前叫 <Offscreen>):

<Activity mode={isVisible ? 'visible' : 'hidden'}>
  <Suspense fallback={<Loading />}>
    <HeavyComponent />
  </Suspense>
</Activity>

9.1 "隐藏但不卸载"

传统模式:

用户离开 Tab → 卸载组件 → 状态丢失
用户回到 Tab → 重新挂载 → 重新 fetch → 重新渲染

Activity 模式:

用户离开 Tab → 隐藏组件 → 状态保留 → DOM 可能被回收
用户回到 Tab → 显示组件 → 状态恢复 → 不需要重新 fetch

9.2 与 Suspense 的交互

<Activity mode="hidden">
  <Suspense fallback={<Loading />}>
    <UserProfile userId={1} />
  </Suspense>
</Activity>

当 Activity mode="hidden" 时:

  • 如果 UserProfile 已经加载完成 → 保持渲染结果
  • 如果 UserProfile 还在 suspend → 继续等待数据,不显示 fallback

这意味着:用户切换到其他 Tab 时,后台数据加载继续进行。用户切回来时,数据已经准备好了。


十、总结:Suspense 的设计哲学

Suspense 解决的核心问题是:

协调"数据什么时候到"和"UI 什么时候揭示"这两个独立问题。

它不是 loading 状态管理。它是:

角色说明
协议Thenable 协议,任何数据源都能配合
边界数据边界,捕获 throw thenable
协调器协调数据获取和 UI 揭示
并发原语与 Transition 配合,实现"旧内容保持"
流式渲染器与 RSC 配合,实现服务端流式渲染

最终回到五个 Law:

  • Law 1 (Environment):Suspense 在 Server 和 Client 都能工作
  • Law 2 (Description):Suspense 控制"什么时候展示什么 Description"
  • Law 3 (Ownership):Suspense 边界拥有子树的渲染生命周期
  • Law 4 (Lane):Transition 里的 Suspense 保持当前 UI,不切换 fallback
  • Law 5 (Cost):Suspense 让用户立即看到可以看的部分,而不是等所有数据

Suspense 的最高境界不是"显示 Loading"。

而是:让用户尽可能快地看到有意义的内容,同时避免不必要的中间状态闪烁。