陌上人如玉
公子世无双

4.16 Suspense 的调度语义

4.16 Suspense 的调度语义

一句话:Suspense 不只是「显示 fallback」——它背后是一套调度机制:挂起会被登记为「挂起的车道(suspendedLanes)」并监听 promise 完成(ping),而是否显示 fallback 取决于「这次更新是不是 transition」(transition 中挂起会保留旧内容)。

一、「挂起」从哪里来

// ① React.lazy:代码还没下载完
const Heavy = lazy(() => import('./Heavy'));

// ② use(promise):数据还没就绪(React 19)
function Comments({ promise }: { promise: Promise<Comment[]> }) {
  const comments = use(promise);           // ⭐ 未就绪 → 挂起
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

// ③ 数据层抛 promise:useSuspenseQuery / 框架的 loader
function Profile() {
  const { data } = useSuspenseQuery({ queryKey: ['me'], queryFn: fetchMe });   // 内部会 throw promise
  return <div>{data.name}</div>;
}
⭐⭐ 三者本质完全一样:**在渲染过程中「抛出一个 thenable(promise)」**。

   这是 Suspense 的唯一协议:
     · 组件在 render 期间 `throw promise`
     · React 捕获它(不是当作错误!而是「挂起信号」)
     · React 找到「最近的 Suspense 边界」→ 显示它的 fallback
     · 同时给这个 promise 挂一个「完成回调」(ping)
     · promise resolve → ping → React 重新渲染那个边界

⭐ 为什么用「抛出」而不是「返回 loading 状态」?
   因为「抛出」能让 React **在渲染中途放弃这棵子树**(不产生半成品输出),
   同时把「等待」变成一个「可以被调度系统处理的信号」——
   这样 React 才能做「优先级」与「不立刻显示 fallback」这些事。
// ✅ 手写一个「最小可用的挂起数据源」(理解协议)
function createSuspenseResource<T>(promise: Promise<T>) {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let result: T;
  let error: unknown;

  const suspender = promise.then(
    (value) => { status = 'success'; result = value; },
    (err) => { status = 'error'; error = err; }
  );

  return {
    read(): T {
      if (status === 'pending') throw suspender;      // ⭐ 挂起(抛 promise)
      if (status === 'error') throw error;            // ⭐ 错误(抛 error)
      return result;
    },
  };
}

// 使用
const userResource = createSuspenseResource(fetchUser());
function User() {
  const user = userResource.read();                   // 未就绪时这里会「抛」
  return <div>{user.name}</div>;
}

二、挂起后 React 做了什么

⭐⭐ 挂起后的四件事(顺序很重要):

① 【登记「挂起的车道」】
   把「这次更新所在的车道」标记为 suspendedLanes
   → 意思是「这条车道的渲染被阻塞了,在它就绪前别重试」

② 【监听 promise 完成(ping)】
   给 promise 挂一个回调:完成时把对应车道加进 pingedLanes
   → 并触发一次「重试」(retryTimedOutBoundary / pingedLanes 被消费)

③ 【处理「已经渲染出来的部分」】
   · 如果这个边界「之前显示过内容」,React 会把那部分**隐藏**(Offscreen 机制,见 4.13)
   · 如果这个边界「从未显示过」,就没有旧内容可隐藏
   → 所以「隐藏」与「显示 fallback」是两件事

④ 【决定「显示谁」】⭐ 关键分支(见下一节)
   · 这次更新是 transition?→ **保留旧内容**(不显示 fallback)
   · 不是 transition?      → **显示 fallback**
⭐ 为什么要有 pingedLanes 这套机制(而不是「promise.then 后直接 setState」)?
   因为「promise 完成」不是一个 React 事件——
   它需要被「翻译成一次调度」(在合适的优先级、合适的时机重渲染)。
   `pingedLanes` 就是「把这个外部信号接入 React 调度系统」的桥。

三、是否显示 fallback:靠什么决定

// ❌ 非 transition:挂起立刻显示 fallback(内容会「闪一下骨架」)
function Search({ keyword }) {
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    setKeyword(e.target.value);
    setResults(null);                        // 触发同步更新 → 子树挂起 → 立刻显示 fallback
  };

  return (
    <Suspense fallback={<Skeleton />}>
      <Results resource={results} />
    </Suspense>
  );
}
// ✅ transition:挂起时【保留旧内容】,等新内容就绪再一次性切换
function Search() {
  const [keyword, setKeyword] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    startTransition(() => setKeyword(value));   // ⭐ 挂起发生在 transition 里
  };

  return (
    <Suspense fallback={<Skeleton />}>
      <Results keyword={keyword} />              {/* 挂起时不显示 fallback,保留上一次的结果 */}
    </Suspense>
  );
}
⭐⭐⭐ 这是 Suspense 最关键的一条规则(面试必答):

   「如果挂起来自【transition(startTransition / useTransition / useDeferredValue)】,
      React 不会显示 fallback —— 它会【继续显示之前的内容】,直到新内容就绪。」

   「如果挂起来自【非 transition 的更新】(普通 setState、首次渲染),
      React 会显示 fallback。」

   ⭐ 为什么这样设计?
      · 用户主动触发的(点击/输入)应该「立刻有反馈」→ 该显示什么就显示什么
      · 但「切 Tab、切换筛选」这类「意图明确、结果未就绪」的场景,
        显示骨架反而更糟(页面闪一下)→ 保留旧内容更连贯

   ⭐ 实践推论:
     「Tab 切换 / 路由切换不闪骨架」的正确写法不是「给 Suspense 加延迟」,
      而是「把这次更新放进 transition」。
