陌上人如玉
公子世无双

11.12 手写 Suspense

11.12 手写 Suspense

一句话:Suspense 的最小实现只有四步——组件在渲染中 throw 一个 thenable → 渲染器捕获它、向上找到最近的边界 → 把「正在等的 promise」记在边界上并让本次渲染作废(重渲染时边界就渲染 fallback)→ promise resolve 时「ping」一次触发重渲染;再补一个「节流」就是可用版本。

⚠️ 前置:本篇建立在 11.11 手写 mini React 的实现之上(createElement / workLoop / commit / Hook)。下面只写「为了支持 Suspense 需要增加与改动」的部分。

一、协议:挂起的唯一形态

// ✅ 挂起 = 渲染期间「抛出 thenable」
//    注意:不是「返回 loading 状态」,也不是「抛 Error」

function Comments({ resource }) {
  const comments = resource.read();      // ⭐ 未就绪时,这里会「抛出一个 promise」
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

/** 一个最小的挂起资源(缓存 + 状态机) */
function createResource(promiseFactory) {
  let status = 'pending';                // 'pending' | 'success' | 'error'
  let result;
  let error;
  let suspender;

  return {
    read() {
      if (status === 'pending') {
        // ⭐⭐ 关键:每次都抛「同一个 thenable」——
        //    如果每次抛新的 promise,渲染器会「永远认为在等新东西」→ 死循环
        suspender ??= promiseFactory().then(
          (v) => { status = 'success'; result = v; },
          (e) => { status = 'error'; error = e; },
        );
        throw suspender;
      }
      if (status === 'error') throw error;      // ⭐ 真错误 → 交给错误边界
      return result;
    },
  };
}

/** 判断是不是 thenable(Suspense 只认它) */
function isThenable(value) {
  return (
    value !== null &&
    (typeof value === 'object' || typeof value === 'function') &&
    typeof value.then === 'function'
  );
}
⭐⭐ 三个「协议层面」的硬约束(每个都对应一个真实的坑):

① 【必须抛「同一个」promise】
   如果每次渲染都 `throw fetchData()`(新建 promise),
   渲染器会认为「又在等新的东西」→ **永远显示 fallback**(死循环)。
   ✅ 用「资源对象」缓存 promise(上面的 `suspender ??=`)。

② 【只能抛 thenable,不能抛 Error】
   抛 Error 会被当成「真错误」(交给 ErrorBoundary,见 8.3),
   所以「数据加载失败」要在「资源内部」把 reject 转成 `throw error`(第二次 read 时才抛 error)。
   实现上就是上面的「status = 'error' → throw error」。

③ 【挂起是「渲染期」的行为,所以渲染必须纯】
   挂起会让 React「丢弃这次渲染」,如果渲染里有副作用就会**重复执行**。

二、渲染器改造:捕获挂起 + 让本次渲染作废

// ============ 改动 1:performUnitOfWork 加 try/catch ============
// 在 11.11 的 performUnitOfWork 外面包一层,捕获「抛出的 thenable」

let abandoned = false;       // ⭐ 本次渲染是否因「挂起」而作废

function performUnitOfWork(fiber) {
  try {
    if (typeof fiber.type === 'function') {
      // ⭐ Suspense 是一种特殊的组件(type 是标记对象)
      if (fiber.type === SUSPENSE) updateSuspenseComponent(fiber);
      else updateFunctionComponent(fiber);
    } else {
      updateHostComponent(fiber);
    }
  } catch (thrown) {
    if (isThenable(thrown)) {
      handleSuspend(fiber, thrown);          // ⭐ 挂起
      return null;                           // 中断本次遍历
    }
    throw thrown;                            // 真错误 → 交给错误边界(见 8.3)
  }

  if (fiber.child) return fiber.child;
  let next = fiber;
  while (next) {
    if (next.sibling) return next.sibling;
    next = next.parent;
  }
  return null;
}
// ============ 改动 2:挂起处理 ============

/**
 * ⭐⭐ 挂起的完整处理(对应真实 React 的:
 *     attachPingListener + markSuspendedPriorityLevel + 「丢弃 wip 树」)
 */
function handleSuspend(fiber, promise) {
  // ① 向上找「最近的 Suspense 边界」
  let boundary = fiber;
  while (boundary && boundary.type !== SUSPENSE) boundary = boundary.parent;

  if (!boundary) {
    // ⭐ 没有边界:这是「真错误」——
    //    真实 React 会报 "A component suspended while responding to synchronous input"
    throw promise;
  }

  // ② ⭐ 关键:把「等待记录」挂到【已提交的那棵树】上的边界 fiber
  //    因为 wip 树马上要被丢弃,挂在它上面会丢
  const liveBoundary = boundary.alternate ?? boundary;
  liveBoundary.pending ??= new Set();

  if (!liveBoundary.pending.has(promise)) {
    liveBoundary.pending.add(promise);

    // ③ ⭐ ping:promise 完成时「移除等待 + 触发一次重渲染」
    promise.then(
      () => {
        liveBoundary.pending.delete(promise);
        triggerRerender(liveBoundary);        // 重渲染(此时 pending 为空 → 渲染 children)
      },
      (err) => {
        // 挂了:移除等待,让下次渲染把错误抛出来(交给错误边界)
        liveBoundary.pending.delete(promise);
        liveBoundary.failed = err;
        triggerRerender(liveBoundary);
      },
    );

    // ④ ⭐ 节流:如果「这个边界之前从未显示过内容」或已超过阈值,
    //    就立刻显示 fallback;否则等一小会儿(见第三节)
    liveBoundary.suspendedAt = performance.now();
  }

  // ⑤ ⭐⭐ 让本次渲染作废:以 currentRoot 为基础重新开始渲染
  //    (重渲染时,边界会看到 pending 非空 → 渲染 fallback 而不是 children)
  abandoned = true;
}
// ============ 改动 3:workLoop 处理「作废」与重渲染 ============

function workLoop() {
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // ⭐ 本次渲染作废 → 丢弃 wip 树,改以「当前已提交的树」为基础重新渲染
  if (abandoned) {
    abandoned = false;
    deletions = [];
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    scheduleWork();
    return;
  }

  if (nextUnitOfWork) {
    scheduleWork();
  } else if (wipRoot) {
    commitRoot();
  }
}

/** ⭐ ping 之后的重渲染入口(与 setState 的重渲染是同一件事) */
let rerenderQueued = false;

function triggerRerender(/* fromFiber */) {
  if (rerenderQueued) return;
  rerenderQueued = true;

  // ⭐ 官方实现会把「ping 的车道」标记为可重试(见 4.16),
  //    这里简化成「下一帧重渲染」(等价于「低优先级重试」)
  requestAnimationFrame(() => {
    rerenderQueued = false;

    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    deletions = [];
    nextUnitOfWork = wipRoot;
    frameStart = performance.now();
    scheduleWork();
  });
}

三、Suspense 组件:边界状态机

// ============ 改动 4:Suspense 组件本体 ============

/** ⭐ 用一个不可伪造的标记对象当「组件类型」(不用字符串,避免与用户组件撞名) */
export const SUSPENSE = Symbol.for('mini-react.suspense');

/**
 * 用法:h(SUSPENSE, { fallback: h(Skeleton, null), throttleMs: 200 }, children)
 * 它是一个「特殊组件」:type 不是函数,而是这个 Symbol
 */
function updateSuspenseComponent(fiber) {
  const live = fiber.alternate ?? fiber;

  const pending = live.pending ?? new Set();
  const failed = live.failed;
  const throttleMs = fiber.props.throttleMs ?? 200;

  // ⭐① 有「失败」→ 抛出错误(交给错误边界,本实现里先简单抛出)
  if (failed) {
    live.failed = null;
    throw failed;
  }

  // ⭐② 是否「需要显示 fallback」
  let showFallback = pending.size > 0;

  // ⭐ 节流:挂起后「延迟 throttleMs 才显示 fallback」,避免一闪而过
  if (showFallback && live.suspendedAt) {
    const elapsed = performance.now() - live.suspendedAt;
    if (elapsed < throttleMs) {
      showFallback = false;

      // 到点后触发一次重渲染(否则永远停在「什么都不显示」的状态)
      const remaining = throttleMs - elapsed;
      const timerKey = `${elapsed}-${remaining}`;
      if (live.throttleTimerKey !== timerKey) {
        live.throttleTimerKey = timerKey;
        setTimeout(() => {
          live.suspendedAt = 0;
          live.throttleTimerKey = null;
          triggerRerender();
        }, remaining);
      }
    }
  }

  // ⭐③ 清掉「过期」的等待记录(promise 已完成但 we 还没处理的情况)
  if (!showFallback) {
    live.suspendedAt = 0;
  }

  // ⭐④ 渲染 fallback 或 children
  const child = showFallback ? fiber.props.fallback : fiber.props.children;
  const elements = (Array.isArray(child) ? child : [child]).filter(Boolean);

  reconcileChildren(fiber, elements);
}
⭐⭐⭐ 这个状态机的「三个分支」与真实 React 的对照:

   【pending 为空】→ 渲染 children
      真实 React:没有 suspended lanes → 正常渲染子树

   【pending 非空 + 未到节流阈值】→ 渲染「nothing」(本实现)或「旧内容」(真实 React)
      ⭐ 真实 React 在这里用 **Offscreen** 把「已经存在的 children DOM 隐藏起来」
        (而不是「什么都不渲染」)——这就是「保留旧内容」的机制(见 4.13)
      ⚠️ 我的简单实现里「什么都不渲染」就等于「内容消失」,体验更差
      ✅ 改进方式见下方「保留旧内容」

   【pending 非空 + 已过阈值】→ 渲染 fallback
      真实 React:显示 fallback(并把旧的子树隐藏)

   【失败】→ 抛错
      真实 React:把错误抛给最近的 ErrorBoundary(见 8.3)
      ⭐ 注意「promise reject 会变成 throw error」——
        这就是「Suspense 管等待、ErrorBoundary 管错误」的分工

四、进阶:实现「保留旧内容」(对应 Offscreen)

/**
 * ⭐⭐ 「保留旧内容」的正确做法:
 *    当边界「已经成功渲染过 children」时,挂起不应该让内容消失,
 *    而应该「保留已有 DOM、只是隐藏它」(真实 React 用 Offscreen 机制)。
 *
 *    在 mini 实现里的两种做法:
 */

/** ============ 做法 A(推荐):用一个「隐藏但不卸载」的包裹节点 ============ */
function updateSuspenseWithOffscreen(fiber) {
  const live = fiber.alternate ?? fiber;
  const hasRenderedChildren = Boolean(live.hasRenderedChildren);
  const pending = live.pending ?? new Set();
  const showFallback = pending.size > 0;

  // ⭐ 边界永远渲染「同一个结构」:[内容槽][fallback 槽]
  //    用 CSS 控制「谁可见」,从而做到「保留 DOM 但不显示」
  const content = fiber.props.children;
  const fallback = fiber.props.fallback;

  const elements = [
    h(
      'div',
      {
        // ⭐ 用 hidden 属性(浏览器会同时处理「不可见」与「不可聚焦」)
        hidden: showFallback && hasRenderedChildren,
        style: { display: showFallback && hasRenderedChildren ? 'none' : 'contents' },
        'data-mini-suspense-content': true,
      },
      ...(Array.isArray(content) ? content : [content]),
    ),
    // fallback 只在「需要显示」时渲染(避免它自己触发挂起)
    showFallback && !hasRenderedChildren
      ? h('div', { 'data-mini-suspense-fallback': true },
          ...(Array.isArray(fallback) ? fallback : [fallback]))
      : null,
  ].filter(Boolean);

  reconcileChildren(fiber, elements);

  // 记录「是否已经渲染过 children」(用于决定「挂起时保留还是显示 fallback」)
  live.hasRenderedChildren = !showFallback || hasRenderedChildren;
}

/** ============ 做法 B(更贴近真实 React):不渲染 children,只隐藏旧 DOM ============ */
// 在 commit 阶段,对「有 pending 的边界」执行:
//   boundaryDom.hidden = true            // 隐藏已提交的旧内容
// 并在 ping 之后把它恢复。
// ⭐ 这就是「Offscreen / Activity」的核心(见 4.13):DOM 不卸载,只是不可见 + 暂停行为。
⭐⭐ 「保留旧内容」带来的一个**重要副作用**(真实 React 也有):

   既然旧 DOM 没被卸载,那么「旧组件的 state 也被保留」——
   ⭐ 这正是「Activity / Offscreen」的核心价值:
     切换 Tab 回来时「输入框内容还在、滚动位置还在」
     (见 4.13 的 `mode="hidden"` 语义)

   ⚠️ 但要注意:这**不是**「无条件保留」——
     如果边界是「首次挂起」(没有旧内容),就只能显示 fallback。

五、节流:为什么不能「立刻显示 fallback」

⭐⭐⭐ 节流(throttle)的动机与实现:

   【动机】挂起时间「很短」时(比如 30ms),如果立刻显示 fallback:
     内容 → 骨架 → 内容  ← 用户看到「闪一下」,比「什么都不显示」更差
   【做法】挂起后「先不显示 fallback」,等 `throttleMs`(如 200ms):
     · 若 promise 在阈值内完成 → 用户完全看不到骨架(只感觉「稍微晚了一点」)
     · 若超过阈值 → 显示骨架(说明这次真的慢)

   ⭐ 判据(什么时候该调大/调小 throttleMs):
     · 网络快、后端稳定 → 可以调大(如 300ms),少闪骨格
     · 网络慢、后端波动大 → 调小(如 100ms),让用户尽早看到「正在加载」
     · ⭐ 更根本的解法是「把这次更新放进 transition」——
       那样**根本不显示 fallback**(见 4.16、下方第六节)
// ⭐ 一个「可配置 + 可观测」的节流实现(比上面内联的更清晰)
const THROTTLE_DELAY = 200;      // ms

function updateSuspenseThrottled(fiber) {
  const live = fiber.alternate ?? fiber;
  const pending = live.pending ?? new Set();

  if (pending.size === 0) {
    live.phase = 'ready';
    reconcileChildren(fiber, normalize(fiber.props.children));
    return;
  }

  const now = performance.now();

  if (live.phase === 'ready' || live.phase === undefined) {
    // ⭐ 刚挂起:进入「未定」状态(既不显示旧内容也不显示 fallback)
    live.phase = 'pending-silent';
    live.suspendedAt = now;

    // 到点后切到「显示 fallback」
    setTimeout(() => {
      if (live.pending?.size > 0 && live.phase === 'pending-silent') {
        live.phase = 'pending-fallback';
        triggerRerender();
      }
    }, THROTTLE_DELAY);
  }

  if (live.phase === 'pending-silent') {
    // ⭐ 静默期:不渲染 fallback(真实 React 在这里显示「旧的 children」)
    reconcileChildren(fiber, []);            // 简单实现:什么都不渲染
    return;
  }

  // 超过阈值 → 显示 fallback
  reconcileChildren(fiber, normalize(fiber.props.fallback));
}

function normalize(x) {
  return (Array.isArray(x) ? x : [x]).filter(Boolean);
}

六、完整实现:一个可直接跑的 Suspense Demo

// ============ 组装:在 11.11 的基础上,导出 Suspense 与 createResource ============
import { createElement as h, render } from './mini-react';
export { SUSPENSE as Suspense };

/** 模拟「慢数据源」(带缓存,保证同一 id 返回同一个 promise) */
const cache = new Map();

export function fetchSlow(id, delayMs) {
  let entry = cache.get(id);
  if (!entry) {
    const promise = new Promise((resolve) => {
      setTimeout(() => resolve({ id, text: `「${id}」的数据(耗时 ${delayMs}ms)` }), delayMs);
    });
    entry = createResource(() => promise);
    cache.set(id, entry);
  }
  return entry;
}

function SlowPanel({ id, delay }) {
  const resource = fetchSlow(id, delay);
  const data = resource.read();              // ⭐ 未就绪 → 挂起
  return h('div', { style: { padding: 12, background: '#eef', borderRadius: 6 } }, data.text);
}

function Skeleton() {
  return h('div', {
    style: {
      padding: 12, borderRadius: 6, background: '#eee',
      color: '#999', fontSize: 12,
    },
  }, '加载中…');
}
// ============ Demo:三个按钮切换「快 / 慢 / 更快」的数据 ============
function App() {
  const [id, setId] = useState('A');
  const [label, setLabel] = useState('A');

  const delays = { A: 60, B: 900, C: 30 };

  return h(
    'div',
    { style: { fontFamily: 'system-ui', padding: 16 } },

    h('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
      ...['A', 'B', 'C'].map((key) =>
        h('button', {
          key,
          onClick: () => { setId(key); setLabel(key); },
          style: { padding: '6px 12px', cursor: 'pointer' },
        }, `切到 ${key}(${delays[key]}ms)`)
      )
    ),

    // ⭐⭐⭐ Suspense 边界(用标记 Symbol 作为 type)
    h(SUSPENSE, {
      fallback: h(Skeleton, null),
      throttleMs: 200,                       // ⭐ 节流:200ms 内完成就不闪骨架
    }, h(SlowPanel, { id, delay: delays[id] })),
  );
}

render(h(App, null), document.getElementById('root'));
⭐⭐ 跑起来你会观察到四个「Suspense 的特征行为」(这就是它的全部价值):

① 【切到 A(60ms)】→ ⭐ 几乎看不到骨架
   因为 60ms < throttleMs(200ms) → 在「静默期」内 promise 就完成了 → 不闪

② 【切到 B(900ms)】→ 200ms 后显示骨架
   超过阈值 → 显示 fallback → 等 promise resolve → 渲染真实内容

③ 【切回 A(已缓存)】→ 瞬间显示(无骨架)
   资源对象缓存了 promise/success 状态 → `read()` 直接返回结果 → 不挂起

④ 【快速连点 A→B→C】→ 每个边界「各自等待」
   如果 B 还没回来你就点了 C,C 会立刻(30ms 后)显示——
   ⭐ 这是「各边界独立揭示」的默认行为(见 4.16 的 revealOrder)
⭐⭐⭐ 这个实现「与真实 React 的差异」(理解差异才算真懂):

① 【真实 React 会「丢弃 wip 树」并用「更高优先级重渲染」】
   我的实现是「作废 → 以 currentRoot 重渲染(低优先级)」,
   真实 React 有「车道」概念:挂起只标记对应车道,
   其它车道的工作**不会被丢弃**(可以继续渲染别的部分)。

② 【真实 React 用 Offscreen「保留旧内容」】
   我的简单版是「静默期什么都不渲染」;
   真实 React 会「把已有内容隐藏(不卸载)」→ 用户看到的是「旧内容 + 稍后更新」
   ⭐ 这是「Tab 切换不闪骨架」的根本机制。

③ 【真实 React 的 ping 是「把车道标记为可重试」】
   我的实现是「直接触发一次全量重渲染」。
   真实 React 用 `pingedLanes` 把「外部信号」接回调度系统(见 4.16)。

④ 【真实 React 区分「同步/过渡」两种挂起】
   transition 里的挂起「不显示 fallback」(见 4.16)。
   我的实现忽略了优先级——所以「切 Tab 会闪骨架」,
   而真实 React 只要把切换放进 `startTransition` 就不会闪。
// ⭐ 想在 mini 实现里也做到「transition 不显示 fallback」,只需加一个开关:
let inTransition = false;

export function startTransition(fn) {
  inTransition = true;
  try { fn(); } finally { inTransition = false; }
}

// 然后在 updateSuspenseThrottled 里:
if (inTransition) {
  // ⭐ 过渡更新中挂起 → 永不显示 fallback(保留旧内容 / 静默)
  reconcileChildren(fiber, live.phase === 'ready' ? normalize(fiber.props.children) : []);
  return;
}

七、本篇特有的坑

// ① 每次渲染都抛「新的 promise」
function Bad({ id }) {
  const data = use(fetchData(id));         // ❌ 如果 fetchData 每次都返回新 promise…
}
// ⭐ 后果:渲染器认为「一直在等新东西」→ 永远显示 fallback
// ✅ 用「资源对象」缓存 promise(同一 id → 同一 promise)

// ② 忘了「挂起记录要挂在『已提交的树』上」
liveBoundary = boundary;                   // ❌ boundary 属于「马上要被丢弃的 wip 树」
liveBoundary = boundary.alternate ?? boundary;   // ✅

// ③ 挂起后「不重渲染」(只标记了 pending,却没触发第二次渲染)
// ⭐ 后果:界面停在「什么都不显示」的状态
// ✅ handleSuspend 结束时「让本次渲染作废 + 重新开始」

// ④ ping 的 then 里没有清理 pending
promise.then(() => triggerRerender());     // ❌ pending 永远非空 → 永远显示 fallback
// ✅ 先 `pending.delete(promise)` 再触发重渲染

// ⑤ 没有「去重」(同一个 promise 被多个组件 read)
// ⭐ 后果:pending 里塞了 N 份,ping 多次(还好),但如果用的是「新建 promise」就崩了
// ✅ 用 Set(同一引用只记一次)

// ⑥ 把「真错误」也当成挂起
catch (e) { if (e instanceof Error) handleSuspend(...) }   // ❌
// ✅ 只有 isThenable(e) 才是挂起;Error 必须继续向上抛(交给 ErrorBoundary)

// ⑦ 没有 Suspense 边界时「静默失败」
// ⚠️ 真实 React 会抛一个明确的错误:
//    "A component suspended while responding to synchronous input."
// ✅ 我的实现里 `throw promise`(或抛一个明确错误)

// ⑧ fallback 自己也会挂起(嵌套挂起)
h(Suspense, { fallback: h(AlsoSuspends) }, children)   // ⚠️ fallback 挂起 → 继续向上找边界
// ✅ 保证 fallback 是「同步可渲染」的(骨架/文本)

// ⑨ 节流计时器没有清理(组件卸载后仍在跑)
setTimeout(...) 没有保存 id                          // ❌ 泄漏 + 卸载后 setState
// ✅ 把 timer 存在 fiber 上,在 commitDeletion 时 clearTimeout(见 5.13)

// ⑩ 节流「没有重置」
// ⚠️ 第一次挂起等了 200ms 才显示骨架;第二次挂起(另一个 promise)
//    如果没有重置 suspendedAt,会「立刻显示骨架」(因为 elapsed 已经很大)
// ✅ 每次「从 ready → pending」时都要重置

// ⑪ 在「静默期」什么都不渲染 → 内容消失(比显示骨架更差)
// ✅ 真实 React 保留旧内容(Offscreen);简单实现至少可以「保留上一次的 elements」

// ⑫ 「资源对象」缓存了「失败状态」但没有「失效机制」
// ⚠️ 用户点「重试」时 read() 会一直抛同一个 error
// ✅ 提供 `resource.reset()`(清掉 promise/status,下次 read 重新请求)

// ⑬ 忘了「挂起要在渲染期,不能在 Effect 里」
useEffect(() => { throw promise; }, []);    // ❌ Effect 里抛不会触发 Suspense
// ⭐ Suspense 只认「渲染期间抛出」

// ⑭ 用 Symbol 之外的「字符串 type」标记 Suspense(可能撞名)
h('Suspense', {...})                        // ⚠️ 与「用户真的想渲染 <Suspense> 标签」冲突
// ✅ 用 Symbol(或用函数的引用相等性,如 `fiber.type === SuspenseComponent`)

// ⑮ 认为「Suspense 能减少请求」
// ⚠️ 它不减少请求,只是「让等待这件事可被调度」——
//    真正的「请求提前」要靠「在渲染之前发起」(如路由 loader 预取、hover 预取)

// ⑯ 挂起后「不停重试」导致请求风暴
// ⚠️ 如果「重渲染 → 再挂起 → 再重渲染」没有收敛,会打爆接口
// ✅ 保证「每次挂起只对应一次 ping」,且「同一个 promise 只 attach 一次」

// ⑰ 忘了「边界卸载时要移除 pending 上的监听」
// ⚠️ 组件卸载后 promise resolve → 仍然触发重渲染(浪费 + 可能报错)
// ✅ 在 liveBoundary 上打「已卸载」标记,ping 时检查
// ⑨ 的完整处理(节流计时器随边界生命周期回收)
function updateSuspenseThrottled(fiber) {
  const live = fiber.alternate ?? fiber;
  // ...
  if (live.throttleTimer) clearTimeout(live.throttleTimer);
  live.throttleTimer = setTimeout(() => {
    live.throttleTimer = null;
    if (live.pending?.size > 0) {
      live.phase = 'pending-fallback';
      triggerRerender();
    }
  }, THROTTLE_DELAY);
}

// 并在 commitDeletion 里回收:
function commitDeletion(fiber, domParent) {
  // ⭐ 清理挂在这个 fiber 上的计时器与等待记录
  if (fiber.throttleTimer) clearTimeout(fiber.throttleTimer);
  fiber.pending?.clear();
  // ...原有删除逻辑
}
// ⑰ 的完整处理(ping 时检查「边界是否还活着」)
promise.then(() => {
  liveBoundary.pending?.delete(promise);
  if (liveBoundary.unmounted) return;       // ⭐ 已卸载 → 不再触发渲染
  triggerRerender();
});
// 在 commitDeletion 里:fiber.unmounted = true

八、面试延伸

  1. 「手写一个 Suspense 的核心是什么?」
    核心是「一个跨阶段的信号协议」:① 组件在渲染期间 throw 一个 thenable(必须每次抛同一个,否则会死循环);② 渲染器捕获它,向上找最近的 Suspense 边界,把「正在等的 promise」记在已提交树上的那个边界 fiber 上(因为 wip 树马上要被丢弃);③ 让本次渲染作废,以 currentRoot 重新渲染——此时边界看到「有 pending」就渲染 fallback(而不是 children);④ promise resolve 时 ping(先从 pending 删除,再触发一次重渲染),此时边界渲染 children。再补一个节流就是可用版本。

  2. 「为什么必须抛『同一个 promise』?」
    因为渲染器的逻辑是「这次渲染挂起了,我记下这个 promise,等它完成再重试」。如果你每次都 throw fetchData()(新建 promise),那么:① 每次重试都会得到新 promise → 渲染器认为「还在等新的东西」→ 永远显示 fallback;② 边界上的 pending 会无限累积;③ 会对后端发起请求风暴(每次渲染一个请求)。所以必须用「资源对象」把 promise 缓存起来(同一 key → 同一 promise)。

  3. 「挂起后『保留旧内容』是怎么实现的?」
    靠 Offscreen / Activity 机制:React 会把「已经渲染出来的那部分子树」隐藏而不是卸载——DOM 还在(只是不可见、不可聚焦),组件的 state 也保留。这样挂起时用户看到的是「旧内容」(稍后会被新内容替换),而不是「内容消失 + 骨架」。⭐ 这带来一个重要副作用:旧组件的 state 也被保留,这正是「Activity 切走再切回来,输入框内容和滚动位置还在」的原因(见 4.13)。注意:如果边界是「首次挂起」(没有旧内容),就只能显示 fallback。

  4. 「为什么要『节流』?怎么决定阈值?」
    动机是避免「一闪而过」——挂起时间很短时(如 30ms),如果立刻显示 fallback,用户会看到「内容 → 骨架 → 内容」,比「什么都不显示」更差。做法是挂起后先静默一段时间(默认约 200ms):阈值内 promise 完成了 → 用户完全看不到骨架;超过阈值 → 显示骨架(说明这次真慢)。阈值选择:网络快、后端稳定 → 可以调大(300ms,少闪);网络慢、后端波动大 → 调小(100ms,尽早反馈)。⭐ 但更根本的解法是「把这次更新放进 transition」——那样根本不显示 fallback(见下一条)。

  5. 「transition 和 Suspense 有什么关系?」
    关系是「transition 决定了『挂起时显不显示 fallback』」:如果挂起来自 transition(startTransition/useTransition/useDeferredValue)→ React 不显示 fallback,而是继续显示之前的内容直到新内容就绪;如果不是 transition(普通 setState、首次渲染)→ 显示 fallback。所以「Tab/路由切换不闪骨架」的正确写法不是「给 Suspense 加延迟」,而是「把这次更新放进 startTransition」(见 4.16)。在 mini 实现里只需加一个 inTransition 开关:过渡中的挂起直接「保留旧内容」。

  6. 「Suspense 和错误边界怎么配合?」
    分工明确、顺序固定:ErrorBoundary 在外、Suspense 在内。① Suspense 管「等待」——渲染期抛出 thenable → 显示 fallback(或保留旧内容);② ErrorBoundary 管「错误」——如果资源 reject,第二次 read() 时应该 throw error,由 ErrorBoundary 接管。⭐ 所以「资源的实现」必须区分两种抛出:第一次抛 promise(挂起),第二次抛 error(失败)。如果顺序颠倒(Suspense 在外、ErrorBoundary 在内),ErrorBoundary 的错误 UI 会被当成「正在加载的内容」,语义混乱。

  7. 「挂起时『作废整棵树』是不是太浪费了?」
    是——真实 React 不做这么粗暴的事。它用 Lane(车道) 精确标记「哪些车道被挂起了」,然后:① 只把被挂起的那部分工作作废,其它车道(如更高优先级的输入更新)可以继续;② ping 回来时用 pingedLanes 让对应车道可重试。⭐ 所以在真实实现里,「挂起」的代价是「这棵子树的重渲染延迟」,而不是「整棵应用重来」。这也是「为什么需要 Lane 模型」的一个核心动机(见 4.8、4.17)。

  8. 「你的 mini Suspense 用在生产会有什么问题?」
    五个:① 没有优先级——挂起会作废整棵树重来(真实 React 只作废对应车道);② 没有 Offscreen——静默期内容会消失(真实 React 保留旧内容);③ 节流计时器管理粗糙——需要在卸载时清理(否则泄漏 + 卸载后 setState);④ 没有「同一 promise 多组件共享」的优化——多个组件读同一资源时会各自 attach,需要去重;⑤ 兜底缺失——真实 React 会处理「同步输入中挂起」(抛明确错误)、「嵌套 Suspense 的边界选择」、「fallback 自身挂起」等大量边界情况。

一句话速记

Suspense 的协议是「渲染期 throw 一个 thenable」(必须每次抛同一个,否则永远显示 fallback / 请求风暴);渲染器要做四件事:捕获 thenable → 向上找最近的边界 → 把 promise 记在「已提交树」的那个边界上并 attach ping(resolve 时先删 pending 再触发重渲染)→ 让本次渲染作废、以 currentRoot 重渲染(此时边界看到 pending 就渲染 fallback);节流是「挂起后先静默约 200ms,阈值内完成就不闪骨架」(比立刻显示 fallback 体验更好);「保留旧内容」靠 Offscreen(DOM 隐藏不卸载,连组件 state 都保留 → Activity 的语义,见 4.13);「挂起是否显示 fallback」取决于这次更新是不是 transition(transition 中挂起不显示 fallback,这才是「Tab 不闪骨架」的正解,见 4.16);promise reject 要在第二次 read 时 throw error 交给 ErrorBoundary(ErrorBoundary 在外、Suspense 在内);真实 React 用 Lane 精确作废而不是「整棵树重来」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.12 手写 Suspense

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