React 混合渲染模式下的 Context 穿透:源码解析服务端生成的 Context 值如何在客户端水合过程中恢复

各位好!欢迎来到今天的“React 内部原理深度剖析”特别版。我是你们的老朋友,那个喜欢在源码里找乐子的资深工程师。

今天我们不聊怎么写 useEffect,也不聊怎么优化 useMemo,我们要聊一个听起来很玄学,实际上非常硬核的话题:React 混合渲染模式下的 Context 穿透

特别是:服务端生成的 Context 值,是怎么在客户端水合过程中毫发无损地“认亲”成功的?

这听起来是不是像某种谍战片?没错,这确实是一场谍战。只不过,我们的主角不是007,而是 React 的内部 Fiber 节点和 Context 对象。

准备好了吗?咱们把咖啡杯放下,把代码编辑器打开,咱们来扒一扒 React 的底裤——哦不,是源码。


第一部分:这是什么鬼?混合渲染与 Context 的“水土不服”

首先,咱们得搞清楚背景。什么是混合渲染?简单说,就是 SSR(服务端渲染) + CSR(客户端渲染)

想象一下,你是一个盲人(客户端的浏览器),你在黑暗中摸索。你的朋友(服务端)先帮你搭好了一个积木城堡(HTML),然后你拿到手后,得用你的眼睛(JS)去验证这个城堡是不是和你想象的一样。

这就是“水合”。

现在,咱们引入 Context。

Context 就像是那个传话筒。父组件把值传给传话筒,子组件通过传话筒拿值。

问题来了:
服务端渲染时,传话筒里装的是“红色”。
客户端水合时,传话筒里装的是“蓝色”。

React 怎么知道该把哪个积木(DOM 节点)拼到哪个位置?React 靠的是“引用相等”。如果服务端生成的 HTML 和客户端渲染出来的 HTML 结构一模一样,它就认为水合成功了。

如果 Context 的值变了,虽然你看到的页面可能一样(因为可能用了 useMemo 缓存),但在 React 的眼里,这棵树的“灵魂”已经变了。React 就会崩溃,或者更惨,它会在控制台给你吐出一大堆红色的报错,告诉你:Hydration failed because the initial UI does not match what was rendered on the server.

所以,Context 的穿透,不仅仅是数据传递,更是一致性保证


第二部分:Context 的“出厂设置”与“动态更新”

在深入源码之前,咱们得先看看 Context 对象是怎么创建的。

源码位置:packages/react/src/createContext.ts

export function createContext<T>(
  defaultValue: T,
  calculateChangedBits?: (a: T, b: T) => number,
) {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE,
    _calculateChangedBits: calculateChangedBits,
    _currentValue: defaultValue, // 关键点1:存储当前值
    _currentValue2: defaultValue, // 关键点2:用于双缓冲渲染
    _threadCount: 0,
    Provider: (null: any),
    Consumer: (null: any),
  };

  // Provider 组件
  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  };

  // Consumer 组件
  context.Consumer = context.Provider;

  return context;
}

你看,createContext 返回了一个对象 context。这个对象里有个 _currentValue

注意: 这个 _currentValue可变的。Provider 组件渲染的时候,会把它的 value prop 传进来,然后 React 会把这个值赋给 _currentValue

但是,这里有个巨大的坑。Context 对象本身是引用相等的。也就是说,服务端 createContext 生成的那个 Context 对象,和客户端 createContext 生成的那个 Context 对象,在内存地址上是不一样的!

如果它们不一样,React 怎么知道它们是“同一个” Context 呢?

答案是:React 不需要知道它们是同一个对象,它只需要知道它们是“同一个结构”下的同一个值。

这就涉及到了 React 的核心调度机制:Dispatcher


第三部分:Dispatcher —— 谁在控制 Context?

React 的调度系统里有一个全局变量:ReactCurrentDispatcher.current

在服务端渲染时,这个 current 指向一个名为 ContextOnlyDispatcher 的对象。在这个对象里,Context 的读取函数(比如 readContext)被实现为直接返回默认值。

在客户端渲染时,current 指向 Dispatcher。这时候,readContext 才会真正去读取 _currentValue

这就像是:

  • 服务端是“假唱”,直接念剧本(默认值)。
  • 客户端是“真唱”,通过麦克风(Dispatcher)拿真实信号。

但是! 当水合开始时,React 必须确保客户端的“假唱”和“真唱”的最终结果是完全一致的。