场景 挂起来源 是否显示 fallback 用户感受
首次进入页面 普通渲染 ✅ 显示 正常的加载态
点击「切换筛选」→ 数据未就绪 transition ❌ 不显示(保留旧结果) 内容「稍后更新」,不闪
点击「加载更多」→ 请求挂起 普通更新 ✅ 显示 底部 loading
快速输入搜索词 transition ❌ 不显示 输入跟手,结果稍后
useDeferredValue 的滞后值 transition ❌ 不显示 内容平滑跟上
// ✅ 一个完整例子:路由切换「不闪骨架」
function Router() {
  const [pathname, setPathname] = useState('/');
  const [isPending, startTransition] = useTransition();

  const navigate = (to: string) => {
    startTransition(() => setPathname(to));      // ⭐ 关键:放进 transition
  };

  return (
    <>
      {isPending && <TopProgressBar />}          {/* 用「轻提示」代替骨架 */}
      <Suspense fallback={<PageSkeleton />}>
        <Route path={pathname} />
      </Suspense>
    </>
  );
}

// ⭐ 对比:如果 navigate 里直接 setPathname(to),切换时就会「闪一下骨架」

四、revealOrder:多个边界怎么揭示

// 默认:多个兄弟边界「各自独立」揭示 —— 谁先就绪谁先显示
<Suspense fallback={<ASkeleton />}><A /></Suspense>
<Suspense fallback={<BSkeleton />}><B /></Suspense>
<C />     {/* 非挂起内容,立刻显示 */}
// → 如果 A 比 B 先就绪,用户会看到 A 先出现、B 后出现(可能「跳一下」)
// ⭐ SuspenseList(实验特性):控制「揭示顺序」
import { SuspenseList } from 'react';

<SuspenseList revealOrder="forwards">           {/* 按顺序揭示:A 好了才揭示 B */}
  <Suspense fallback={<ASkeleton />}><A /></Suspense>
  <Suspense fallback={<BSkeleton />}><B /></Suspense>
  <Suspense fallback={<CSkeleton />}><C /></Suspense>
</SuspenseList>

<SuspenseList revealOrder="together">           {/* 一起揭示:全部就绪才一起显示 */}
  …
</SuspenseList>

<SuspenseList revealOrder="backwards">          {/* 从后往前揭示 */}
  …
</SuspenseList>
revealOrder 行为 适合
forwards 按顺序揭示(前一个就绪后才揭示下一个) 有阅读顺序的内容(文章段落)
backwards 从后往前揭示 少见(列表追加)
together 全部就绪后一起揭示 表格的多列数据(避免「参差不齐」)
默认(不用 SuspenseList) 各自独立,先到先显示 大多数场景
⚠️ 现状:`SuspenseList` 目前仍是【实验特性】(且在新的并发实现下行为有变化)。
   实践中的替代手段是「把「需要一起显示」的内容放进同一个 Suspense 边界」
   —— 同一边界内的挂起会一起等,从而「一起揭示」。

五、SSR 与客户端:两条不同的路径

