考点 8.1:Error Boundary 原理
第 0 段:直觉锚定
想象一栋大楼的每层楼都装了"防火门"。某一层着火了(组件 throw),烟会顺着楼梯往上飘(沿 return 链冒泡)。遇到第一个有防火门的楼层(Error Boundary),门自动关闭,把火控制在那一层的下方------上面的楼层不受影响,下面的楼层已经被烧毁的也只能重建。防火门关上后,那层楼会亮起"应急灯"(fallback UI),告诉上面的人"这里出了问题,但我们处理好了"。
Error Boundary 就是 React 组件树里的"防火门"。它只能由类组件 充当,因为只有类组件能通过 getDerivedStateFromError 和 componentDidCatch 这两个生命周期声明"我有防火门"。
⚠️ 常见先入为主的误解: 很多人以为 Error Boundary 是一个独立的 wrapper 组件(比如
<ErrorBoundary>)。实际上它只是一个满足条件的类组件 ------任何定义了getDerivedStateFromError或componentDidCatch的类组件都自动成为 Error Boundary。它不需要特殊的 API 注册,React 通过检查fiber.type.getDerivedStateFromError这个静态方法来识别它。
第 1 段:问题背景
在 React 16 之前,如果一个组件在 render 期间 throw,整个应用会崩溃------React 内部的状态已经被破坏,无法恢复。因为旧版 Stack Reconciler 是递归的,一旦中途出错,调用栈直接断掉,没有恢复机制。
React 16 的目标:让组件树的错误可以被局部捕获,只卸载出错的那棵子树,其他部分正常运行。
为什么现有机制不够用?
try/catch只能捕获命令式代码的错误,不能捕获 JSX 渲染期间的错误- 组件的 render 是声明式的,错误发生的位置在 React 内部的调用栈中,用户代码的
try/catch够不到 - 需要一种沿着组件树冒泡的机制,类似 DOM 事件冒泡,但针对的是错误而不是事件
第 2 段:核心数据结构
Error Boundary 机制涉及三个关键 flags 和一个特殊的 Update:
php
Fiber.flags 相关位:
┌──────────────────────────────────────────────┐
│ Incomplete --- 标记:这个 Fiber 的子树有未完成的错误 │
│ ShouldCapture --- 标记:这个边界应该捕获错误 │
│ DidCapture --- 标记:这个边界已经捕获了错误 │
└──────────────────────────────────────────────┘
Error Boundary 识别条件(源码 ReactFiberThrow.js:665-670):
ini
fiber.tag === ClassComponent
AND (fiber.type.getDerivedStateFromError 存在
OR (fiber.stateNode.componentDidCatch 存在
AND 不是已失败的旧版 Error Boundary))
Update 结构(捕获阶段创建的特殊更新):
go
CaptureUpdate {
tag: CaptureUpdate, // 标记这是错误捕获更新
payload: () => getDerivedStateFromError(error), // 返回新 state
callback: () => {
logCaughtError(...)
instance.componentDidCatch(error, {componentStack}) // commit 阶段调用
}
}
错误冒泡的实际链路(3 个节点的实例):
kotlin
App (HostRoot)
│
ErrorBoundary (ClassComponent) ← 有 getDerivedStateFromError
│ 识别为 Error Boundary
Panel (FunctionComponent)
│
Button (FunctionComponent) ← throw Error("boom!")
│
sourceFiber = Button
冒泡路径:Button.return → Panel.return → ErrorBoundary (命中!)
ErrorBoundary.return → App (HostRoot, 兜底)
第 3 段:运行流程
错误处理分两个阶段:render 阶段捕获 + commit 阶段回调。
kotlin
render 阶段:
beginWork(Button)
→ 调用用户函数组件
→ throw Error("boom!")
↓
被工作循环的 try/catch 捕获
↓
┌─ throwException() ──────────────────────────────────┐
│ 1. sourceFiber.flags |= Incomplete │
│ 2. 检查 value 是否是 Promise → 否,走错误路径 │
│ 3. 从 returnFiber 开始沿 return 链向上遍历: │
│ - ClassComponent + 有 error boundary 方法? │
│ → Yes: flags |= ShouldCapture │
│ → 创建 CaptureUpdate 加入 updateQueue │
│ → return false(找到边界,停止冒泡) │
│ - HostRoot? │
│ → flags |= ShouldCapture │
│ → 创建 RootErrorUpdate │
│ → return false(根节点兜底) │
│ - 其他 → 继续向上 │
│ 4. 走到 null → return true(致命错误,无法恢复) │
└──────────────────────────────────────────────────────┘
↓
unwindUnitOfWork() --- 栈展开
↓
┌─ unwindWork() ──────────────────────────────────────┐
│ 对每个展开的 Fiber: │
│ - ClassComponent with ShouldCapture? │
│ → flags = (flags & ~ShouldCapture) | DidCapture │
│ → return workInProgress(重新进入 beginWork) │
│ - 其他 → return null(继续展开) │
└──────────────────────────────────────────────────────┘
↓
重新 beginWork(ErrorBoundary) --- 带 DidCapture 标记
→ 用户组件读到 this.state.hasError === true
→ 渲染 fallback UI
commit 阶段:
commitLayoutEffectEffects()
→ 执行 CaptureUpdate 的 callback
→ 调用 componentDidCatch(error, {componentStack})
关键源码定位:
1. 错误捕获入口 react@18.3.1 · ReactFiberWorkLoop.js · unwindUnitOfWork 当 unitOfWork.flags & Incomplete 时,不走 completeUnitOfWork,改为展开栈。
2. 边界查找 react@18.3.1 · ReactFiberThrow.js · throwException(第 364-705 行) 先看第 647-702 行的 do-while 循环,这是沿 return 链找 Error Boundary 的核心逻辑。注意第 666 行的条件:(flags & DidCapture) === NoFlags------已经捕获过的边界不会再捕获。
3. 栈展开与标志转换 react@18.3.1 · ReactFiberUnwindWork.js · unwindWork(第 77-93 行) 当遇到 flags & ShouldCapture 的 ClassComponent,把 ShouldCapture 换成 DidCapture,返回这个 Fiber 重新进入 beginWork。
4. Update 创建 react@18.3.1 · ReactFiberThrow.js · initializeClassErrorUpdate(第 120-199 行) payload 包含 getDerivedStateFromError 的调用(render 阶段执行),callback 包含 componentDidCatch 的调用(commit 阶段执行)。
第 4 段:设计动机与权衡
为什么只有类组件能做 Error Boundary?
这不是技术限制,而是设计选择。React 团队曾明确表示:Error Boundary 的两个生命周期做的事情本质不同------getDerivedStateFromError 是纯渲染逻辑 (根据 error 计算新 state),componentDidCatch 是副作用逻辑(日志上报)。这种分离和类组件的生命周期模型天然匹配。
函数组件目前没有等价的 Hook。社区提出的 useErrorBoundary 提案尚未被采纳。
核心权衡:
- 牺牲了: 函数组件不能做 Error Boundary(不符合 React 对 Hook 的纯函数语义要求)
- 换来了: 错误处理和渲染逻辑的清晰分离------
getDerivedStateFromError在 render 阶段安全执行(纯函数),componentDidCatch在 commit 阶段执行(允许副作用) DidCapture标记的意义: 防止同一个 Error Boundary 递归捕获自己渲染 fallback 时产生的错误,避免无限循环
Error Boundary 不能捕获的场景:
- 事件处理器中的错误(不在 React 渲染流程内,用普通 try/catch)
- 异步代码(setTimeout、Promise 回调)
- 服务端渲染
- Error Boundary 自身的错误(会继续向上冒泡)
第 5 段:次级误解和边界
误解 1:Error Boundary 捕获错误后,子树会被保留,只是不渲染。
实际:子树会被完全卸载并重建 。DidCapture 导致 Error Boundary 的 beginWork 走完全不同的路径,子组件的 state 全部丢失。这是正确的行为------出错后的 state 不可信。
误解 2:多个嵌套的 Error Boundary,只有最内层的会捕获。
实际:错误冒泡机制类似 DOM 事件------最近的 Error Boundary 先捕获。但如果那个 Error Boundary 在渲染 fallback 时也出错了,错误会继续冒泡到上一层。这不是"只有最内层捕获",而是"从内向外逐层尝试"。
边界条件:并发模式下的错误处理
在 Concurrent Mode 中,如果组件 throw 但找到的"边界"是 Suspense(不是 Error Boundary),React 会走不同的路径------throwException 先检查 value.then(是否是 Promise),如果是走 Suspense 路径(第 382-483 行),否则才走 Error Boundary 路径。这两个路径在源码里是同一个函数的 if/else 分支。
现在我们知道了 Error Boundary 通过 throwException → unwindWork 两步实现错误捕获:第一步沿 return 链找到有 getDerivedStateFromError / componentDidCatch 的类组件并标记 ShouldCapture,第二步在栈展开时转换为 DidCapture 并重新渲染 fallback UI。
但 Error Boundary 只能捕获渲染阶段的错误。如果你需要在命令式代码中把 ref 暴露给父组件,React 提供了 forwardRef 和 useImperativeHandle------这就是 8.2 forwardRef 与 useImperativeHandle 要处理的事情。
考点 8.2:forwardRef 与 useImperativeHandle
第 0 段:直觉锚定
想象你在公司里用内线电话找人。普通员工(函数组件)没有电话分机号(ref),外面的人没法直接拨进来。forwardRef 就是给这个员工装一部内线电话------让外部能拿到一个"分机号"(ref)。
但这还不够。你不想让外面的人直接翻你的办公桌(直接访问 DOM),而是希望他们通过前台转接(暴露指定方法)。useImperativeHandle 就是"前台接线员"------你告诉前台"只转接 focus() 和 scrollIntoView() 这两个来电",其他请求一律不接。
第 1 段:问题背景
React 的核心设计是数据向下流动:props 从父到子。但有两个场景需要"反向引用":
- 焦点管理 :父组件需要调用子组件内部 DOM 节点的
focus() - 命令式 API :组件库需要暴露
open()/close()等方法
函数组件的 render 返回的是 React Element,不是 DOM 节点。函数组件本身也没有实例(stateNode 为 null)。所以 ref 直接放到函数组件上会报错------React 不知道该把什么交给 ref。
⚠️ 常见先入为主的误解: 很多人以为
forwardRef是把 ref 透传给子组件的 props。实际分两步:forwardRef只是把 ref 从"React 内部保留字段"变成"普通函数参数";真正决定 ref 拿到什么值的是useImperativeHandle或子组件内部的 DOM ref。
第 2 段:核心数据结构
forwardRef 创建的 Element 类型:
csharp
// ReactForwardRef.js --- forwardRef 的返回值不是组件,是一个 descriptor 对象
{
$$typeof: REACT_FORWARD_REF_TYPE, // 标记:我是 forwardRef 组件
render: (props, ref) => ReactNode // 用户写的渲染函数,ref 作为第二参数
}
在 Fiber 中的对应:
ini
fiber.tag = ForwardRef
fiber.type = { $$typeof: REACT_FORWARD_REF_TYPE, render: fn }
fiber.ref = 父组件传入的 ref(挂载在 Fiber 上,不在 props 中)
useImperativeHandle 本质是一个 Layout Effect:
csharp
// ReactFiberHooks.js --- useImperativeHandle 的内部实现
mountImperativeHandle(ref, create, deps) {
effectDeps = deps.concat([ref]) // ref 也参与依赖比较
mountEffectImpl(
UpdateEffect | LayoutStaticEffect, // = useLayoutEffect 的 flags
HookLayout, // = layout hook 标记
imperativeHandleEffect.bind(null, create, ref),
effectDeps,
)
}
imperativeHandleEffect 的核心逻辑 (ReactFiberHooks.js:2791-2823):
csharp
imperativeHandleEffect(create, ref):
if ref 是函数:
inst = create() // 调用用户的工厂函数
refCleanup = ref(inst) // 用回调 ref 设置值
return () => { refCleanup?.() || ref(null) }
if ref 是对象:
inst = create()
ref.current = inst // 直接赋值给 ref.current
return () => { ref.current = null }
三个节点的完整 ref 流转实例:
csharp
父组件 子组件 (forwardRef)
┌─────────────────────┐ ┌─────────────────────────────┐
│ const inputRef = │ │ const Input = forwardRef( │
│ useRef(null) │ │ (props, ref) => { │
│ │ │ useImperativeHandle( │
│ <Input ref={inputRef}>│──────│ ref, │
│ ... │ ref │ () => ({ │
│ │ 传递 │ focus: () => ... │
│ inputRef.current ───│───────│──→ { focus, scrollIntoView } │
│ = { focus, ... } │ 值回填 │ }) │
└─────────────────────┘ │ }) │
│ } │
│ ) │
└─────────────────────────────┘
第 3 段:运行流程
ref 从创建到赋值,分两个阶段:
scss
render 阶段:
父组件 JSX 编译
→ createElement(Input, { ref: inputRef })
→ ref 不在 props 中,挂到 element.ref
↓
beginWork 遇到 ForwardRef 类型的 Fiber
→ workInProgress.ref = inputRef (从 element 取出)
↓
updateForwardRef(current, workInProgress, Component, nextProps, renderLanes)
→ const render = Component.render // 用户的渲染函数
→ const ref = workInProgress.ref // 从 fiber 取出
→ 从 nextProps 中剥离 ref:
if ('ref' in nextProps) {
propsWithoutRef = { ...nextProps, 排除 key === 'ref' }
}
→ renderWithHooks(current, workInProgress, render, propsWithoutRef, ref, renderLanes)
// ref 作为第 5 个参数传入,用户的渲染函数签名 (props, ref) 收到它
↓
用户函数组件内部执行 useImperativeHandle(ref, create, deps)
→ mountImperativeHandle:
→ 创建一个 layout effect,destroy/crete = imperativeHandleEffect
→ 这个 effect 挂到 hooks 链表上,flags |= UpdateEffect | LayoutStaticEffect
commit 阶段(layout 子阶段):
commitLayoutEffectEffects()
→ 遍历 fiber 的 effect 链表
→ 发现 HookLayout 类型的 effect
→ 执行 imperativeHandleEffect(create, ref)
→ inst = create() // 用户定义的 { focus, scrollIntoView }
→ ref.current = inst // 赋值给父组件的 ref
// 或者 ref 是函数时:ref(inst)
同时,commitAttachRef 也会执行:
→ 但对于 ForwardRef,instanceToUse = finishedWork.stateNode
→ 注意:函数组件的 stateNode 为 null
→ 所以 ForwardRef Fiber 的 ref 由 useImperativeHandle 管理
→ commitAttachRef 的赋值会被 useImperativeHandle 的 layout effect 覆盖
关键源码定位:
1. forwardRef 创建 descriptor react@18.3.1 · ReactForwardRef.js · forwardRef(第 12-83 行) 核心是第 51-54 行:创建 { $$typeof: REACT_FORWARD_REF_TYPE, render } 对象。这个对象不是组件实例,只是一个描述符。
2. beginWork 处理 ForwardRef react@18.3.1 · ReactFiberBeginWork.js · updateForwardRef(第 405-464 行) 核心是第 416 行 const ref = workInProgress.ref 和第 442-449 行 renderWithHooks(..., ref, ...)------ref 作为独立参数传入,不在 props 中。
3. useImperativeHandle 本质 react@18.3.1 · ReactFiberHooks.js · mountImperativeHandle(第 2826-2858 行) 核心是第 2852-2857 行:调用 mountEffectImpl(HookLayout, ...),说明它就是一个 useLayoutEffect,只是 create 被包装成了 imperativeHandleEffect。
4. ref 赋值 react@18.3.1 · ReactFiberHooks.js · imperativeHandleEffect(第 2791-2823 行) create() 返回用户定义的 handle 对象,然后通过 ref.current = inst 或 ref(inst) 赋值给父组件的 ref。
第 4 段:设计动机与权衡
为什么 forwardRef 不直接把 ref 放进 props?
历史原因:在 React 19 之前,ref 是 React 的保留字段,createElement 会把 ref 从 props 中剥离出来挂到 element 上。forwardRef 的存在就是为了绕过这个限制------它是一个"ref 转发声明",告诉 React "我知道有 ref 要传下去,请把它作为函数参数给我"。
React 19 开始,ref 作为普通 prop 传递(ref 不再是保留字段),forwardRef 变成可选。但为了向后兼容,源码中 updateForwardRef(第 419-434 行)仍然会做 ref 剥离的逻辑。
useImperativeHandle 为什么用 Layout Effect 而不是 Passive Effect?
因为 ref 的值必须在 DOM 更新之后、浏览器绘制之前可用。如果用 Passive Effect(useEffect),在浏览器绘制那一帧,ref.current 还是旧值,可能导致视觉闪烁或竞态问题。
核心权衡:
- 牺牲了: 每次渲染都创建新的 handle 对象(
create()在每次 layout effect 执行时调用),有 deps 时通过依赖比较跳过 - 换来了: ref.current 始终是最新的、类型安全的命令式 API,而不是暴露整个 DOM 节点或组件实例
第 5 段:次级误解和边界
误解 1:forwardRef 和 useImperativeHandle 必须一起用。
实际:forwardRef 只负责"接收 ref",不关心 ref 最终拿到什么。你可以在 forwardRef 组件内直接 const inputRef = useRef(); ... <input ref={inputRef}>; ref.current = inputRef.current,不用 useImperativeHandle。只是这样不够优雅,且无法过滤暴露的方法。
误解 2:useImperativeHandle 的 ref 回调 cleanup 和 useEffect 的 cleanup 行为一样。
实际有差异:当 ref 是回调函数时,imperativeHandleEffect 在 create 时调用 ref(inst),如果 ref 返回了 cleanup 函数就存下来;destroy 时先执行 cleanup,没有 cleanup 才调用 ref(null)。这比 useEffect 的简单 destroy 多了一层间接性。
边界条件:StrictMode 双调用
StrictMode 下 mountImperativeHandle 会被双调用,但第二次调用不会真正赋值 ref------React 的 StrictMode 双调用机制确保只有最终结果生效,中间过程不会影响外部 ref。
现在我们知道了 forwardRef 把 ref 从保留字段变成函数参数,useImperativeHandle 在 layout effect 中把用户定义的 handle 对象赋给 ref。两者配合实现了类型安全的命令式 API。
但 useImperativeHandle 只是在 commit 阶段执行副作用。如果子组件需要等待异步数据,React 提供了一种完全不同的机制------组件可以 throw Promise 让渲染暂停------这就是 8.3 Suspense 的工作原理(throw Promise) 要处理的事情。
现在我来看看 beginWork 处理 SuspenseComponent 的关键部分,具体来说就是 Suspense 决定是显示 primary 还是 fallback 的部分。
考点 8.3:Suspense 的工作原理(throw Promise)
第 0 段:直觉锚定
想象你在餐厅点了一份需要现做的菜(异步数据)。有两种策略:
旧方案:服务员站在厨房门口等菜做好,期间不服务其他客人(同步阻塞)。
Suspense 方案:厨师说"菜还没好,这是小票"(throw Promise),服务员先把菜单翻到"等待中"那页,去服务别的客人。等菜好了,厨房响铃(Promise resolve),服务员回来翻回"菜单页"。
这里的关键洞察是: "菜还没好"不是用状态标记的,而是用 throw 表达的。组件直接 throw 一个 Promise,React 的渲染引擎像 try/catch 一样接住它,找到最近的 Suspense 边界,切换到 fallback。
第 1 段:问题背景
React 的声明式 UI 要求:组件的 render 应该是纯函数------给定相同的 props 和 state,输出相同的 UI。但数据获取是异步的。
在 Suspense 之前,异步数据获取有三种方案:
- 回调/状态模式 :组件自己
fetch→setState,每个组件独立管理加载状态 - 高阶组件模式 :
withData(WrappedComponent),在外层处理加载状态 - Render Props :
<DataLoader>{(data, loading) => ...}</DataLoader>
这些方案的问题:加载状态的粒度是组件级别。无法做到"一组组件共享一个加载状态"------要么每个组件各自 loading,要么在父组件统一管理,手动跟踪所有依赖。
Suspense 要做的:让组件假装数据是同步的。如果数据没准备好,throw Promise;React 找到最近的 Suspense 边界,展示 fallback;数据就绪后自动重试。
⚠️ 常见先入为主的误解: 很多人以为 Suspense 是 React 的数据获取库(类似 SWR/React Query)。实际上 Suspense 只是一个渲染协调机制------它不获取数据,只负责"数据没准备好时显示 fallback,准备好后重新渲染"。数据怎么获取是数据源(Relay、React Query 等)的事情。
第 2 段:核心数据结构
Suspense 边界的 Fiber 结构:
php
Suspense Fiber (tag: SuspenseComponent)
├── memoizedState: SuspenseState | null
│ ├── dehydrated: SuspenseInstance | null // SSR hydration 用
│ └── retryLane: Lane // 重试用的 lane
│
├── updateQueue: RetryQueue | null // Set<Wakeable>,待监听的 Promise
│
├── flags 相关:
│ ├── DidCapture --- 子树已经 throw 过,应该显示 fallback
│ ├── ShouldCapture --- 标记此边界应该捕获(栈展开时转换为 DidCapture)
│ └── ScheduleRetry --- 需要 commit 后立即调度重试
│
└── 子树结构(显示 primary 时):
child → Offscreen (mode: 'visible', children: primary)
sibling → null
子树结构(显示 fallback 时):
child → Offscreen (mode: 'hidden', children: primary) ← 保留但不渲染
sibling → Fragment (children: fallback) ← 实际渲染的 fallback
Offscreen Fiber 是关键:它用 mode: 'visible' | 'hidden' 控制 primary children 是否渲染,而不是卸载它们。这样当 Promise resolve 后,可以快速切回 primary 而不需要重建整棵子树。
三个节点的 throw-catch 实例:
scss
<Suspense fallback={<Loading/>}>
<Profile/> ← throw promise
</Suspense>
beginWork(Suspense)
→ showFallback = false (初次渲染,没有 DidCapture)
→ mountSuspensePrimaryChildren()
→ 创建 Offscreen(mode: 'visible', children: <Profile/>)
↓
beginWork(Offscreen)
↓
beginWork(Profile)
→ 数据没准备好,throw thePromise
↓
throwException() 接住
→ thePromise.then 存在 → 走 Suspense 路径(不是 Error Boundary 路径!)
→ getSuspenseHandler() 找到 Suspense Fiber
→ markSuspenseBoundaryShouldCapture()
→ suspenseBoundary.flags |= ShouldCapture
→ thePromise 存入 suspenseBoundary.updateQueue (RetryQueue)
→ attachPingListener(root, thePromise, renderLanes)
→ thePromise.then(() => scheduleRetry())
↓
unwindWork()
→ Suspense Fiber: flags & ShouldCapture → 转换为 DidCapture
→ 返回 Suspense Fiber,重新 beginWork
↓
beginWork(Suspense) --- 第二次!
→ didSuspend = (flags & DidCapture) !== NoFlags → showFallback = true
→ mountSuspenseFallbackChildren()
→ Offscreen(mode: 'hidden', children: <Profile/>)
→ Fragment(children: <Loading/>)
→ memoizedState = SUSPENDED_MARKER
第 3 段:运行流程
完整的 Suspense 生命周期分四个阶段:
scss
阶段 1:首次渲染 primary
beginWork(Suspense)
→ showFallback = false
→ mountSuspensePrimaryChildren()
→ Offscreen(mode: 'visible') → 渲染 primary children
↓
beginWork(子组件)
→ throw thePromise
↓
阶段 2:捕获 + 切换 fallback
throwException(root, returnFiber, sourceFiber, thePromise, renderLanes)
→ typeof thePromise.then === 'function' ← 关键分支!
→ 走 Suspense 路径(不是 Error Boundary 路径)
→ getSuspenseHandler() → 找到最近的 Suspense boundary
→ markSuspenseBoundaryShouldCapture(suspenseBoundary, ...)
→ suspenseBoundary.flags |= ShouldCapture
→ suspenseBoundary.updateQueue.add(thePromise) ← 存 Promise
→ attachPingListener(root, thePromise, renderLanes)
→ thePromise.then(ping → retrySuspendedRoot)
↓
unwindWork() 栈展开
→ Suspense Fiber: ShouldCapture → DidCapture
→ 返回 Suspense,重新 beginWork
↓
beginWork(Suspense) --- 第二次
→ didSuspend = true → showFallback = true
→ mountSuspenseFallbackChildren()
→ primary: Offscreen(mode: 'hidden') ← 保留但不渲染
→ fallback: Fragment → 渲染 <Loading/>
→ memoizedState = SUSPENDED_MARKER
↓
commit 阶段 → 渲染 fallback UI
阶段 3:Promise resolve → 触发重试
thePromise.resolve(value)
→ ping listener 触发
→ scheduleRetryOnFiber(root, suspenseBoundary, retryLane)
→ ensureRootIsScheduled() → 重新调度渲染
↓
阶段 4:重试渲染 primary
beginWork(Suspense)
→ didSuspend = false (DidCapture 已清除)
→ updateSuspensePrimaryChildren()
→ Offscreen(mode: 'visible') ← 重新显示 primary
→ 删除 fallback Fragment (加入 deletions)
→ memoizedState = null ← 清除挂起状态
↓
beginWork(子组件)
→ 数据已就绪,正常渲染
↓
commit 阶段 → 渲染 primary UI,删除 fallback
关键源码定位:
1. throw Exception 的 Suspense 分支 react@18.3.1 · ReactFiberThrow.js · throwException(第 381-484 行) 第 382 行 typeof value.then === 'function' 是 Suspense 和 Error Boundary 的分叉点。如果是 Promise 走 Suspense,否则走 Error Boundary。
2. 标记 Suspense 捕获 react@18.3.1 · ReactFiberThrow.js · markSuspenseBoundaryShouldCapture(第 241-362 行) 核心是第 357 行 suspenseBoundary.flags |= ShouldCapture。Concurrent Mode 下走第 316-362 行,Legacy Mode 有不同的处理(第 253-314 行,直接标 DidCapture,不走展开流程)。
3. beginWork 决定显示 primary 还是 fallback react@18.3.1 · ReactFiberBeginWork.js · updateSuspenseComponent(第 2348-2613 行) 核心是第 2363-2372 行:didSuspend = (flags & DidCapture) !== NoFlags,决定 showFallback。然后分别走 mountSuspensePrimaryChildren(第 2614 行)或 mountSuspenseFallbackChildren(第 2634 行)。
4. fallback 子树结构 react@18.3.1 · ReactFiberBeginWork.js · mountSuspenseFallbackChildren(第 2634-2697 行) 第 2643-2646 行:primary 变成 Offscreen(mode: 'hidden');第 2684-2689 行:fallback 变成 Fragment。两者是 sibling 关系。
第 4 段:设计动机与权衡
为什么用 throw 而不是 return 一个特殊值?
因为 throw 可以穿透组件栈。如果一个深层子组件 throw,React 不需要在每一层手动传递"加载中"状态。throw 的冒泡机制天然地找到最近的 Suspense 边界,就像 Error Boundary 一样。
为什么 Offscreen 不卸载 primary children?
性能权衡。如果卸载了,Promise resolve 后需要从头重建整棵子树(包括重新执行所有 Hooks、重建 Fiber 节点)。用 mode: 'hidden' 保留 Fiber 树,只是不渲染 DOM,重试时可以直接复用,跳过大部分工作。
核心权衡:
- 牺牲了: 需要数据源配合(必须实现"throw Promise"协议),不是所有数据获取库都能直接用 Suspense
- 换来了: 声明式的加载状态管理,组件代码完全不需要关心"数据是否在加载中"
- Suspense vs Error Boundary 共享 throwException: 两个机制在同一个函数中通过
typeof value.then分流,设计简洁但有认知成本
Legacy Mode vs Concurrent Mode 的 Suspense 行为差异:
- Legacy Mode(
ReactDOM.render):throw 后立即 commit fallback(同步),primary 子树以"不一致状态"提交 - Concurrent Mode(
ReactDOM.createRoot):throw 后可以延迟 commit,等更多数据就绪后一次性更新,避免加载状态闪烁
第 5 段:次级误解和边界
误解 1:Suspense 只能用于 React.lazy。
实际:任何 throw Promise 的机制都能触发 Suspense------React.lazy、Relay、React Query 的 suspense: true 模式、甚至手动 throw new Promise(...)。throwException 只检查 typeof value.then === 'function',不关心 Promise 的来源。
误解 2:Suspense fallback 显示后,primary children 的 state 会丢失。
实际不会。primary children 被包在 Offscreen(mode: 'hidden')中,Fiber 树和 Hooks 状态完整保留。当 Promise resolve 触发重试时,updateSuspensePrimaryChildren 复用之前的 Offscreen Fiber,所有 state 都还在。只有 Offscreen 的 mode 从 'hidden' 变回 'visible'。
边界条件:嵌套 Suspense 的冒泡
如果一个 Suspense 内部的子组件 throw,但这个 Suspense 自身也在 fallback 状态,那 Promise 会冒泡到外层 Suspense。因为 getSuspenseHandler() 返回的是当前 Suspense 上下文中最近的活跃边界。如果内层已经在 fallback,它的子组件的 throw 会跳过它,继续向上找。
现在我们知道了 Suspense 通过 throw Promise → throwException 识别 thenable → markSuspenseBoundaryShouldCapture 标记边界 → 栈展开时转 DidCapture → 重新渲染时显示 Offscreen(hidden) + Fragment(fallback) → Promise resolve 后 ping 重试 → 切回 primary。
这个机制依赖一个前提:浏览器中已经有 DOM 可以操作。但如果页面是服务端渲染的呢?客户端需要把服务端的 HTML"激活"成可交互的 React 应用------这就是 8.4 Hydration 原理 要处理的事情。
现在讲 8.4 Hydration 原理。
考点 8.4:Hydration 原理
第 0 段:直觉锚定
想象一个舞台剧。服务端渲染(SSR)就像提前搭好了布景------HTML 已经在浏览器里了,观众能看到"画面"。但演员还没就位(没有事件绑定、没有状态管理),布景是"死的"。
Hydration 就是"演员上台"的过程:React 拿到这份已有的 HTML,逐个节点对比------"这个 <div> 对应我的 Fiber 节点,props 一致,好,你留下;这个 <button> 的 onClick 我来绑定"。不创建新 DOM,只复用已有的,把静态 HTML "激活"成可交互的 React 应用。
如果发现不匹配(服务端渲染的 HTML 和客户端期望的不一样),就像换布景------丢弃旧 DOM,重新创建。这个过程叫"client render from scratch"。
第 1 段:问题背景
SSR 的核心矛盾:服务端生成的 HTML 是静态的字符串,没有事件、没有状态、没有交互。用户点击按钮没反应------直到 JavaScript 加载并执行。
传统方案是 ReactDOM.render() 在客户端从头渲染,服务端的 HTML 直接被丢弃,用户体验差(白屏→闪烁→可交互)。
Hydration 要达到的目标:
- 复用服务端 HTML,不做不必要的 DOM 操作
- 绑定事件处理器,让页面可交互
- 恢复组件状态,让 React 接管后续更新
- 遇到不匹配时优雅降级,而不是整个应用崩溃
⚠️ 常见先入为主的误解: 很多人以为 Hydration 是"服务端渲染 + 客户端渲染"两遍渲染。实际是只有一遍渲染------React 在 render 阶段遍历 Fiber 树时,同时沿着已有的 DOM 树前进,逐个"认领"DOM 节点。如果匹配成功就不创建新 DOM;匹配失败才触发 client render。
第 2 段:核心数据结构
Hydration 的核心状态存在于 ReactFiberHydrationContext.js 的模块级变量中:
php
Hydration 上下文状态:
┌────────────────────────────────────────────────────┐
│ isHydrating: boolean │
│ → 当前是否处于 hydration 模式 │
│ │
│ hydrationParentFiber: Fiber | null │
│ → 当前正在 hydrate 的父级 Host Fiber │
│ │
│ nextHydratableInstance: HydratableInstance | null │
│ → DOM 树中的"下一个待认领"的 DOM 节点 │
│ → 相当于一个游标,沿着 DOM 树 DFS 前进 │
│ │
│ hydrationErrors: Array<CapturedValue> | null │
│ → 收集的不匹配错误(Concurrent Mode 下批量处理) │
│ │
│ rootOrSingletonContext: boolean │
│ → 是否在根容器或 Singleton 节点上下文中 │
└────────────────────────────────────────────────────┘
nextHydratableInstance 是整个机制的核心------它是一个游标,指向 DOM 树中下一个可以被认领的节点。React 在遍历 Fiber 树时,每遇到一个 HostComponent/HostText,就拿这个游标和 Fiber 对比。
FiberRoot 的 hydration 标记:
arduino
FiberRoot {
hydrate: true ← 标记这是一个 hydration 根
// containerInfo 指向已有的 DOM 容器(包含 SSR 输出的 HTML)
}
DOM 树与 Fiber 树的对应实例:
css
SSR 输出的 DOM 树: 客户端构建的 Fiber 树:
<div id="root"> HostRoot
<div class="app"> └── HostComponent (div.app)
<h1>Title</h1> │ ├── HostComponent (h1)
<p>Hello</p> │ │ └── HostText ("Title")
</div> │ └── HostComponent (p)
│ └── HostText ("Hello")
hydration 过程:
nextHydratableInstance 从 <div class="app"> 开始
→ 认领 <div> ✓ → 游标移到 <h1>
→ 认领 <h1> ✓ → 游标移到 "Title" 文本节点
→ 认领文本 ✓ → 游标移到 <p>
→ 认领 <p> ✓ → 游标移到 "Hello" 文本节点
→ 认领文本 ✓ → hydration 完成
第 3 段:运行流程
Hydration 分三个阶段:初始化 → 逐节点认领 → 不匹配处理。
scss
阶段 0:初始化
hydrateRoot(container, <App/>)
→ createHydrationContainer(...)
→ createFiberRoot(containerInfo, tag, hydrate=true)
→ scheduleInitialHydrationOnRoot(root, lane)
↓
performWorkOnRoot → performConcurrentWorkOnRoot → renderRootConcurrent
→ prepareFreshStack(root, renderLanes)
→ createWorkInProgress(root.current, pendingProps)
→ enterHydrationState(workInProgress)
→ nextHydratableInstance = getFirstHydratableChild(containerInfo)
→ hydrationParentFiber = fiber
→ isHydrating = true
// 游标指向容器的第一个子 DOM 节点
阶段 1:逐节点认领(beginWork 阶段)
beginWork 遇到 HostComponent (current === null, 初次渲染)
→ updateHostComponent(current=null, workInProgress, ...)
→ tryToClaimNextHydratableInstance(workInProgress)
→ nextInstance = nextHydratableInstance // 取游标
→ tryHydrateInstance(fiber, nextInstance, context)
→ canHydrateInstance(nextInstance, fiber.type, ...)
// 检查 DOM 节点类型是否匹配(div === div?)
// 检查 DOM 是否已被认领(避免重复认领)
→ 匹配成功:fiber.stateNode = domInstance ← 复用!
→ 游标前进:nextHydratableInstance = nextSibling/firstChild
→ 匹配失败:throwOnHydrationMismatch(fiber)
阶段 2:属性比对 + 事件绑定(completeWork 阶段)
completeWork 遇到 HostComponent
→ wasHydrated = popHydrationState(workInProgress)
→ if wasHydrated:
→ prepareToHydrateHostInstance(workInProgress, hostContext)
→ hydrateInstance(instance, type, memoizedProps, ...)
// 对比 SSR DOM 的属性和 Fiber 期望的属性
// 更新不匹配的属性(如 className, style)
// 绑定事件(通过 __reactFiber$xxx 等 internal 属性)
→ finalizeHydratedChildren(...)
// 检查子节点是否完全匹配
// 如果不匹配,标记 flags |= Hydrate(需要更新)
→ if NOT wasHydrated:
→ createInstance(type, newProps, ...) // 创建新 DOM
→ appendAllChildren(instance, workInProgress)
// 走正常的客户端渲染路径
completeWork 遇到 HostText
→ wasHydrated = popHydrationState(workInProgress)
→ if wasHydrated:
→ prepareToHydrateHostTextInstance(workInProgress)
// 对比文本内容是否一致
// 一致:直接复用
// 不一致:标记更新
阶段 3:不匹配处理(mismatch recovery)
如果 tryToClaimNextHydratableInstance 发现类型不匹配:
→ throwOnHydrationMismatch(fiber)
→ isHydrating = false ← 退出 hydration 模式
→ fiber.flags |= DidNotHydrate
→ 后续节点走 createInstance 路径(创建新 DOM)
Concurrent Mode 的优化:
→ 不立即 throw,而是将错误收集到 hydrationErrors 数组
→ 渲染完成后统一处理
→ 对用户可见的 fallback 是"从最近的 Suspense 边界重新客户端渲染"
关键源码定位:
1. Hydration 初始化 react@18.3.1 · ReactFiberReconciler.js · createHydrationContainer(第 282-351 行) 核心是第 313 行 createFiberRoot(containerInfo, tag, hydrate=true) 和第 348 行 scheduleInitialHydrationOnRoot(root, lane)。
2. 进入 hydration 状态 react@18.3.1 · ReactFiberHydrationContext.js · enterHydrationState(第 162-178 行) 游标 nextHydratableInstance 指向容器的第一个可 hydrate 子节点,isHydrating = true。
3. beginWork 认领 DOM react@18.3.1 · ReactFiberBeginWork.js · updateHostComponent(第 1938-1939 行) current === null 时调用 tryToClaimNextHydratableInstance(workInProgress)。
4. 认领核心逻辑 react@18.3.1 · ReactFiberHydrationContext.js · tryToClaimNextHydratableInstance(第 445-468 行) 取游标 → tryHydrateInstance 检查类型是否匹配 → 匹配则复用,不匹配则 throwOnHydrationMismatch。
5. completeWork 属性比对 react@18.3.1 · ReactFiberCompleteWork.js · completeWork HostComponent 分支(第 1391-1405 行) wasHydrated 为 true 时走 prepareToHydrateHostInstance,比对属性并绑定事件。
第 4 段:设计动机与权衡
为什么 Hydration 在 beginWork 和 completeWork 两步完成?
分工不同:
- beginWork(认领) :确认 DOM 节点类型是否匹配(div === div?),匹配则把
fiber.stateNode指向已有 DOM。游标前进。 - completeWork(属性比对) :子树已经全部认领完毕,此时才做属性级别的比对和事件绑定。因为事件绑定需要知道子树完整结构(事件委托到 root container)。
为什么不是服务端渲染一遍、客户端再渲染一遍?
性能。如果客户端重新创建所有 DOM 节点,SSR 的 HTML 就完全浪费了------用户会看到页面闪烁。Hydration 通过复用已有 DOM,跳过了 createElement + appendChild 这两个最昂贵的 DOM 操作。
核心权衡:
- 牺牲了: 必须保证服务端和客户端渲染结果一致。任何不一致都会导致 hydration mismatch,React 需要丢弃不匹配的 DOM 并重建(性能惩罚)
- 换来了: 首屏可交互时间(TTI)大幅缩短。用户在 JS 加载完成前就能看到内容,JS 加载后只需"激活"而非"重建"
- selective hydration(React 18): 允许 Suspense 边界内的局部 hydration 失败时只重建那部分子树,而非整个应用。这使得 hydration 变得"渐进式"
第 5 段:次级误解和边界
误解 1:Hydration 失败会卸载整个应用重新渲染。
实际:在 React 18 的 Concurrent Mode 中,hydration mismatch 是局部恢复 的。throwException 会找到最近的 Suspense 边界,只重建那棵子树,不影响其他部分。源码中 ReactFiberThrow.js 的第 551-595 行处理了这个场景------hydration 失败时尝试用 Suspense boundary 做 client render 恢复。
误解 2:Hydration 过程中事件可以正常触发。
实际不能。在 Hydration 完成之前,DOM 节点上没有绑定 React 的事件处理器。虽然用户能看到按钮,但点击不会有 React 级别的响应。只有 completeWork 的 prepareToHydrateHostInstance 执行后(commit 阶段),事件才被绑定。这就是"可交互时间"和"首次内容绘制时间"之间的差距。
边界条件:增量 Hydration(Selective Hydration)
React 18 引入了选择性 Hydration:如果页面有多个 Suspense 边界,React 可以先 hydrate 用户正在交互的区域 ,其他区域延迟 hydrate。这是通过给 Suspense 边界设置不同的 retryLane 实现的------用户交互触发的事件会提高对应区域的优先级。
现在我们知道了 Hydration 通过 enterHydrationState 初始化游标 → tryToClaimNextHydratableInstance 在 beginWork 逐节点认领 → prepareToHydrateHostInstance 在 completeWork 比对属性并绑定事件 → mismatch 时通过 Suspense 边界做局部恢复。
但 Hydration 发生在同一个 JavaScript 运行时 内------服务端渲染的 HTML 和客户端 React 在同一个 JS 环境中对接。如果组件的渲染逻辑完全不在这端 运行呢?服务端直接生成序列化的 UI 描述发给客户端------这就是 8.5 Server Components(RSC)原理 要处理的事情。
源码已定位。现在讲 8.5 Server Components(RSC)原理。
考点 8.5:Server Components(RSC)原理
第 0 段:直觉锚定
想象一个国际快递系统。传统的 SSR 像是工厂直营店------工厂(服务端)自己生产商品(HTML),直接摆在货架上。但工厂和商店是同一个地方,工厂的机器(Node.js 运行时)必须一直开着。
RSC 像是工厂 + 物流 + 零售店的分工:
- 工厂(服务端) :生产商品(渲染 Server Component),但不生产 HTML ------它生产的是一份装箱单(序列化的组件描述)
- 物流(Flight 协议) :把装箱单通过流式传输发给零售店
- 零售店(客户端) :拿到装箱单后,按单子组装货架(创建 React Element),然后渲染成 DOM
关键区别:工厂和零售店用不同的语言 。工厂说"这个位置放一个 Button 组件,它的代码在 client.js 里"(引用 ID),零售店收到后自己去仓库找这个组件(动态 import),然后渲染。服务端从不直接发送 DOM 节点。
第 1 段:问题背景
传统 SSR 的问题:
- 所有组件都在服务端执行------即使有些组件完全不依赖服务端数据(比如纯 UI 组件),也必须在 Node.js 中运行
- hydration 必须等整个页面------即使大部分内容已经可用,也要等 JS 全部加载并执行
- bundle 体积大------客户端需要下载所有组件的 JS,包括那些只在服务端有用的(数据库查询库等)
RSC 要解决的:
- 零客户端 JS :Server Component 的代码永远不会发送到客户端。客户端只收到序列化的渲染结果(一个引用 ID)
- 自动代码分割:Server Component 可以直接 import Client Component,这个 import 边界就是天然的代码分割点
- 流式渲染:服务端渲染完一个子树就立即发送,不需要等整个页面
⚠️ 常见先入为主的误解: 很多人以为 RSC 就是"更好的 SSR"。实际 RSC 和 SSR 是正交的两个概念 :SSR 产出 HTML,RSC 产出序列化的组件树描述(Flight 格式)。RSC 可以配合 SSR(先在服务端渲染成 Flight → 转 HTML 发送),也可以不配合(纯流式 RSC)。
第 2 段:核心数据结构
服务端序列化格式(Flight 协议)
Server Component 渲染后,产出的不是 HTML,而是一组 chunk:
yaml
chunk 类型:
Model chunk: JSON 序列化的组件树描述
Module chunk: 客户端组件的模块引用(moduleId + exportName)
Error chunk: 错误信息
Text chunk: 纯文本内容
服务端渲染一个 Server Component 时,遇到不同类型的元素:
scss
renderElement() 的分支逻辑:
1. typeof type === 'function' && !isClientReference(type)
→ Server Component:直接调用函数,递归渲染子树
→ 输出:子树的渲染结果(内联在 JSON 中)
2. isClientReference(type)
→ Client Component:不调用函数!
→ 输出:引用 ID 元组
[REACT_ELEMENT_TYPE, moduleId, key, props]
// type 位置放的是模块引用,不是函数本身
3. Host 元素 (div, span...)
→ 输出:元素元组
[REACT_ELEMENT_TYPE, "div", key, {className: "...", children: [...]}]
客户端接收后的解析
客户端收到 Flight 流后,逐 chunk 解析:
arduino
reviveModel(response, rawModel):
if value 是数组 && value[0] === REACT_ELEMENT_TYPE:
→ parseModelTuple()
→ createElement(response, tuple[1], tuple[2], tuple[3])
// 如果 tuple[1] 是模块引用(Client Component):
// → resolveModule() → 动态 import 组件代码
// → 返回 React.lazy wrapper
// 如果 tuple[1] 是字符串(Host 元素):
// → 直接返回 React.createElement("div", props)
三个组件的完整流转实例:
kotlin
服务端组件树:
<ServerLayout> ← Server Component
<ClientSidebar data={...}/> ← Client Component
<ServerContent/> ← Server Component
<p>{data}</p> ← Host 元素
</ServerLayout>
服务端 Flight 输出(简化):
chunk 0: [
REACT_ELEMENT_TYPE, // $$
{moduleId: "./ClientSidebar.js", ...}, // Client Component 引用
null, // key
{data: {...}} // props(已序列化)
]
chunk 1: [
REACT_ELEMENT_TYPE,
"p", // Host 元素
null,
{children: "Hello from DB"} // 子节点(数据已在服务端获取)
]
客户端解析后:
chunk 0 → React.lazy(() => import("./ClientSidebar.js"))
createElement(LazySidebar, {data: {...}})
// ClientSidebar 的代码被动态 import,不会阻塞初始渲染
chunk 1 → createElement("p", null, "Hello from DB")
// 纯 Host 元素,不需要额外 JS
第 3 段:运行流程
完整的 RSC 渲染流程:
typescript
服务端(ReactFlightServer):
renderToPipeableStream(<App/>)
→ 创建 Request 对象
→ performWork(request)
→ renderModelDestructive(request, task, ...)
→ 遍历组件树
↓
遇到 Server Component (typeof type === 'function', 不是 client reference)
→ renderFunctionComponent(request, task, key, type, props)
→ 直接调用 type(props) ← 在服务端执行!
→ 拿到返回的 React Element
→ 递归 renderModelDestructive 处理子树
→ 结果内联在 JSON 中(不需要引用 ID)
↓
遇到 Client Component (isClientReference(type) === true)
→ 不调用函数!
→ 序列化为元组:
[REACT_ELEMENT_TYPE, clientReference, key, props]
→ emitModelChunk() → 写入输出流
→ 客户端会通过 clientReference 的 moduleId 动态 import
↓
遇到 throw Promise(Server Component 中 use() / await)
→ renderModel 中的 catch 块(第 3400-3432 行)
→ 创建新 Task,绑定 ping
→ 返回 serializeLazyID(newTask.id)
→ 客户端收到 "$L123" 格式的引用
→ 解析为 React.lazy wrapper
→ Promise resolve 后 ping 触发,继续渲染
↓
输出流(Flight 格式):
chunk 0: JSON (Model)
chunk 1: JSON (Model)
chunk 2: Error/Text/Module...
客户端(ReactFlightClient):
接收 Flight 流
→ processModelChunk(response, id, json)
→ parseModel(response, json)
→ JSON.parse(json)
→ reviveModel(response, rawModel, {'': rawModel}, '')
→ 遍历 parsed JSON
→ 遇到 "$" 前缀的字符串 → parseModelString
→ "$L123" → createLazyChunkWrapper(chunk)
→ 返回 React.lazy 对象
→ "$@456" → 返回 Promise chunk
→ "$$escaped" → 转义字符串
→ 遇到数组 [REACT_ELEMENT_TYPE, ...] → parseModelTuple
→ createElement(response, type, key, props)
→ type 是模块引用 → resolveModule → 动态 import
↓
最终得到完整的 React Element 树
→ 交给 React Reconciler 正常渲染
→ Client Component 用 React.lazy 包裹 → 触发 Suspense
→ 动态 import 完成后重新渲染
关键源码定位:
1. 服务端渲染入口 react@18.3.1 · react-server/src/ReactFlightServer.js · renderModel(第 3350 行)→ renderModelDestructive(第 3463 行) 这是服务端遍历组件树的核心,区分 Server/Client/Host 元素。
2. Server/Client 分流 react@18.3.1 · react-server/src/ReactFlightServer.js · renderElement(第 2179-2298 行) 第 2204-2210 行:typeof type === 'function' && !isClientReference(type) → Server Component,调用函数;否则检查 $$typeof 分支处理 Client Component。
3. 服务端序列化格式 react@18.3.1 · react-server/src/ReactFlightServer.js(第 2082-2084 行) [REACT_ELEMENT_TYPE, type, key, props] --- 这个元组格式是 Flight 协议的核心。
4. 客户端解析 react@18.3.1 · react-client/src/ReactFlightClient.js · parseModel(第 5299-5346 行) JSON.parse 后用 reviveModel 遍历,遇到 $ 前缀字符串解析引用,遇到 REACT_ELEMENT_TYPE 元组转为 createElement。
5. 客户端 Lazy 引用 react@18.3.1 · react-client/src/ReactFlightClient.js · parseModelString(第 2360-2404 行) "$L" 前缀 → createLazyChunkWrapper → 返回 React.lazy,让 Suspense 机制接管。
第 4 段:设计动机与权衡
为什么不是"服务端渲染 HTML,客户端 hydrate"?
因为 Server Component 的代码不应该发送到客户端。如果用传统 SSR + hydrate,客户端需要加载 Server Component 的 JS 代码才能 hydrate,违背了"零客户端 JS"的目标。
Flight 协议的设计核心:Server Component 渲染后只序列化结果,不序列化代码。客户端收到的是一棵"半成品" Element 树------Server Component 的位置已经被替换成了渲染结果(Host 元素或 Client Component 引用),客户端只需要"填充"Client Component 的代码。
核心权衡:
- 牺牲了: Server Component 不能有状态(useState/useEffect),不能有浏览器 API,每次请求都在服务端重新执行。组件的"边界"需要开发者显式标记(
"use client"/"use server") - 换来了: Server Component 可以直接访问数据库、文件系统、环境变量,不需要 API 层。客户端 bundle 体积理论上可以无限小(只包含 Client Component)
- Flight 协议的序列化成本: 组件树需要被 JSON 序列化/反序列化,大型应用可能有性能开销。但因为是流式的,不会阻塞整个页面
第 5 段:次级误解和边界
误解 1:Server Component 和 Client Component 可以自由互相嵌套。
实际有严格的单向边界规则 :Server Component 可以 import 并渲染 Client Component,但 Client Component 不能直接 import 并渲染 Server Component。因为 Client Component 在浏览器运行,无法执行服务端代码。
绕过方式:Client Component 可以通过 children prop 接收 Server Component 的渲染结果------由共同的 Server Component 父节点把 Server Component 的输出作为 children 传给 Client Component。
误解 2:RSC 和 SSR 是同一件事。
实际是两个独立的机制,可以组合使用:
- SSR(renderToString / renderToPipeableStream) :产出 HTML,用于首屏渲染
- RSC(ReactFlightServer) :产出 Flight 格式的组件描述,用于后续更新
- SSR + RSC 组合:首次请求用 SSR 生成 HTML + Flight 流(HTML 中内嵌 Flight 数据),客户端 hydrate 后用 Flight 做后续交互
边界条件:Server Component 中的 Suspense
Server Component 中可以使用 use() 触发异步数据获取。服务端的 renderModel 捕获 throw 的 Promise(第 3400-3432 行),创建新 Task 并绑定 ping。但这个 Suspense 的行为和客户端不同------服务端不会显示 fallback,而是暂停该子树的序列化,等数据就绪后继续。客户端收到的已经是完整的渲染结果。
现在我们知道了 RSC 通过 Flight 协议序列化组件树------Server Component 在服务端执行并内联结果,Client Component 只发送模块引用 ID,客户端通过 parseModel 重建 Element 树并用 React.lazy + Suspense 处理异步部分。
RSC 的渲染结果最终都要落到真实的 DOM 上。大部分时候 DOM 节点在组件树中按顺序排列,但有时候需要"传送"到其他位置------比如 Modal 渲染在 body 下但事件仍然冒泡到组件树------这就是 8.6 Portal 的事件冒泡行为 要处理的事情。
好,源码已定位。现在讲 8.6 Portal 的事件冒泡行为。
考点 8.6:Portal 的事件冒泡行为
第 0 段:直觉锚定
想象你在公司总部大楼的某个会议室里(React 组件树),通过视频会议系统连接了一个远程分部的屏幕(Portal 渲染到 document.body)。
如果远程分部有人拍了桌子(Portal 内的 DOM 事件),声音会怎样传播?
- 物理传播(原生 DOM 冒泡):声音从分部的会议室开始,在分部大楼里往上冒泡------从拍桌子的房间 → 走廊 → 分部前台
- 公司传播(React 合成事件):视频系统把这个"拍桌子"事件传回总部,从总部视角看,这是"总部远程会议室里发生的事件",按总部的组织架构向上汇报------拍桌子的人 → 部门经理 → VP
关键:两套冒泡是独立的 。React 的事件冒泡不跟随 DOM 树,而是跟随 Fiber 树 。Portal 把 DOM 渲染到了别处,但 Fiber 树中 Portal 节点的位置没变,所以事件仍然按 Fiber 树的 return 链冒泡。
第 1 段:问题背景
Portal 的核心能力:把子组件渲染到 DOM 树中的不同位置 (比如 document.body),但逻辑上仍然属于父组件的子树。
这带来了一个矛盾:
- DOM 层面:Portal 的子节点不在父组件的 DOM 容器内,而是在另一个容器中
- React 层面:Portal 的子节点在 Fiber 树中仍然挂在父组件下面
原生 DOM 事件冒泡遵循 DOM 树结构------从 target 向上冒泡到 document.body。如果 Portal 的子节点在 body 下,原生事件会冒泡到 body,但不会经过 Portal 在 React 组件树中的父组件的 DOM 节点。
如果 React 合成事件也遵循 DOM 冒泡,那 Portal 内的事件就不会被 Portal 外部的父组件捕获。这违背了 Portal 的设计意图------Portal 应该是"逻辑上透明的",外部组件应该能正常捕获 Portal 内的事件。
⚠️ 常见先入为主的误解: 很多人以为 Portal 内的事件不会冒泡到 Portal 外的父组件。实际恰恰相反------React 通过特殊的事件处理逻辑,让 Portal 内的事件同时参与两套冒泡:原生 DOM 冒泡到 Portal 容器,React 合成事件冒泡到 Fiber 树中的父组件。
第 2 段:核心数据结构
Portal 的 Fiber 结构:
php
Fiber (tag: HostPortal)
├── stateNode: {
│ containerInfo: DOMNode ← Portal 的目标容器(如 document.body)
│ }
├── child: Fiber ← Portal 的子 Fiber 树(正常挂载)
├── return: Fiber ← 指向 React 组件树中的父 Fiber
└── flags: PortalStatic ← 标记这是 Portal 节点
事件处理中的关键数据结构:
yaml
DispatchListener {
instance: Fiber ← 注册事件的 Fiber
listener: Function ← 用户写的事件处理函数
currentTarget: EventTarget ← 事件当前冒泡到的 DOM 节点
}
DispatchQueue = Array<{
event: SyntheticEvent,
listeners: Array<DispatchListener>
}>
两棵树的关系实例:
css
React Fiber 树: DOM 树:
App <div id="root">
├── Header <header>...</header>
└── Portal (→ document.body) <main>...</main>
├── Modal </div>
└── Overlay <body>
<div class="modal"> ← Portal 渲染到这里
<div class="overlay">
...
</div>
</div>
</body>
原生 click 事件冒泡路径(DOM 树):
.overlay → .modal → body → html → document
React 合成事件冒泡路径(Fiber 树):
Overlay → Modal → Portal → App
↑ 不走 DOM 树,走 return 链!
第 3 段:运行流程
Portal 事件处理的核心在 dispatchEventForPluginEventSystem 中,它需要跨越 Portal 边界找到正确的 ancestor:
scss
原生 click 事件在 .overlay 上触发
→ 冒泡到 body(Portal 的 containerInfo)
→ body 上的事件监听器捕获(listenToAllSupportedEvents 注册的)
→ dispatchEvent(domEventName, eventSystemFlags, body, nativeEvent)
↓
findInstanceBlockingTarget(targetNode)
→ getClosestInstanceFromNode(targetNode)
→ 从 DOM 节点找到对应的 Fiber(通过 internalInstanceKey)
→ 拿到 Overlay 的 Fiber
→ return_targetInst = overlayFiber
↓
dispatchEventForPluginEventSystem(...)
→ targetInst = overlayFiber
→ targetContainer = body ← 注意:事件监听器在 body 上!
↓
// 关键循环:从 targetFiber 沿 return 链向上找,
// 确定事件应该从哪个 Fiber 开始冒泡
mainLoop: while (true) {
node = overlayFiber
↓
node.return → modalFiber
node.return → portalFiber ← tag === HostPortal!
→ container = portalFiber.stateNode.containerInfo (= body)
→ isMatchingRootContainer(body, body) → true!
→ break! 找到了匹配的 rootContainer
// 但如果 Portal 的 container 不匹配当前 targetContainer:
node.return → portalFiber
→ container = portalFiber.stateNode.containerInfo
→ isMatchingRootContainer(container, targetContainer) → false!
→ 继续向上找 grandNode:
grandNode = portalFiber.return (= appFiber)
→ grandNode.tag === HostRoot
→ grandContainer = rootContainer
→ isMatchingRootContainer(grandContainer, targetContainer) → ?
}
↓
// 找到正确的 ancestorInst 后
dispatchEventsForPlugins(...)
→ extractEvents()
→ 从 ancestorInst 开始,沿 return 链收集所有同类型事件的 listener
→ 收集到 Portal → App 上的 onClick 处理函数!
// 注意:收集走的是 Fiber return 链,不是 DOM 冒泡
→ processDispatchQueue()
→ 按顺序执行所有 listener
→ Portal 外的 App 组件的 onClick 被触发
更完整的流程------跨越 Portal 容器边界:
ini
场景:Portal 的 container 是 body,但事件监听器在 root div 上触发
(比如 root div 也有一个 listener,或者事件从 Portal 外部冒泡到 root)
dispatchEventForPluginEventSystem 中的 Portal 跨越逻辑(第 634-682 行):
遇到 HostPortal 节点:
→ container = portalFiber.stateNode.containerInfo (= body)
→ isMatchingRootContainer(body, targetContainerNode=rootDiv) → false!
→ 检查 Portal 的祖先是否匹配 targetContainer:
grandNode = portalFiber.return
→ grandNode.tag === HostRoot
→ grandContainer = rootNode.stateNode.containerInfo (= rootDiv)
→ isMatchingRootContainer(rootDiv, rootDiv) → true!
→ return! ← 这个 Portal 的事件会被 rootDiv 的监听器处理
不需要特殊跨越
另一个场景:事件从 Portal 的 DOM 容器冒泡上来
→ targetContainer = body
→ 从 Portal 内的 Fiber 向上遍历
→ 遇到 HostPortal,container = body
→ isMatchingRootContainer(body, body) → true → break!
→ ancestorInst 设为 Portal 内的 target Fiber
→ 事件正常沿 Fiber 树冒泡(Portal → App)
关键源码定位:
1. 事件监听器注册在 root 和 Portal 容器上 react@18.3.1 · react-dom-bindings/src/events/ReactDOMEventListener.js 每个 React root 和 Portal 的 containerInfo 都会注册事件监听器。所以 Portal 内的事件会被两个监听器捕获:Portal 容器上的和 root 容器上的(如果 DOM 冒泡能到达)。
2. Portal 边界跨越 react@18.3.1 · react-dom-bindings/src/events/DOMPluginEventSystem.js · dispatchEventForPluginEventSystem(第 628-686 行) 核心的 mainLoop 循环:遇到 HostPortal 时检查 container 是否匹配当前 targetContainer。匹配则 break(正常处理),不匹配则沿 DOM 树继续找祖先。
3. Portal Fiber 在 beginWork 中 react@18.3.1 · react-reconciler/src/ReactFiberBeginWork.js · updatePortalComponent(第 3631-3654 行) pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo) --- 设置 Portal 的容器上下文,让子组件渲染到正确的 DOM 容器。
4. Portal 在 completeWork 中 react@18.3.1 · react-reconciler/src/ReactFiberCompleteWork.js(第 1663-1671 行) popHostContainer + updateHostContainer --- Portal 不创建 DOM(子 DOM 直接 append 到 containerInfo),只做上下文切换。
第 4 段:设计动机与权衡
为什么不让事件跟随 DOM 树冒泡?
因为 Portal 的核心价值是"逻辑上属于父组件,物理上在别处"。如果事件也只在物理 DOM 树中冒泡,那 Portal 外部的父组件永远无法捕获 Portal 内的事件,Portal 就变成了一个"事件黑洞"------你可以把 Modal 渲染到 body,但 App 上的 onClick 捕获不到 Modal 内的点击。
React 的选择:事件冒泡跟随 Fiber 树(逻辑树),不跟随 DOM 树(物理树) 。这让 Portal 对外部组件完全透明。
为什么需要那么复杂的 mainLoop 逻辑?
因为一个页面上可能有多个 React root 和多个 Portal ,每个都有自己的事件监听器。同一个原生事件可能被多个监听器捕获。mainLoop 的作用是确定"这个事件应该从哪个 Fiber 开始冒泡",避免同一个 React 合成事件被重复触发。
核心权衡:
- 牺牲了: 事件处理逻辑复杂度增加(需要处理 Portal 边界、多个容器、重复触发等问题)
- 换来了: Portal 的行为对开发者透明------你不需要知道子组件被渲染到了哪个 DOM 容器,事件冒泡的行为和普通组件完全一致
- 原生 stopPropagation 的限制: 在 Portal 内调用
nativeEvent.stopPropagation()会阻止原生冒泡(到 body),但不影响 React 合成事件的 Fiber 冒泡(到 App)
第 5 段:次级误解和边界
误解 1:Portal 内的事件不会触发 document/body 上的原生监听器。
实际会触发。Portal 只是改变了 React 合成事件的冒泡路径,原生 DOM 冒泡完全不受影响。Portal 内的 click 会正常冒泡到 body → html → document。只是 React 的事件收集不会跟随这个 DOM 路径。
误解 2:Portal 的 container 必须是 document.body。
实际可以是任何 DOM 节点。Portal 的 containerInfo 只需要是一个有效的 DOM 容器。常见场景包括 body(Modal)、iframe.contentDocument(跨 iframe 渲染)、甚至另一个 React root 的容器。
边界条件:嵌套 Portal 的事件
如果 Portal A 内部又有一个 Portal B(渲染到另一个容器),事件处理的 mainLoop 会继续向上遍历,跳过不匹配的 Portal 容器,直到找到匹配的 targetContainer。每一层 Portal 都会注册自己的事件监听器,确保事件不会丢失。
现在我们知道了 Portal 的事件冒泡走 Fiber return 链而非 DOM 树------dispatchEventForPluginEventSystem 通过 mainLoop 跨越 HostPortal 边界,确保 Portal 内的事件能冒泡到 Fiber 树中的父组件,同时不重复触发。
模块 8 已经讲了 6 个考点,还剩最后一个。React 18 引入了 use() hook,它统一了 Suspense 和 Context 的消费方式------这就是 8.7 React 18 的 use() hook 要处理的事情。
源码已定位。现在讲 8.7 React 18 的 use() hook。
考点 8.7:React 18 的 use() hook
第 0 段:直觉锚定
想象一个万能插座适配器。它不关心你插的是哪种插头------两脚扁插(Promise)也好,三脚圆插(Context)也好------它都能适配,给你输出电(值)。
use() 就是 React Hook 世界里的这个万能适配器。它接受两种"插头":
- Promise:如果已 resolve,直接返回值;如果 pending,throw 出去触发 Suspense
- Context :直接读取 context 值(等价于
useContext)
关键区别于其他 Hook:use() 可以在条件语句和循环中调用 。useState、useEffect 们必须在组件顶层调用(Hook 规则),但 use() 不受此限制------因为它不需要在 Hooks 链表中保持位置。
第 1 段:问题背景
在 use() 之前,React 有两个独立的"读取外部数据"机制:
useContext:读取 Context,但不可以在条件语句中调用React.lazy+ Suspense:等待异步模块加载,但只能用于组件级别的懒加载
它们无法统一:
- 无法在条件语句中读取 Context(违反 Hook 规则)
- 无法在组件内部等待一个 Promise 的结果(只能通过
React.lazy或数据框架的 Suspense 集成) - 异步组件(
async function Component())只在 Server Components 中支持,Client Components 不能用
use() 的目标:一个 API 统一两种"读取" ------Promise 和 Context,并且打破 Hook 规则的位置限制。
⚠️ 常见先入为主的误解: 很多人以为
use()让 Client Components 可以变成 async 函数。实际不行。use()本身是同步的------它不是await。它通过"同步 unwrap 已 resolved 的 Promise"或"throw pending Promise 触发 Suspense"来工作。Client Component 仍然是普通函数,不是 async 函数。
第 2 段:核心数据结构
use() 不使用 Hooks 链表。它使用独立的 ThenableState 机制:
ini
ThenableState(存储在 fiber.dependencies 上):
dev: { didWarnAboutUncachedPromise: boolean, thenables: Array<Thenable> }
prod: Array<Thenable> ← 就是一个 thenable 数组
Thenable 的 expando 属性(React 注入的):
thenable.status = 'pending' | 'fulfilled' | 'rejected'
thenable.value = <resolved value> // status === 'fulfilled' 时
thenable.reason = <rejected error> // status === 'rejected' 时
两个模块级变量(不在 Hook 链表中):
ini
thenableIndexCounter: number = 0 ← 当前组件中 use() 的调用位置索引
thenableState: ThenableState | null = null ← 当前组件的 thenable 缓存
use() 的分支逻辑:
csharp
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
// Promise → useThenable()
return useThenable(thenable);
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
// Context → readContext()
return readContext(context);
}
}
throw new Error('Unsupported type');
}
三个调用位置的实例:
php
function Profile({userId}) {
// use() 可以在条件语句中!
if (userId !== null) {
const user = use(fetchUser(userId)); // thenableIndexCounter = 0
// 如果 fetchUser 的 Promise 已 resolve → 直接返回 user
// 如果 pending → throw SuspenseException → 触发 Suspense fallback
}
const theme = use(ThemeContext); // 走 readContext,和 useContext 等价
const posts = use(fetchPosts(userId)); // thenableIndexCounter = 1
// thenableState 记录 [fetchUserPromise, fetchPostsPromise]
// 重渲染时通过 index 对比:同一个位置的 thenable 复用旧结果
}
第 3 段:运行流程
路径 A:use(Promise) --- thenable 已 resolved
ini
use(fetchUser(userId)) // 假设 Promise 已 resolved
↓
use(usable)
→ typeof usable.then === 'function' → useThenable(thenable)
→ index = thenableIndexCounter++
→ if thenableState === null: thenableState = createThenableState()
→ trackUsedThenable(thenableState, thenable, index)
→ trackedThenables[index] 不存在 → push(thenable)
→ thenable.status === 'fulfilled'
→ return thenable.value ← 同步返回!无需 Suspense
路径 B:use(Promise) --- thenable pending
ini
use(fetchUser(userId)) // Promise 还在 pending
↓
use(usable)
→ useThenable(thenable)
→ trackUsedThenable(thenableState, thenable, index)
→ thenable.status 不是 'fulfilled' 也不是 'rejected'
→ 给 thenable 注入 status 追踪:
pendingThenable.status = 'pending'
thenable.then(
value => { thenable.status = 'fulfilled'; thenable.value = value },
error => { thenable.status = 'rejected'; thenable.reason = error }
)
→ 二次检查 status(防止同步 resolve)
→ 还是 pending:
suspendedThenable = thenable ← 存到模块变量
throw SuspenseException ← 抛出特殊异常!
↓
React 工作循环捕获 SuspenseException
→ throwException(root, returnFiber, sourceFiber, thePromise, ...)
→ 走 Suspense 路径(和 8.3 讲的完全一致)
→ 显示 fallback → Promise resolve → ping 重试
↓
重试渲染时:
renderWithHooks → thenableState 从 fiber.dependencies 恢复
→ useThenable 调用 trackUsedThenable
→ trackedThenables[index] === thenable(同一个 Promise)
→ thenable.status === 'fulfilled'(已经 resolve 了)
→ return thenable.value ← 这次同步返回
路径 C:use(Context)
scss
use(ThemeContext)
↓
use(usable)
→ usable.$$typeof === REACT_CONTEXT_TYPE
→ readContext(context)
→ 和 useContext 完全一致:从 fiber.dependencies 读取 context value
关键源码定位:
1. use() 入口 react@18.3.1 · react-reconciler/src/ReactFiberHooks.js · use(第 1150-1165 行) 三路分支:Promise → useThenable,Context → readContext,其他 → throw Error。
2. thenable 追踪核心 react@18.3.1 · react-reconciler/src/ReactFiberThenable.js · trackUsedThenable(第 107-284 行) 第 193-270 行的 switch:fulfilled → 直接返回值,rejected → throw error,default → 注入 status 追踪 + 检查同步 resolve → 还是 pending → throw SuspenseException。
3. thenable status 注入 react@18.3.1 · react-reconciler/src/ReactFiberThenable.js(第 239-256 行) 给未追踪的 thenable 注入 status/value/reason expando 属性,使其可同步 unwrap。
4. 重渲染时 dispatcher 切换 react@18.3.1 · react-reconciler/src/ReactFiberHooks.js · useThenable(第 1124-1145 行) 当组件因 Suspense 重试时,检查是否还有未处理的 Hooks,决定切回 mount/update dispatcher。
5. thenableState 初始化 react@18.3.1 · react-reconciler/src/ReactFiberHooks.js(第 700-701 行) renderWithHooks 中 thenableIndexCounter = 0; thenableState = null; --- 每次 render 重置。
第 4 段:设计动机与权衡
为什么 use() 不遵守 Hook 规则?
因为 use() 不在 Hooks 链表中操作。它的状态存在 thenableState(一个独立的数组)中,位置通过 thenableIndexCounter 追踪。这个计数器在每次 render 开始时重置为 0,每次 use() 调用递增。
关键约束:条件语句中的 use() 必须和上次渲染调用相同次数 。否则 thenableIndexCounter 对不上,会错误地复用其他位置的 thenable。实际上条件语句中的 use(fetch(...)) 在 Suspense 重试时,如果条件不再满足,会导致问题。React 的建议是:条件中的 use() 应该配合稳定的条件判断(如 if (resource) 而非 if (shouldFetch))。
为什么给 thenable 注入 expando 属性?
性能。如果不注入 status/value,每次 use(promise) 都需要 .then() 等待结果,无法同步 unwrap。通过注入 expando 属性,React 把 Promise 变成了一个"可同步检查状态的 thenable"------一旦 resolve,后续的 use() 调用直接读 thenable.value,无需任何异步操作。
核心权衡:
- 牺牲了:
use()内部实现比普通 Hook 复杂得多(独立的 thenable 追踪系统、dispatcher 动态切换、SuspenseException 特殊处理) - 换来了: 统一的 API(Promise 和 Context 用同一个
use()消费)、可以在条件语句中使用、不需要 async 函数就能处理异步数据 - 与 Hook 规则的关系:
use()是唯一一个可以条件调用的"Hook",但它不是真正的 Hook(不操作 Hooks 链表),所以不违反 Hook 规则
第 5 段:次级误解和边界
误解 1:use() 可以替代所有 useContext。
技术上可以(use(Context) 等价于 useContext(Context)),但有个关键区别:use() 可以在条件语句中调用,useContext 不行。在组件顶层无条件使用时,两者完全等价。但在循环或条件中读取 Context,只能用 use()。
误解 2:use(fetch(...)) 每次渲染都会发起新请求。
如果每次渲染都创建新的 Promise(fetch 返回新 Promise),React 会检测到 thenable 不匹配(trackedThenables[index] !== thenable),触发 uncached promise 警告,并复用旧的 thenable(第 120-156 行)。但如果每次都是相同引用的 Promise(比如缓存的),则直接复用。
正确的使用方式是配合 Suspense 兼容的数据框架(React Query、Relay 等),它们会缓存 Promise。
边界条件:use() 不能被 try/catch 包裹
源码中有明确的检测(第 730-747 行):如果 use() 被用户的 try/catch 包裹,React 会在 DEV 模式下发出警告。因为 use() throw 的 SuspenseException 是内部实现细节,不应该被用户代码捕获。错误处理应该通过 Error Boundary。
模块 8「高级特性」7 个考点全部讲完。
题目考核
题 1
一个函数组件在 render 时 throw 了一个普通 Error。请描述从 throw 开始到最终用户看到 fallback UI 的完整过程。如果最近的 Error Boundary 在渲染 fallback 时也 throw 了,会发生什么?
题 2
Suspense 和 Error Boundary 都通过 throwException 处理异常,但走不同路径。请解释 throwException 如何区分两者,以及为什么 React 选择把这两个机制合并在同一个函数中而不是分成两个独立函数。
题 3
用你自己的话解释 use() 的工作原理,重点说明:为什么它可以在条件语句中调用而其他 Hook 不行?当 use(pendingPromise) 时组件的渲染流程是什么?