这怎么做到的?这就得请出我们的主角:Provider 的 Fiber 节点


第四部分:Provider 的秘密 —— _context 属性

让我们看看 Provider 组件的源码实现(简化版):

// packages/react/src/ReactFiberContext.js

function updateContextProvider(
  workInProgress,
  context,
  nextValue,
) {
  // ...
  // Provider 的 value 属性被存储在 workInProgress.memoizedProps.value 里
  // 但是,关键在于它关联了 context 对象
  const provider = workInProgress.stateNode;
  provider._context._currentValue = nextValue; // 关键点3:更新 Context 的值
  // ...
}

注意那个 provider._context

当服务端渲染时,React 遍历 Fiber 树,遇到 Provider,它会执行 updateContextProvider。此时,context._currentValue 被设置成了服务端的那个值(比如 red)。

当客户端水合时,React 再次遍历 Fiber 树,遇到同一个 Provider(因为组件结构没变),它会再次执行 updateContextProvider

奇迹发生了:
React 重新把 context._currentValue 设置成了 red

虽然客户端的 Context 对象内存地址变了,但 React 在水合过程中,通过 Fiber 树的遍历,强行把服务端的那个“记忆值”塞回了客户端的 Context 对象里。

这就像是你把一本旧日记本(服务端 DOM)里的内容,完整地抄写到了新笔记本(客户端 DOM)上。虽然笔记本换了,但内容是一样的。


第五部分:processChildContext —— 水合的“黑魔法”

等等,事情没那么简单。Context 是可以嵌套的。父组件设置了一个值,子组件可以覆盖它。如果服务端覆盖了,客户端没覆盖,或者反过来,React 就会疯掉。

React 是怎么防止这种“乱套”的?

这就必须提到 processChildContext 函数。

源码逻辑大概是这样的(概念理解):

  1. 服务端渲染阶段:
    当 React 遍历 Fiber 树,遇到子组件时,它会调用 processChildContext。这个函数会根据父组件的 Context 状态,计算出子组件此时应该看到的 Context 值(如果子组件没有显式定义 Provider,它就继承父级的;如果子组件定义了,它就覆盖)。
    这个计算出来的“纯净版” Context 值,会被 React 存储起来。

  2. 客户端水合阶段:
    React 遍历到同一个子组件时,它会重新调用 processChildContext
    为什么?因为客户端的父组件 Provider 可能更新了值(虽然水合时通常还没更新,或者说是同步的)。
    React 会对比服务端计算出来的值和客户端重新计算出来的值。

如果两者不一致?
报错!Hydration failed!
React 会告诉你:“嘿,我在服务端看到这个组件的 Context 是 A,但我在客户端看到它是 B。这不对劲!”

如果两者一致?
太好了,水合继续。


第六部分:实战演练 —— 代码里的“谍战”

为了让你更直观地理解,咱们写一段代码,然后模拟一下 React 的内心独白。

代码示例

// App.js
import React, { createContext, useContext, useState } from 'react';

// 1. 创建 Context
const ThemeContext = createContext('dark');

function App() {
  const [theme, setTheme] = useState('light'); // 客户端动态切换

  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <MainContent />
    </ThemeContext.Provider>
  );
}

function Header() {
  return <h1>我是 Header,Context 值是 {useContext(ThemeContext)}</h1>;
}

function MainContent() {
  return <div>我是内容,Context 值是 {useContext(ThemeContext)}</div>;
}

export default App;

服务端渲染流程 (SSR)

  1. 初始化: ThemeContext._currentValue = 'dark' (默认值)。
  2. Provider 渲染: React 遍历 App 的 Fiber 树,找到 <ThemeContext.Provider value={theme}>
  3. 更新 Context: React 把 ThemeContext._currentValue 更新为 'dark'
  4. 子组件渲染: 渲染 Header 和 MainContent。
    • 它们调用 useContext
    • readContext 读取到 'dark'
    • 生成 HTML: <h1>我是 Header,Context 值是 dark</h1>, <div>我是内容,Context 值是 dark</div>
  5. 水合准备: React 把这些 HTML 发送到浏览器。它还记住了:“哦,在 <ThemeContext.Provider> 这个层级下,Context 值是 'dark'”。