维度 客户端 Suspense SSR / 流式 Suspense
「挂起」的表现 用 Offscreen 隐藏已渲染部分 + 显示 fallback 服务端先输出 fallback 的 HTML + 一个占位标记
就绪后的行为 React 重新渲染该边界(保留 state) 服务端把真实 HTML 流式发送 + 一段替换脚本
是否显示 fallback 取决于是否 transition 总是先输出 fallback(因为 HTML 必须有序)
与「水合」的关系 挂起不影响外层水合 慢的部分后水合(渐进式水合)
⭐⭐ 一个「反直觉」但重要的差异:

   客户端:挂起「可能不显示 fallback」(transition 场景)
   服务端:**总是**先输出 fallback 的 HTML(因为 HTML 流必须有序,
          没有「保留旧内容」这个选项——第一次访问时也没有旧内容)

   ⭐ 所以「SSR 下的 transition」并不能避免骨架出现(首屏本来就要给骨架),
     transition 主要改善的是【客户端导航/交互】时的骨架闪烁。

六、完整实现:一个 mini Suspense(190 行)

下面是「用类组件捕获抛出的 promise」实现的 Suspense(这是社区 polyfill 的思路),并且实现了「节流」与「transition 下不显示 fallback」两个关键特性。

// mini-suspense.tsx —— 教学版 Suspense
import React, { Component, createContext, useContext, useRef, useEffect, useState, useTransition } from 'react';

/** ============ 1. 工具:判断是否为 thenable ============ */
function isThenable(value: unknown): value is Promise<unknown> {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as { then?: unknown }).then === 'function'
  );
}

/** ============ 2. 调度上下文:让「transition 内」的挂起不显示 fallback ============ */
interface RuntimeContext {
  /** 当前是否处于「低优先级(transition)」更新中 */
  isTransition: boolean;
  /** 注册一个「重试回调」——当挂起的 promise 完成时被调用 */
  ping: (fn: () => void) => void;
}

const Runtime = createContext<RuntimeContext>({ isTransition: false, ping: (fn) => fn() });
export const useMiniRuntime = () => useContext(Runtime);

/** ============ 3. 提供 transition 能力的包装 ============ */
export function MiniRoot({ children }: { children: React.ReactNode }) {
  const [isPending, startTransition] = useTransition();
  const pendingRef = useRef(0);

  const ping = (fn: () => void) => {
    // ⭐ 关键:用 transition 触发「重试」,
    //    这样「重试期间的挂起」也不会显示 fallback
    pendingRef.current++;
    startTransition(() => {
      fn();
      pendingRef.current--;
    });
  };

  return (
    <Runtime.Provider value={{ isTransition: isPending || pendingRef.current > 0, ping }}>
      {children}
    </Runtime.Provider>
  );
}
/** ============ 4. Suspense 本体 ============ */
interface MiniSuspenseProps {
  fallback: React.ReactNode;
  children: React.ReactNode;
  /** ⭐ 节流:挂起后「延迟多久」才显示 fallback(避免一闪而过) */
  throttleMs?: number;
}

interface MiniSuspenseState {
  /** 是否处于「挂起」状态 */
  suspended: boolean;
  /** 是否「到了该显示 fallback」的时刻(节流用) */
  showFallback: boolean;
  error: unknown;
}

export class MiniSuspense extends Component<MiniSuspenseProps, MiniSuspenseState> {
  static contextType = Runtime;
  declare context: RuntimeContext;

  state: MiniSuspenseState = { suspended: false, showFallback: false, error: null };
  private throttleTimer: ReturnType<typeof setTimeout> | null = null;
  private retryCount = 0;

  /**
   * ⭐ 核心:捕获子组件渲染时「抛出的 thenable」
   *   注意:真实 React 是在 reconciler 里捕获的;
   *        这里借用 Error Boundary 的机制来「捕获并重试」(polyfill 思路)
   */
  static getDerivedStateFromError(error: unknown): Partial<MiniSuspenseState> | null {
    if (isThenable(error)) return { suspended: true, error: null };
    return { error };                              // 真正的错误 → 交给错误边界逻辑
  }

  componentDidCatch(error: unknown) {
    if (!isThenable(error)) throw error;            // 真错误往上抛(交给外层 ErrorBoundary)

    const thenable = error as Promise<unknown>;

    // ⭐ ① 「节流」:如果不是 transition,延迟 throttleMs 再显示 fallback
    if (this.throttleTimer === null) {
      if (!this.context.isTransition) {
        this.throttleTimer = setTimeout(() => {
          this.throttleTimer = null;
          if (this.state.suspended) this.setState({ showFallback: true });
        }, this.props.throttleMs ?? 120);
      } else {
        // ⭐ transition 内:不显示 fallback(保持旧内容)
        this.setState({ showFallback: false });
      }
    }

    // ⭐ ② 监听 promise 完成 → ping → 重试渲染
    thenable.then(
      () => this.retry(),
      (err) => this.setState({ suspended: false, error: err })
    );
  }

