Dive into React——高级特性

考点 8.1:Error Boundary 原理

第 0 段:直觉锚定

想象一栋大楼的每层楼都装了"防火门"。某一层着火了(组件 throw),烟会顺着楼梯往上飘(沿 return 链冒泡)。遇到第一个有防火门的楼层(Error Boundary),门自动关闭,把火控制在那一层的下方------上面的楼层不受影响,下面的楼层已经被烧毁的也只能重建。防火门关上后,那层楼会亮起"应急灯"(fallback UI),告诉上面的人"这里出了问题,但我们处理好了"。

Error Boundary 就是 React 组件树里的"防火门"。它只能由类组件 充当,因为只有类组件能通过 getDerivedStateFromErrorcomponentDidCatch 这两个生命周期声明"我有防火门"。

⚠️ 常见先入为主的误解: 很多人以为 Error Boundary 是一个独立的 wrapper 组件(比如 <ErrorBoundary>)。实际上它只是一个满足条件的类组件 ------任何定义了 getDerivedStateFromErrorcomponentDidCatch 的类组件都自动成为 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 · unwindUnitOfWorkunitOfWork.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 通过 throwExceptionunwindWork 两步实现错误捕获:第一步沿 return 链找到有 getDerivedStateFromError / componentDidCatch 的类组件并标记 ShouldCapture,第二步在栈展开时转换为 DidCapture 并重新渲染 fallback UI。

但 Error Boundary 只能捕获渲染阶段的错误。如果你需要在命令式代码中把 ref 暴露给父组件,React 提供了 forwardRefuseImperativeHandle------这就是 8.2 forwardRef 与 useImperativeHandle 要处理的事情。


考点 8.2:forwardRef 与 useImperativeHandle

第 0 段:直觉锚定

想象你在公司里用内线电话找人。普通员工(函数组件)没有电话分机号(ref),外面的人没法直接拨进来。forwardRef 就是给这个员工装一部内线电话------让外部能拿到一个"分机号"(ref)。

但这还不够。你不想让外面的人直接翻你的办公桌(直接访问 DOM),而是希望他们通过前台转接(暴露指定方法)。useImperativeHandle 就是"前台接线员"------你告诉前台"只转接 focus() 和 scrollIntoView() 这两个来电",其他请求一律不接。


第 1 段:问题背景

React 的核心设计是数据向下流动:props 从父到子。但有两个场景需要"反向引用":

  1. 焦点管理 :父组件需要调用子组件内部 DOM 节点的 focus()
  2. 命令式 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 = instref(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 之前,异步数据获取有三种方案:

  1. 回调/状态模式 :组件自己 fetchsetState,每个组件独立管理加载状态
  2. 高阶组件模式withData(WrappedComponent),在外层处理加载状态
  3. 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 PromisethrowException 识别 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 要达到的目标:

  1. 复用服务端 HTML,不做不必要的 DOM 操作
  2. 绑定事件处理器,让页面可交互
  3. 恢复组件状态,让 React 接管后续更新
  4. 遇到不匹配时优雅降级,而不是整个应用崩溃

⚠️ 常见先入为主的误解: 很多人以为 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 级别的响应。只有 completeWorkprepareToHydrateHostInstance 执行后(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 的问题:

  1. 所有组件都在服务端执行------即使有些组件完全不依赖服务端数据(比如纯 UI 组件),也必须在 Node.js 中运行
  2. hydration 必须等整个页面------即使大部分内容已经可用,也要等 JS 全部加载并执行
  3. 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() 可以在条件语句和循环中调用useStateuseEffect 们必须在组件顶层调用(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 行) renderWithHooksthenableIndexCounter = 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) 时组件的渲染流程是什么?

相关推荐
冰暮流星1 小时前
javascript之this关键字
开发语言·前端·javascript
余大大.1 小时前
SystemVerilog-参数宏与拼接符的使用
前端
羸弱的穷酸书生1 小时前
跟AI学一手之前端导出
前端·文件导出
怕浪猫1 小时前
Electron 开发实战(十三):性能优化策略|极速启动、低内存、流畅渲染、极致瘦身
前端·javascript·electron
Csvn1 小时前
React useEffect 异步竞态:90% 的人都踩过的坑
前端·react.js
如果超人不会飞1 小时前
用TinyRobot Bubble组件打造灵活强大的AI对话气泡
前端·vue.js
橘子星1 小时前
打破串行枷锁:深入理解 JS 同步、异步与 Promise 实战
前端·javascript
用户059540174461 小时前
LangChain 记忆模块踩坑实录:靠自动化测试,我把上下文丢失率从 30% 降到 0
前端·css