客户端水合流程 (CSR)

  1. 初始化: 浏览器加载 JS。ThemeContext 被重新创建。此时 ThemeContext._currentValue 还是 'dark' (默认值)。
  2. Provider 渲染: React 重新遍历 Fiber 树,找到 <ThemeContext.Provider value={theme}>
  3. 关键步骤: theme 的值是多少?
    • 如果是初始水合: theme 还是 'dark'(因为服务端传过来的状态还没变)。
    • React 再次调用 updateContextProvider,把 ThemeContext._currentValue 设置为 'dark'
  4. 子组件渲染: Header 和 MainContent 渲染。
    • useContext 读取到 'dark'
    • 生成 DOM: <h1>我是 Header,Context 值是 dark</h1>, <div>我是内容,Context 值是 dark</div>
  5. 水合比对:
    • React 拿着服务端生成的 HTML(<h1>...dark</h1>)和客户端生成的 DOM(<h1>...dark</h1>)做比对。
    • 匹配成功!
    • React 把客户端的 <h1> 节点标记为“已水合”,不再重绘。

如果状态变了呢?

假设用户点击了按钮,setTheme('light')

  1. 客户端重新渲染:
    • ThemeContext._currentValue 被更新为 'light'
    • Header 和 MainContent 重新渲染,生成新的 DOM:<h1>...light</h1>
  2. 水合比对:
    • React 看着服务端发来的 HTML(<h1>...dark</h1>),手里拿着客户端的 DOM(<h1>...light</h1>)。
    • 比对失败!
    • React 会丢弃客户端刚生成的 DOM(或者尝试挂载),并在控制台抛出错误。

这解释了为什么在 SSR 中,你不能在 Context 的 value 里使用动态变量(除非你做了特殊的处理,比如 useMemo 保证引用稳定,或者使用 getServerSideProps 传递初始值)。 否则,每次渲染 Context 都在变,水合就会一直失败。


第七部分:深入源码 —— readContext 的实现

为了更硬核一点,咱们看看 readContext 到底是怎么实现的。

源码:packages/react-reconciler/src/ReactFiberContext.js

function readContext(context, observedBits) {
  // 1. 获取当前的 Dispatcher
  const dispatcher = ReactCurrentDispatcher.current;
  if (dispatcher === null) {
    // 如果没有 Dispatcher(比如在 render 之前调用),抛错
    throw new Error(
      'Invalid hook call. Hooks can only be called inside of the body of a function component.',
    );
  }
  // 2. 调用 Dispatcher 的 readContext 方法
  return dispatcher.readContext(context, observedBits);
}

然后,Dispatcher.readContext 会去读取 context._currentValue

但是! 这里有个更高级的机制:Context Stack(上下文栈)

React 支持嵌套的 Provider。比如:

<ThemeContext.Provider value="red">
  <UserContext.Provider value="Alice">
    <Child />
  </UserContext.Provider>
</ThemeContext.Provider>

当渲染 Child 时,React 会把“红色”和“Alice”都压入栈中。readContext 不仅读取当前的值,还会根据栈的深度找到正确的值。

在水合过程中:
React 必须完美地复刻这个栈结构。如果服务端压栈了 3 层,客户端水合时必须也压栈 3 层,并且顺序一致。只有这样,Child 组件才能拿到正确的值,才能生成正确的 HTML。


第八部分:常见陷阱与解决方案

讲了这么多原理,咱们来聊聊在实际开发中,Context 穿透和水合最常遇到的坑。

陷阱 1:Context Value 引用不稳定

这是最经典的坑。

// ❌ 错误示范
const MyContext = createContext();

function MyComponent() {
  const [count, setCount] = useState(0);
  return (
    <MyContext.Provider value={{ count, setCount }}>
      {/* ... */}
    </MyContext.Provider>
  );
}

每次 count 变化,value 对象都会重新创建(新的引用)。服务端渲染时是 {count: 0},客户端水合时变成了 {count: 1}。React 一对比,发现 Context 的值变了,子组件肯定也变了,但 HTML 没变,于是报错。

✅ 解决方案:
使用 useMemouseCallback 来稳定引用。

// ✅ 正确示范
const MyContext = createContext();

function MyComponent() {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);
  return (
    <MyContext.Provider value={value}>
      {/* ... */}
    </MyContext.Provider>
  );
}

或者,如果 Context 的值只是用来给 useEffect 用,而不是用来触发重渲染,可以把它从 Provider 的 value 里拿出来。

陷阱 2:服务端与客户端默认值不一致

React 规定,如果 Provider 没有提供值,Context 的默认值必须一致。