  componentWillUnmount() {
    if (this.throttleTimer) clearTimeout(this.throttleTimer);
  }

  private retry = () => {
    this.retryCount++;
    this.context.ping(() => {
      // 清掉节流计时器,回到「未挂起」状态 → 触发重新渲染(子组件会再试一次)
      if (this.throttleTimer) {
        clearTimeout(this.throttleTimer);
        this.throttleTimer = null;
      }
      this.setState({ suspended: false, showFallback: false, error: null });
    });
  };

  render() {
    const { suspended, showFallback, error } = this.state;
    const { children, fallback } = this.props;

    if (error) throw error;                        // 交给外层 ErrorBoundary

    // ⭐ 关键:挂起但「还没到显示 fallback 的时刻」→ 保持旧内容
    //    (真实 React 用 Offscreen 保留 DOM;这里用「保留上一次的 children 输出」来近似)
    if (suspended && !showFallback) {
      return this.lastChildren ?? null;            // 保留上一帧的输出
    }

    if (suspended && showFallback) {
      return <>{fallback}</>;                      // 显示 fallback
    }

    // 未挂起:渲染子组件,并记住输出(供「挂起时保留」用)
    this.lastChildren = children;
    return <>{children}</>;
  }

  private lastChildren: React.ReactNode = null;
}
/** ============ 5. 一个「会挂起」的数据源 + 使用示例 ============ */
const cache = new Map<string, { status: 'pending' | 'done'; value?: string; promise: Promise<void> }>();

function fetchSlow(key: string, delayMs: number) {
  let entry = cache.get(key);
  if (!entry) {
    const promise = new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(() => {
      entry!.status = 'done';
      entry!.value = `「${key}」的结果(耗时 ${delayMs}ms)`;
    });
    entry = { status: 'pending', promise };
    cache.set(key, entry);
  }
  return entry;
}

function SlowData({ id, delay }: { id: string; delay: number }) {
  const entry = fetchSlow(id, delay);
  if (entry.status === 'pending') throw entry.promise;   // ⭐ 挂起
  return <p>{entry.value}</p>;
}

export function Demo() {
  const [id, setId] = useState('A');

  return (
    <MiniRoot>
      <div>
        <button onClick={() => setId('A')}>切到 A(快)</button>
        <button onClick={() => setId('B')}>切到 B(慢 800ms)</button>
        <button onClick={() => { cache.clear(); setId('C'); }}>切到 C(慢,清缓存)</button>

        {/* ⭐ 用 throttleMs 观察「节流」效果;用 transition 观察「不显示 fallback」 */}
        <MiniSuspense fallback={<p>加载中…</p>} throttleMs={150}>
          <SlowData id={id} delay={id === 'A' ? 100 : 800} />
        </MiniSuspense>
      </div>
    </MiniRoot>
  );
}
⭐⭐ 这个实现复现了 Suspense 的三个关键特性:

① 【throw thenable = 挂起】
   `if (entry.status === 'pending') throw entry.promise;`
   → 边界捕获 → 显示(或延迟显示)fallback

② 【ping + 重试】
   promise resolve 后调用 retry → 触发一次 transition 更新 → 子组件重新渲染
   (真实 React 是「给车道打 pinged 标记 → 调度重试」)

③ 【节流 + transition 下不显示 fallback】
   · throttleMs:延迟显示 fallback,避免「一闪而过」
   · isTransition:transition 中挂起 → 直接不显示 fallback

⚠️ 与真实实现的差别:
   · 真实 React 在 reconciler 内部捕获(不是靠 ErrorBoundary)
   · 真实 React 用 Offscreen「保留 DOM 并隐藏」,这里只是「保留上一帧的元素」
   · 真实 React 会「丢弃半成品子树」,这里有更粗糙的重试
   · 真实的优先级与车道机制比「一个 boolean」复杂得多

七、本篇特有的坑

// ① 以为「Suspense 加个延迟就能避免骨架闪烁」
<Suspense fallback={<Skeleton />}>…</Suspense>          // ⚠️ 延迟只是掩盖
// ✅ 正确做法:把「切换」放进 transition(startTransition / useTransition)

// ② 在 transition 外的普通 setState 触发挂起 → 一定显示 fallback
const handleTab = (t) => setTab(t);                     // ⚠️ Tab 切换会闪骨架
const handleTab2 = (t) => startTransition(() => setTab(t));   // ✅ 不闪

