
一句话: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
八、面试延伸
- 「手写一个 Suspense 的核心是什么?」
核心是「一个跨阶段的信号协议」:① 组件在渲染期间throw一个 thenable(必须每次抛同一个,否则会死循环);② 渲染器捕获它,向上找最近的 Suspense 边界,把「正在等的 promise」记在已提交树上的那个边界 fiber 上(因为 wip 树马上要被丢弃);③ 让本次渲染作废,以currentRoot重新渲染——此时边界看到「有 pending」就渲染 fallback(而不是 children);④ promise resolve 时 ping(先从 pending 删除,再触发一次重渲染),此时边界渲染 children。再补一个节流就是可用版本。 -
「为什么必须抛『同一个 promise』?」
因为渲染器的逻辑是「这次渲染挂起了,我记下这个 promise,等它完成再重试」。如果你每次都throw fetchData()(新建 promise),那么:① 每次重试都会得到新 promise → 渲染器认为「还在等新的东西」→ 永远显示 fallback;② 边界上的 pending 会无限累积;③ 会对后端发起请求风暴(每次渲染一个请求)。所以必须用「资源对象」把 promise 缓存起来(同一 key → 同一 promise)。 -
「挂起后『保留旧内容』是怎么实现的?」
靠 Offscreen / Activity 机制:React 会把「已经渲染出来的那部分子树」隐藏而不是卸载——DOM 还在(只是不可见、不可聚焦),组件的 state 也保留。这样挂起时用户看到的是「旧内容」(稍后会被新内容替换),而不是「内容消失 + 骨架」。⭐ 这带来一个重要副作用:旧组件的 state 也被保留,这正是「Activity 切走再切回来,输入框内容和滚动位置还在」的原因(见 4.13)。注意:如果边界是「首次挂起」(没有旧内容),就只能显示 fallback。 -
「为什么要『节流』?怎么决定阈值?」
动机是避免「一闪而过」——挂起时间很短时(如 30ms),如果立刻显示 fallback,用户会看到「内容 → 骨架 → 内容」,比「什么都不显示」更差。做法是挂起后先静默一段时间(默认约 200ms):阈值内 promise 完成了 → 用户完全看不到骨架;超过阈值 → 显示骨架(说明这次真慢)。阈值选择:网络快、后端稳定 → 可以调大(300ms,少闪);网络慢、后端波动大 → 调小(100ms,尽早反馈)。⭐ 但更根本的解法是「把这次更新放进 transition」——那样根本不显示 fallback(见下一条)。 -
「transition 和 Suspense 有什么关系?」
关系是「transition 决定了『挂起时显不显示 fallback』」:如果挂起来自 transition(startTransition/useTransition/useDeferredValue)→ React 不显示 fallback,而是继续显示之前的内容直到新内容就绪;如果不是 transition(普通setState、首次渲染)→ 显示 fallback。所以「Tab/路由切换不闪骨架」的正确写法不是「给 Suspense 加延迟」,而是「把这次更新放进startTransition」(见 4.16)。在 mini 实现里只需加一个inTransition开关:过渡中的挂起直接「保留旧内容」。 -
「Suspense 和错误边界怎么配合?」
分工明确、顺序固定:ErrorBoundary 在外、Suspense 在内。① Suspense 管「等待」——渲染期抛出 thenable → 显示 fallback(或保留旧内容);② ErrorBoundary 管「错误」——如果资源 reject,第二次read()时应该throw error,由 ErrorBoundary 接管。⭐ 所以「资源的实现」必须区分两种抛出:第一次抛 promise(挂起),第二次抛 error(失败)。如果顺序颠倒(Suspense 在外、ErrorBoundary 在内),ErrorBoundary 的错误 UI 会被当成「正在加载的内容」,语义混乱。 -
「挂起时『作废整棵树』是不是太浪费了?」
是——真实 React 不做这么粗暴的事。它用 Lane(车道) 精确标记「哪些车道被挂起了」,然后:① 只把被挂起的那部分工作作废,其它车道(如更高优先级的输入更新)可以继续;② ping 回来时用pingedLanes让对应车道可重试。⭐ 所以在真实实现里,「挂起」的代价是「这棵子树的重渲染延迟」,而不是「整棵应用重来」。这也是「为什么需要 Lane 模型」的一个核心动机(见 4.8、4.17)。 -
「你的 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 精确作废而不是「整棵树重来」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!