// 定义
const ThemeContext = createContext('light'); // 默认 light

// 服务端
<ThemeContext.Provider>
  <Header /> {/* 读取到 light */}
</ThemeContext.Provider>

// 客户端
<ThemeContext.Provider>
  <Header /> {/* 读取到 light */}
</ThemeContext.Provider>

如果服务端默认值是 light,客户端是 dark,那 Header 渲染出来的 HTML 就不一样,水合直接炸裂。

陷阱 3:动态的 Context(SSR 中的 Context)

如果你必须在服务端和客户端使用不同的 Context 值,或者 Context 值依赖于服务端数据(比如用户登录状态)。

解决思路:
不要把动态的 Context 值放在 Provider 的 value prop 里,直接用 Provider 包裹。

// ❌ 错误
<UserContext.Provider value={user}> {/* user 是从 API 拉取的动态数据 */}
  <App />
</UserContext.Provider>

// ✅ 正确
<UserContext.Provider>
  <App />
</UserContext.Provider>

然后在 App 组件里,通过 useEffectuseLayoutEffect 去读取 UserContext,并更新状态。

或者,更高级的做法(Next.js App Router):
利用 Next.js 的 getServerSidePropsuseServerInsertedHTML 等特性,在服务端注入特定的 Provider 值,然后在客户端通过 hydrateRoot 传入相同的初始值。


第九部分:源码视角下的“水合一致性”

让我们站在更高维度看这个问题。React 的水合机制本质上是在做Diff 算法的变体。

  1. 生成序列: React 遍历 Fiber 树,生成一个“指令序列”或者“渲染结果序列”。对于 Context 来说,这个序列包含了每个层级 Context 的值。
  2. 比对: 客户端 React 遍历树,生成“当前渲染结果序列”。
  3. 校验: 逐层比对。如果 Context 值不一致,说明 DOM 结构或内容不一致,React 就会回滚,触发重新渲染(通常是客户端全量渲染)。

为什么 React 如此执着于一致性?
因为 React 假设了 DOM 是静态的。它认为一旦 HTML 渲染出来了,它就是“真理”。客户端的任务就是去验证这个“真理”是不是真的。如果 Context 导致了子组件的重新渲染,导致 DOM 变了,那说明服务端的“真理”是错的,或者客户端的渲染逻辑有误。

这就解释了为什么 Context 在 SSR 中是个大麻烦。 它是动态的,而 SSR 依赖的是静态的 HTML。


第十部分:总结与展望

好了,各位听众,咱们今天把 Context 在混合渲染下的水合过程聊得差不多了。

我们来回顾一下这趟旅程:

  1. Context 是什么? 一个可变的全局变量容器。
  2. Context 穿透是什么? 数据从上往下流,通过 Provider 挂载到 Context 对象上,通过 Consumer 读取。
  3. 水合是什么? 客户端拿着服务端的 HTML,试图用 JS 重新构建 DOM,并验证两者是否一致。
  4. 核心矛盾: Context 的动态性 vs 水合的一致性要求。
  5. 解决方案:
    • Provider 的复用: React 通过 Fiber 树结构,确保服务端和客户端的 Provider 是一一对应的。
    • 值的强制同步: updateContextProvider 在水合时会强制把 Context 的 _currentValue 设为与之前一致。
    • 子上下文的计算: processChildContext 确保了嵌套 Context 的层级关系在水合时完美复现。
    • Dispatcher 的切换: 确保“假唱”和“真唱”的接口一致。

最后,给各位一点建议:

虽然 Context 很好用,省去了 props drilling 的痛苦,但在 SSR 场景下,它就像是一把双刃剑。

  • 简单场景: 静态的 Theme, User Info,完全没问题。
  • 复杂场景: 动态的列表数据,依赖 API 的状态,请务必小心。要么用 useMemo 锁住引用,要么干脆放弃 SSR,直接用 CSR,或者使用 Suspense 和流式 SSR 来处理动态内容。

React 的水合机制虽然强大,但也非常“敏感”。它就像是一个洁癖严重的强迫症患者,容不得半点沙子。理解了 Context 的水合原理,你就能更好地理解 React 的设计哲学,也能在遇到报错时,一眼看出它到底在纠结什么。

好了,今天的讲座就到这里。希望大家在未来的开发中,能驾驭 Context 这匹烈马,而不是被它掀翻在地。

谢谢大家!记得点赞收藏,咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注