// ③ 让「数据源」每次渲染都新建 promise → 无限挂起
throw fetchData();                                       // ❌ 每次渲染都抛新 promise
throw cachedResource.read();                              // ✅ 用缓存/资源对象

// ④ 把「真错误」当成「挂起」抛(Suspense 只认 thenable)
throw new Error('x') 在 Suspense 边界内 → 不会显示 fallback,
必须由 ErrorBoundary 接(见 8.3)

// ⑤ 把 ErrorBoundary 放在 Suspense 内层(顺序反了)
<Suspense fallback={…}><ErrorBoundary fallback={…}>…</ErrorBoundary></Suspense>
// ⚠️ 语义混乱(错误 UI 会被当成「未就绪的内容」)
// ✅ ErrorBoundary 在外、Suspense 在内

// ⑥ 在「Suspense 边界之外」读一个会挂起的资源
function App() {
  const data = use(promise);          // ❌ 没有 Suspense 包裹 → 报错「A component suspended…」
  return <div>{data}</div>;
}

// ⑦ 用 Suspense 包「不是真正异步」的东西(同步组件永远不会挂起)
<Suspense fallback={<Skeleton />}><SyncComponent /></Suspense>   // ⚠️ fallback 永不出现

// ⑧ 「多个边界」都放同一个 fallback → 用户看到「骨架海」
// ✅ 合并「同时就绪」的内容到同一个边界,或给每个边界更贴合的骨架

// ⑨ 依赖「Suspense 一定保留子组件 state」
// ⚠️ 挂起时「已经渲染的部分」是隐藏而非卸载,所以 state 保留(这点是对的);
//    但如果「挂起发生在首次渲染」→ 没有 state 可保留

// ⑩ 在 SSR 中期待「transition 能避免骨架」
// ⚠️ 服务端总是先输出 fallback(HTML 流必须有序)→ 首屏一定有骨架

// ⑪ 用 Suspense 做「错误处理」
// ⚠️ 它只处理「等待」;错误要 ErrorBoundary(Promise reject 会抛错)

// ⑫ 忘记「挂起的 promise 会被『多次订阅』」
// ⚠️ 同一次渲染里多个组件读同一个资源 → 要给「同一个 promise」挂多次监听
//    (真实 React 用 ping 去重;手写实现要注意「只 retry 一次」)

// ⑬ 在「Suspense 边界内」做「必须同步完成的事」(如测量布局)
// ⚠️ 挂起时这部分不会执行 → 依赖它的逻辑要放在边界之外或等就绪

// ⑭ 认为「Suspense 能提升性能」
// ⚠️ 它不减少工作量;它改善的是「感知」与「调度粒度」(让慢的部分不阻塞快的)

// ⑮ 在「非并发模式」(legacy ReactDOM.render)下期待新语义
// ⚠️ transition 与 Suspense 的很多行为依赖 createRoot
// ③ 的完整修复:把 promise 缓存起来(资源模式)
const resourceCache = new Map<string, Promise<Data>>();

function getResource(id: string): Promise<Data> {
  let p = resourceCache.get(id);
  if (!p) {
    p = fetchData(id);
    resourceCache.set(id, p);
  }
  return p;                                  // ⭐ 同一个 id 永远返回同一个 promise
}

function Profile({ id }: { id: string }) {
  const data = use(getResource(id));          // ✅ promise 稳定
  return <div>{data.name}</div>;
}
// ⑫ 的说明:手写实现要避免「重复 retry」
private retry = () => {
  // ⭐ 用「是否已挂起」作为去重标记:一次挂起只对应一次 retry
  if (!this.state.suspended) return;
  this.context.ping(() => this.setState({ suspended: false, showFallback: false }));
};

八、面试延伸

  1. 「Suspense 的挂起机制是什么?」
    唯一的协议是「组件在渲染期间抛出一个 thenable」。React 捕获它(不是当作错误,而是「挂起信号」),然后:① 找到最近的 Suspense 边界,决定显示 fallback 还是保留旧内容;② 把这个 promise 登记为「挂起」(标记 suspendedLanes)并挂一个完成回调(ping);③ promise resolve 时通过 pingedLanes 把信号接回 React 的调度系统,触发重试渲染。所以「挂起」本质是「一个能被调度系统处理的外部等待信号」。

  2. 「什么时候显示 fallback,什么时候不显示?」
    关键规则:取决于「这次更新是不是 transition」。① 是 transition(startTransition / useTransition / useDeferredValue)→ React 不显示 fallback,而是继续显示之前的内容,直到新内容就绪后一次性切换;② 不是 transition(普通 setState、首次渲染)→ 显示 fallback。设计理由是「用户主动触发的交互应该立刻有反馈,而『切换 Tab / 筛选』这类『意图明确、结果未就绪』的场景,保留旧内容比闪骨架更连贯」。

  3. 「为什么要『节流』?」
    因为「挂起时间很短」时显示 fallback 会一闪而过(内容 → 骨架 → 内容),体验比「什么都不显示」更差。所以 React 有一套节流策略:短暂挂起(在阈值内)不显示 fallback(表现为「内容稍晚一点更新」)。注意:节流是「避免闪烁的兜底」,而避免切换闪烁的正确手段是「把更新放进 transition」——两者是不同层面的东西。

  4. 「suspendedLanes 和 pingedLanes 是什么?」
    它们是「挂起状态在调度系统里的表示」:① suspendedLanes——记录「哪些优先级的更新被挂起阻塞了」(在它们对应的 promise 完成前,React 不会白白重试它们);② pingedLanes——当某个挂起的 promise 完成时,把对应车道标记为「已 ping」,从而允许 React 重试这条车道的渲染。这一对标记是「把外部的异步信号翻译成 React 内部的调度语言」的桥梁。

  5. 「多个 Suspense 边界怎么决定揭示顺序?」
    默认情况下「各自独立」——谁先就绪谁先显示,可能出现「参差不齐」的观感。要控制顺序可以用 <SuspenseList revealOrder="forwards | backwards | together">:forwards 按顺序揭示、together 全部就绪才一起显示、backwards 从后往前。但 SuspenseList 目前仍是实验特性,实践中的替代手段是「把『需要一起显示』的内容放进同一个 Suspense 边界」(同一边界内会一起等)。

  6. 「SSR 的 Suspense 和客户端的有什么不同?」
    关键差异是「服务端总是先输出 fallback 的 HTML」——因为 HTML 流必须有序,服务端没有「保留旧内容」这个选项(首次访问也没有旧内容)。而客户端的挂起「可能不显示 fallback」(transition 场景,保留已渲染的 DOM)。推论:在 SSR 下,transition 不能避免首屏骨架出现(首屏本来就要给骨架),它主要改善的是「客户端交互/导航」时的骨架闪烁。此外服务端的慢边界会「后水合」(渐进式水合)。

  7. 「Suspense 和 ErrorBoundary 的关系?」
    两者分工明确,且顺序固定:ErrorBoundary 在外、Suspense 在内。① Suspense 处理「等待」——挂起时显示 fallback(或保留旧内容);② ErrorBoundary 处理「错误」——use(promise) 的 promise reject 时会抛错,由 ErrorBoundary 接管。如果顺序颠倒(Suspense 在外、ErrorBoundary 在内),ErrorBoundary 的错误 UI 会被外层当成「正在加载的内容」,语义混乱且可能一直显示 fallback。

  8. 「为什么说『Suspense 不提升性能』?」
    因为它不减少工作量——该渲染的还要渲染、该请求的还要请求。它改善的是两件事:① 感知(避免「内容 → 骨架 → 内容」的闪烁,尤其在 transition 下);② 调度粒度(让「慢的部分」不阻塞「快的部分」——服务端可以流式先发 shell(见 9.6),客户端可以「只挂起那一块」而不是整页)。所以它的价值是「让等待更可接受 + 让渲染更可分割」,而不是「让渲染更快」。

一句话速记

Suspense 的唯一协议是「渲染期间 throw thenable」——React 把它当「挂起信号」而不是错误;挂起后 React 会「登记 suspendedLanes + 给 promise 挂 ping 回调」,promise 完成后通过 pingedLanes 接回调度系统并重试渲染;是否显示 fallback 取决于「这次更新是不是 transition」——transition 中的挂起会保留旧内容(不显示 fallback),非 transition 才显示 fallback(这就是「Tab/路由切换不闪骨架」的正确写法);节流只是「短时间挂起不显示 fallback」的兜底;多个边界默认各自独立揭示,可用 SuspenseList(实验)控制 forwards/backwards/together;SSR 下总是先输出 fallback(HTML 流必须有序,所以 transition 救不了首屏骨架);ErrorBoundary 在外、Suspense 在内(分别管「错误」与「等待」);它不提升性能,只改善「感知」与「调度粒度」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.16 Suspense 的调度语义

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