React 组件渲染、协调与数据流 / React Components, Rendering, and Data Flow
📅 创建时间:2026-07-28 🏷️ 标签:#React #Rendering #Fiber #Components #Concurrent 📚 前置知识:[[00-overview]]
📋 本章目标
- 理解 React 渲染的三个阶段:Render、Reconcile、Commit,以及它们各自做什么
- 掌握 Fiber 架构的核心思想:可中断渲染、work loop、优先级调度
- 牢记组件纯度的规则:渲染期间不能修改外部、不能发请求、不能操作 DOM
- 理解 Props 只读、State 快照、单向数据流的深层含义
- 掌握 key 的身份标识语义,而不是把它当性能优化技巧
- 理解并发特性(Suspense、startTransition、useDeferredValue)的工作原理
- 建立 memo/useMemo/useCallback 的决策框架,避免过早优化
第1部分:渲染、协调与提交 —— React 的三幕剧
1.1 一次更新的完整旅程
当组件首次挂载或状态更新时,React 不会直接操作 DOM。它先完整计算"UI 应该是什么样",然后只在必要的时候接触真实 DOM。这个过程分为三个严格的阶段:
┌─────────────────────────────────────────────────────────────┐
│ React 更新三阶段 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 触发源:setState / 父组件重渲染 / Context 变化 / 首次挂载 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 阶段一:Render(渲染 / 计算) │ │
│ │ │ │
│ │ React 调用组件函数(或 class 的 render 方法) │ │
│ │ 传入当前 Props 和 State,产出新的 React Element 树 │ │
│ │ │ │
│ │ function Greeting({ name }) { │ │
│ │ return <h1>Hello, {name}</h1> ← 这就是渲染结果 │ │
│ │ } │ │
│ │ │ │
│ │ 关键:Render 阶段是纯净的 —— 没有副作用,不碰 DOM │ │
│ │ 在 Concurrent Mode 下,Render 可以被中断和丢弃 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 阶段二:Reconcile(协调 / 对比) │ │
│ │ │ │
│ │ 将新生成的 Element 树与当前 Fiber 树逐节点对比 │ │
│ │ 判断规则: │ │
│ │ type 不同? → 整个子树重建 │ │
│ │ type 相同? → 复用 DOM 节点,更新 Props │ │
│ │ key 不同? → 视为不同身份,重建 │ │
│ │ │ │
│ │ 输出是一个 effect list(需要执行的 DOM 操作清单) │ │
│ │ 这个阶段不操作 DOM,只标记"哪里需要改" │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 阶段三:Commit(提交 / 应用) │ │
│ │ │ │
│ │ 不可中断(同步执行),分为三个子阶段: │ │
│ │ │ │
│ │ BeforeMutation:读取当前 DOM 快照(getSnapshot) │ │
│ │ Mutation: 执行 DOM 增删改、更新 ref │ │
│ │ Layout: 执行 useLayoutEffect,浏览器绘制 │ │
│ │ 在 Mutation 之后、Paint 之前 │ │
│ │ │ │
│ │ Commit 完成后 → 浏览器绘制 → 执行 useEffect │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘1.2 渲染不等于 DOM 更新
这是 React 初学者最容易产生的误解。React 可以多次调用你的组件函数(Render),但如果在 Reconcile 阶段发现新旧输出完全一致,Commit 阶段会跳过该节点 —— 对应 DOM 没有任何变化。
// 父组件重渲染时,Child 也会被重新调用(Render)
// 但如果它的 props 没变,Commit 阶段会跳过它的 DOM
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<Child name="Alice" /> {/* 每次 count 变,Child 都重渲染 */}
</div>
)
}
function Child({ name }: { name: string }) {
console.log('Child rendered') // 每次都会打印
return <p>{name}</p> // 但 DOM 不会更新(props 没变)
}1.3 同步渲染 vs 并发渲染
┌─────────────────────────────────────────────────────────────┐
│ 同步渲染 vs 并发渲染 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 同步渲染(React 17 及之前默认): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Render]━━━━━━━━━━━━━━━━━━━━━━━━[Commit] │ │
│ │ │ │
│ │ 一次开始就必须一口气完成,中间不能被打断 │ │
│ │ 大型更新会阻塞主线程,导致掉帧、输入卡顿 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 并发渲染(React 18+ Concurrent Mode): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Render]····⏸····[Render]····⏸····[Commit] │ │
│ │ ↑ 中断 ↑ 中断 │ │
│ │ │ │
│ │ React 可以在 Render 中途暂停,处理更高优先级的更新 │ │
│ │ (如用户输入),完成后再继续或丢弃当前渲染 │ │
│ │ Commit 阶段始终同步 —— 不能让用户看到半成品 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:Fiber 架构 —— React 的"可中断引擎"
2.1 为什么需要 Fiber
React 15 使用递归的 Stack Reconciler,遍历组件树的过程不可中断。当组件树很大时,一次递归可能需要 16ms 以上,导致浏览器无法在帧间隔内绘制,用户感知为卡顿。Fiber 架构(React 16+)把协调工作拆分成可中断的小单元,让 React 可以"暂停"渲染,把主线程还给浏览器。
2.2 Fiber Node 的本质
每个组件实例、每个 DOM 元素在 React 内部都对应一个 Fiber node:
┌─────────────────────────────────────────────────────────────┐
│ 一个 Fiber Node 的核心数据结构(概念模型) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ type: 'div' | Greeting | null │ │
│ │ key: null | 'user-42' │ │
│ │ │ │
│ │ // 实例 / DOM 引用 │ │
│ │ stateNode: DOMElement | ComponentInstance │ │
│ │ │ │
│ │ // 树关系(Fiber 是链表树,不是递归树) │ │
│ │ child: Fiber | null ← 第一个子节点 │ │
│ │ sibling: Fiber | null ← 下一个兄弟节点 │ │
│ │ return: Fiber | null ← 父节点 │ │
│ │ │ │
│ │ // 双缓冲:当前屏幕 vs 正在构建 │ │
│ │ alternate: Fiber | null ← 另一棵树中的对应节点 │ │
│ │ │ │
│ │ // 副作用标记 │ │
│ │ flags: Placement | Update | Deletion | ... │ │
│ │ subtreeFlags: ... │ │
│ │ │ │
│ │ // 优先级 │ │
│ │ lanes: number ← 本节点关联的更新优先级 │ │
│ │ childLanes: number ← 子树中的更新优先级 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Fiber 树的遍历方式(深度优先、可中断): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ App ← return │ │
│ │ / \ │ │
│ │ child sibling │ │
│ │ / \ │ │
│ │ Header Main │ │
│ │ | | │ │
│ │ child child │ │
│ │ │ │
│ │ 遍历顺序:App → Header → (回到 App) → Main │ │
│ │ 每个 Fiber 处理完就检查:还有时间吗?要不要暂停? │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.3 Work Loop 与时间切片
Fiber 的核心调度机制是一个循环,React 称之为 work loop:
┌─────────────────────────────────────────────────────────────┐
│ Work Loop:每 5ms 检查一次是否要交出控制权 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ function workLoop(deadline) { │ │
│ │ while (nextUnitOfWork && │ │
│ │ deadline.timeRemaining() > 5) { │ │
│ │ nextUnitOfWork = performUnitOfWork( │ │
│ │ nextUnitOfWork │ │
│ │ ) │ │
│ │ } │ │
│ │ if (nextUnitOfWork) { │ │
│ │ // 还有工作没做完,请求下一个空闲回调 │ │
│ │ requestIdleCallback(workLoop) │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ performUnitOfWork(fiber): │ │
│ │ 1. beginWork(fiber) —— 协调当前节点 │ │
│ │ 2. 深度优先找下一个 fiber │ │
│ │ → 先 child,没有则 sibling, │ │
│ │ 都没有则向上 return 找叔叔 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键:React 实际上使用自己的 Scheduler 包实现时间切片, │
│ 而不是直接用 requestIdleCallback(后者兼容性差、优先级低) │
│ │
└─────────────────────────────────────────────────────────────┘2.4 Lane 模型 —— 优先级调度
React 使用 31 位二进制数(lane)来表示更新的优先级。位运算使得"比较优先级""合并更新""判断是否包含某优先级"都是 O(1):
// Lane 的概念模型(简化)
// 数字越小优先级越高
// ┌─────────────────────────────────────────────────┐
// │ SyncLane = 0b0000000000000000000001 │ → 同步(点击、输入)
// │ InputContinuousLane = 0b0000000000000000000100 │ → 连续输入
// │ DefaultLane = 0b0000000000000000010000 │ → 默认(setState)
// │ TransitionLane = 0b0000000000000010000000 │ → startTransition
// │ IdleLane = 0b0010000000000000000000 │ → 空闲时执行
// └─────────────────────────────────────────────────┘
// 当高优先级更新到达时:
// 1. 检查当前正在渲染的 lanes 是否包含更高优先级的更新
// 2. 如果是,中断当前渲染,先处理高优先级更新
// 3. 被中断的低优先级渲染可以稍后恢复(或丢弃)Lane 模型是 Concurrent Mode 的基础。它让 React 能够在"响应用户输入"和"渲染大量数据"之间做出取舍,始终让页面保持响应。
第3部分:组件必须保持纯净
3.1 纯函数的定义
在 React 中,一个组件是"纯净的"意味着:给定相同的 Props、State 和 Context,组件总是产出相同的 JSX。组件应该是 props → JSX 的确定性映射。
┌─────────────────────────────────────────────────────────────┐
│ 纯组件 vs 不纯组件 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ 纯组件: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ function Price({ amount, currency }: PriceProps) { │ │
│ │ // 仅依赖输入,输出由输入唯一决定 │ │
│ │ const formatted = new Intl.NumberFormat( │ │
│ │ 'zh-CN', { style: 'currency', currency } │ │
│ │ ).format(amount) │ │
│ │ return <output>{formatted}</output> │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ❌ 不纯组件: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ let counter = 0 // 模块级可变变量! │ │
│ │ │ │
│ │ function ImpureBadge() { │ │
│ │ counter++ // 每次渲染都改变外部世界 │ │
│ │ return <span>渲染了 {counter} 次</span> │ │
│ │ } // 同样的 props,每次调用结果不同 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.2 渲染期间绝对不能做的事
React 的 Render 阶段可以随时被中断、丢弃、重来。在并发模式下,React 甚至可能多次调用你的组件来检测副作用。因此在渲染期间:
┌─────────────────────────────────────────────────────────────┐
│ 渲染期间 (Render Phase) 的禁令 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 修改外部变量或模块状态 │
│ ❌ 发起网络请求(fetch / axios) │
│ ❌ 直接操作 DOM(document.getElementById / innerHTML) │
│ ❌ 调用 setState 触发新的渲染(可能导致无限循环) │
│ ❌ 设置计时器(setTimeout / setInterval) │
│ ❌ 读写 localStorage / sessionStorage │
│ ❌ 修改传入的 Props 或 ref.current(在渲染输出前) │
│ │
│ 这些操作应该在哪里执行? │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Event Handler → 响应用户交互 │ │
│ │ useEffect → 同步外部系统(浏览器绘制后) │ │
│ │ useLayoutEffect → 在浏览器绘制前同步执行 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.3 纯组件的好处
- 可预测:Props 出了结果一定对,调试时不需要排查外部状态。
- 可并发:React 可以安全地中断和重试渲染,不会产生副作用。
- 可测试:给输入验证输出,不需要 mock 任何全局变量。
- Strict Mode 检查:React 18+ 在开发环境下会双重调用组件函数,暴露不纯的逻辑。
第4部分:Props 是只读输入
4.1 单向数据流
React 的数据流是严格单向的:父组件通过 Props 把数据"向下"传递,子组件通过回调函数把意图"向上"传达。
// ✅ 正确的单向数据流
type TodoItemProps = {
todo: { id: string; title: string; done: boolean }
onToggle: (id: string) => void
}
function TodoItem({ todo, onToggle }: TodoItemProps) {
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)} // 表达意图,不直接修改
/>
{todo.title}
</label>
)
}4.2 不要修改 Props
┌─────────────────────────────────────────────────────────────┐
│ 为什么不能修改 Props │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ todo.done = true // 直接修改 Props 对象 │
│ ❌ props.list.push(item) // 修改 Props 中的数组 │
│ ❌ props.config.theme = 'dark' // 修改嵌套对象 │
│ │
│ 后果: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. React 不知道数据变了 → 不会重新渲染 │ │
│ │ 2. 数据流变得无法追踪 → "这个值到底是谁改的?" │ │
│ │ 3. 父组件持有的引用被破坏 → 后续对比失效 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 正确做法:子组件通过回调表达意图,父组件负责更新 │
│ │
└─────────────────────────────────────────────────────────────┘第5部分:State 是一次渲染的快照
5.1 setState 请求下一次渲染
这是 React 中最重要的心智模型之一。当你调用 setState 时,你当前闭包中的变量不会立即改变:
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1) // count = 0
setCount(count + 1) // count 仍然是 0!
console.log(count) // 打印 0,不是 1
}
// 点击一次,count 变成 1,不是 2
// 因为两次 setCount(count + 1) 都读取的是当前渲染的快照值 0
}┌─────────────────────────────────────────────────────────────┐
│ State 快照语义 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 渲染 1(count = 0) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户点击按钮 │ │
│ │ setCount(0 + 1) → React:下次渲染 count = 1 │ │
│ │ setCount(0 + 1) → React:下次渲染 count = 1 │ │
│ │ console.log(0) → 当前闭包中 count 仍是 0 │ │
│ │ │ │
│ │ React 批处理这两次 setState,最终 count = 1 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 渲染 2(count = 1) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 组件用新的 count = 1 重新执行 │ │
│ │ 新的闭包中 count = 1,与上一次渲染完全隔离 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘5.2 函数式更新
当新状态依赖旧状态时,使用函数式更新确保拿到最新的计算值:
// ❌ 基于当前快照值
setCount(count + 1)
setCount(count + 1)
// 结果:count 只增加 1
// ✅ 基于更新队列中的最新值
setCount(prev => prev + 1)
setCount(prev => prev + 1)
// 结果:count 增加 2
// React 把这两次 update function 放入队列,按顺序执行必须使用函数式更新的场景:
- 在同一个事件处理中多次更新同一个 state
- 在 setTimeout/Promise 回调中更新(这些回调捕获了旧的闭包值)
- 在 useEffect 中更新,且依赖了该 state
5.3 批量更新(Batching)
React 18+ 默认对所有更新进行自动批处理:
┌─────────────────────────────────────────────────────────────┐
│ 自动批处理(Automatic Batching) │
├─────────────────────────────────────────────────────────────┤
│ │
│ React 17:只有事件处理中的更新会被批处理 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ setTimeout(() => { │ │
│ │ setCount(c => c + 1) // 触发渲染 │ │
│ │ setFlag(true) // 再次触发渲染 │ │
│ │ // React 17: 两次渲染! │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ React 18+:所有更新都自动批处理 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ setTimeout(() => { │ │
│ │ setCount(c => c + 1) │ │
│ │ setFlag(true) │ │
│ │ // React 18: 一次渲染! │ │
│ │ }) │ │
│ │ │ │
│ │ // 如果确实需要强制同步刷新(极少见): │ │
│ │ flushSync(() => setCount(c => c + 1)) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第6部分:Key 的深层含义
6.1 Key 是身份标识,不是性能技巧
React 文档中经常说"key 帮助 React 识别哪些元素改变了",这容易让人误以为 key 是某种性能优化。实际上,key 是兄弟节点之间的唯一身份标识。没有 key(或使用错误的 key),React 会错判两个节点的身份关系。
┌─────────────────────────────────────────────────────────────┐
│ Key 决定"谁是谁" │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景:列表重排序 [A, B, C] → [B, C, A] │
│ │
│ 没有 key(使用 index): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ index=0: 原来是 A,现在是 B → React 认为: │ │
│ │ "index=0 的节点还是同一个,只是内容变了" │ │
│ │ 更新 A 的 DOM 为 B 的内容 │ │
│ │ │ │
│ │ index=1: 原来是 B,现在是 C → 更新 DOM │ │
│ │ index=2: 原来是 C,现在是 A → 更新 DOM │ │
│ │ │ │
│ │ 结果:每个节点都更新了 DOM,全部重建 │ │
│ │ 如果有输入框,输入状态会错位到错误的行! │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 有正确的 key(基于业务 ID): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ key='a': 之前在第0位,现在在第2位 → React 知道: │ │
│ │ "节点 A 换了位置,但身份没变" │ │
│ │ 只需移动 DOM 元素的位置,无需重建 │ │
│ │ │ │
│ │ key='b': 之前在第1位,现在在第0位 → 移位置 │ │
│ │ key='c': 之前在第2位,现在在第1位 → 移位置 │ │
│ │ │ │
│ │ 结果:DOM 节点被移动而非重建,输入状态保持正确 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.2 为什么不能用 index
// ❌ 危险:使用 index 作为 key
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
// 当列表头部插入一项时:
// 之前: index=0→"买牛奶", index=1→"写代码"
// 之后: index=0→"取快递"(新), index=1→"买牛奶", index=2→"写代码"
// React 认为 index=0 的节点从"买牛奶"变成了"取快递"——内容更新
// index=1 的节点从"写代码"变成了"买牛奶"——内容更新
// 实际上只是插入了一项,其余没变!
// 如果有未受控的输入框,输入内容会错位6.3 Key 只在当前列表层级有意义
// key 不会作为 prop 传入组件
function TodoItem(props) {
console.log(props.key) // undefined —— key 不是 prop
}
// 如果需要业务 ID 在组件内使用,单独传递:
{todos.map(todo => (
<TodoItem key={todo.id} id={todo.id} todo={todo} />
))}
// 不同列表的 key 不需要全局唯一
<div>
{users.map(u => <User key={u.id} ... />)} {/* key="1" ~ "5" */}
{posts.map(p => <Post key={p.id} ... />)} {/* key="1" ~ "100" */}
{/* 两个列表有重复 key 没关系,它们在不同父级下 */}
</div>第7部分:并发特性
7.1 Suspense —— 让组件"等待"
Suspense 让 React 可以在组件还没准备好时显示 fallback,而不会阻塞整个页面:
import { Suspense, lazy } from 'react'
// 代码分割
const Dashboard = lazy(() => import('./Dashboard'))
// 数据获取(需要支持 Suspense 的数据库,如 TanStack Query 5+)
function App() {
return (
<Suspense fallback={<Loading skeleton />}>
<Dashboard />
</Suspense>
)
}┌─────────────────────────────────────────────────────────────┐
│ Suspense 边界的工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ App │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ Suspense (fallback=<Spinner />) │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │ │
│ │ │ │ Dashboard (正在加载数据...) │ │ │ │
│ │ │ │ ↓ 抛出 Promise │ │ │ │
│ │ │ │ React 捕获这个 Promise │ │ │ │
│ │ │ │ → Dashboard 不渲染 │ │ │ │
│ │ │ │ → 显示 fallback: <Spinner /> │ │ │ │
│ │ │ │ → Promise resolve 后重试 │ │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Suspense 之外的组件不受影响,正常渲染 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键:Suspense 让"等待"变得声明式 │
│ 不需要 isLoading && <Spinner /> 的命令式条件判断 │
│ │
└─────────────────────────────────────────────────────────────┘7.2 startTransition —— 标记非紧急更新
import { useState, useTransition } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
// 紧急更新:输入框必须立即响应
setQuery(e.target.value)
// 非紧急更新:搜索结果可以等一等
startTransition(() => {
setResults(search(e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <small>搜索中...</small>}
<ResultList results={results} />
</>
)
}┌─────────────────────────────────────────────────────────────┐
│ startTransition 的优先级调度 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户快速输入 "react"(5个字符,5次 onChange): │
│ │
│ 不使用 startTransition: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [渲染r][渲染re][渲染rea][渲染reac][渲染react] │ │
│ │ 每次都要等搜索结果渲染完才能处理下一个字符 │ │
│ │ 输入框卡顿,用户体验差 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 使用 startTransition: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 紧急 [r→e→a→c→t] 输入框始终紧跟键盘 │ │
│ │ 非紧急 [搜索"r"]⏸→[搜索"re"]⏸→...→[搜索"react"] │ │
│ │ 中间的搜索被中断丢弃,只执行最后一次 │ │
│ │ 输入框丝滑,搜索不阻塞 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘7.3 useDeferredValue —— 延迟某个值的更新
useDeferredValue 是 startTransition 的"值"版本。当你无法控制 setState 的调用位置时(比如 props 来自第三方库),使用 useDeferredValue 来延迟某个值的同步:
function SearchResults({ query }: { query: string }) {
// deferredQuery 会"滞后"于 query
// 当 query 快速变化时,deferredQuery 保持在旧值
// React 在空闲时才更新 deferredQuery
const deferredQuery = useDeferredValue(query)
// 使用 deferredQuery 渲染列表
// 这样即使 query 频繁变化,列表重渲染也不会阻塞输入
const results = useMemo(
() => search(deferredQuery),
[deferredQuery]
)
return <ResultList results={results} />
}7.4 Strict Mode 的双重渲染
React 18+ 的 Strict Mode 在开发环境下会双重调用以下内容:
- 组件函数体(Render 阶段)
- 状态更新函数(
setCount(prev => ...)中的 updater) - useState / useReducer 的初始值函数
- useMemo / useCallback 的计算函数
┌─────────────────────────────────────────────────────────────┐
│ Strict Mode 双重调用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 目的:在开发时暴露不纯的组件逻辑 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ function Counter() { │ │
│ │ console.log('rendering') // 开发模式下打印两次 │ │
│ │ │ │
│ │ const [count, setCount] = useState(() => { │ │
│ │ console.log('initializer') // 打印两次 │ │
│ │ return 0 │ │
│ │ }) │ │
│ │ │ │
│ │ return <div>{count}</div> │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 这确保你的组件在并发模式下被中断后重新渲染时: │
│ • 不会产生错误的副作用(如重复计数) │
│ • 不会依赖"渲染只执行一次"的假设 │
│ │
│ 只在开发环境生效,生产构建不受影响 │
│ │
└─────────────────────────────────────────────────────────────┘第8部分:memo / useMemo / useCallback 决策框架
8.1 它们解决了什么问题
┌─────────────────────────────────────────────────────────────┐
│ memo / useMemo / useCallback 职责 │
├─────────────────────────────────────────────────────────────┤
│ │
│ React.memo(Component) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 对组件进行浅层 props 比较,跳过无变化的渲染 │ │
│ │ │ │
│ │ 使用场景: │ │
│ │ • 组件经常渲染但 props 很少变化 │ │
│ │ • 组件的渲染开销很大(大型列表、图表、动画) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ useMemo(fn, deps) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 缓存计算结果,deps 不变时直接返回缓存值 │ │
│ │ │ │
│ │ 使用场景: │ │
│ │ • 计算开销大(filter + sort + map 大数组) │ │
│ │ • 作为其他 Hook 的依赖,需要引用稳定性 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ useCallback(fn, deps) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 缓存函数引用,deps 不变时返回同一个函数 │ │
│ │ │ │
│ │ 使用场景: │ │
│ │ • 传给 memo 子组件的回调,避免子组件不必要渲染 │ │
│ │ • 作为 useEffect 的依赖 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘8.2 决策树 —— 什么时候该用
┌─────────────────────────────────────────────────────────────┐
│ memo / useMemo / useCallback 决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的组件重渲染频繁吗? │
│ │ │
│ ├── 不频繁 → 什么都不要加,保持代码简洁 │
│ │ │
│ └── 频繁 ↓ │
│ │ │
│ ├── 子组件 props 没变但跟着渲染了? │
│ │ └── 给子组件加 React.memo │
│ │ └── 如果传了内联对象/函数/JSX作为props │
│ │ └── 用 useMemo / useCallback 包裹 │
│ │ │
│ ├── 组件内有重计算(大数组 filter/sort/reduce)? │
│ │ └── 用 useMemo 包裹计算结果 │
│ │ │
│ └── 回调函数作为 useEffect 的依赖? │
│ └── 用 useCallback 保持引用稳定 │
│ │
│ ⚠️ 常见误区: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ❌ 给所有组件加 memo → 比较也有开销 │ │
│ │ ❌ 所有值都 useMemo → 内存中存了大量缓存值 │ │
│ │ ❌ 所有函数都 useCallback → 闭包捕获更多变量 │ │
│ │ │ │
│ │ ✅ 先用 React DevTools Profiler 测量,再决策 │ │
│ │ ✅ 通常"提升状态"或"组件拆分"比 memo 更有效 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘8.3 比较开销 vs 重渲染开销
// ❌ 过度优化:计算比渲染便宜多了
function UserName({ user }: { user: User }) {
const fullName = useMemo(
() => `${user.firstName} ${user.lastName}`,
[user.firstName, user.lastName]
)
return <span>{fullName}</span>
}
// 字符串拼接的代价 < useMemo 的 deps 比较 + 闭包内存
// ✅ 合理使用:计算开销确实大
function FilteredList({ items, query }: Props) {
const filtered = useMemo(() => {
return items
.filter(item => matchesQuery(item, query))
.sort(byPriority)
.map(withMetadata)
}, [items, query])
return <List items={filtered} />
}
// 对 10000 条记录的 filter+sort+map,useMemo 完全值得第9部分:受控组件与非受控组件
9.1 两种模式
┌─────────────────────────────────────────────────────────────┐
│ 受控 vs 非受控组件 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 受控组件(Controlled) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 表单的值由 React State 驱动 │ │
│ │ │ │
│ │ function Form() { │ │
│ │ const [email, setEmail] = useState('') │ │
│ │ return ( │ │
│ │ <input │ │
│ │ value={email} │ │
│ │ onChange={e => setEmail(e.target.value)} │ │
│ │ /> │ │
│ │ ) │ │
│ │ } │ │
│ │ │ │
│ │ React 是数据的唯一真实来源(Single Source of Truth)│ │
│ │ 适合:需要实时校验、格式化、条件禁用提交按钮 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 非受控组件(Uncontrolled) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DOM 自己管理状态,React 只在需要时读取 │ │
│ │ │ │
│ │ function Form() { │ │
│ │ const inputRef = useRef<HTMLInputElement>(null) │ │
│ │ function handleSubmit() { │ │
│ │ console.log(inputRef.current?.value) │ │
│ │ } │ │
│ │ return ( │ │
│ │ <input defaultValue="" ref={inputRef} /> │ │
│ │ ) │ │
│ │ } │ │
│ │ │ │
│ │ 适合:简单表单、文件上传、与第三方 DOM 库集成 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘9.2 常见陷阱:派生状态
// ❌ 反模式:把 props 复制进 state
function Profile({ user }: { user: User }) {
const [name, setName] = useState(user.name) // 仅仅在首次渲染时同步
// 当 user prop 变化时,name state 不会自动更新
// 两份数据不同步,这是 React 中最常见的 bug 之一
}
// ✅ 正确做法 1:直接使用 props(不要复制)
function Profile({ user }: { user: User }) {
return <input value={user.name} onChange={...} />
}
// ✅ 正确做法 2:如果确实需要本地副本,明确语义并处理同步
function Profile({ user, onUpdate }: Props) {
const [draft, setDraft] = useState(user.name)
// 当外部 user 变化时,重置草稿
useEffect(() => {
setDraft(user.name)
}, [user.id]) // 注意:用 id 而不是整个 user 对象
}第10部分:组合模式
10.1 Compound Components(复合组件)
复合组件让多个组件隐式共享状态,对外暴露灵活的 API:
// 定义复合组件
type TabsContextType = {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>
}
function Tab({ tab, children }: { tab: string; children: React.ReactNode }) {
const ctx = useContext(TabsContext)!
return (
<button
role="tab"
aria-selected={ctx.activeTab === tab}
onClick={() => ctx.setActiveTab(tab)}
>
{children}
</button>
)
}
function TabPanels({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
function TabPanel({ tab, children }: { tab: string; children: React.ReactNode }) {
const ctx = useContext(TabsContext)!
if (ctx.activeTab !== tab) return null
return <div role="tabpanel">{children}</div>
}
// 使用 —— 声明式、可自由组合
<Tabs defaultTab="account">
<TabList>
<Tab tab="account">Account</Tab>
<Tab tab="security">Security</Tab>
</TabList>
<TabPanels>
<TabPanel tab="account"><AccountSettings /></TabPanel>
<TabPanel tab="security"><SecuritySettings /></TabPanel>
</TabPanels>
</Tabs>10.2 Slots 模式(Props 组合)
// 通过 props 暴露可替换的部分
type CardProps = {
header?: React.ReactNode
children: React.ReactNode
footer?: React.ReactNode
}
function Card({ header, children, footer }: CardProps) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
)
}
// 使用
<Card
header={<><Icon name="user" /> Profile</>}
footer={<Button>Save</Button>}
>
<ProfileForm />
</Card>10.3 Render Props vs Hooks
┌─────────────────────────────────────────────────────────────┐
│ Render Props vs Hooks —— 如何选择 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Render Props(React 16 之前的主流复用模式) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ function Mouse({ children }: { │ │
│ │ children: (pos: Point) => ReactNode │ │
│ │ }) { │ │
│ │ const [pos, setPos] = useState({ x:0, y:0 }) │ │
│ │ useEffect(() => { │ │
│ │ const h = (e: MouseEvent) => │ │
│ │ setPos({ x: e.clientX, y: e.clientY }) │ │
│ │ window.addEventListener('mousemove', h) │ │
│ │ return () => window.removeEventListener(...) │ │
│ │ }, []) │ │
│ │ return <>{children(pos)}</> │ │
│ │ } │ │
│ │ │ │
│ │ // 使用: │ │
│ │ <Mouse> │ │
│ │ {pos => <Cursor x={pos.x} y={pos.y} />} │ │
│ │ </Mouse> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Hooks(现代推荐方式) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ function useMouse(): Point { │ │
│ │ const [pos, setPos] = useState({ x:0, y:0 }) │ │
│ │ // ... 同样的 useEffect │ │
│ │ return pos │ │
│ │ } │ │
│ │ │ │
│ │ function Cursor() { │ │
│ │ const pos = useMouse() │ │
│ │ return <div style={{ left: pos.x, top: pos.y }} />│ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 选择原则: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • 复用逻辑 → Hooks(几乎总是) │ │
│ │ • 复用 UI + 逻辑 → 组件 + Composition │ │
│ │ • 需要渲染位置的灵活性 → Render Props(或 children)│ │
│ │ • 新项目 → 默认用 Hooks,只在有明确理由时用 RP │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结1:渲染三阶段是所有 React 行为的基础
React 的每次更新都遵循 Render(计算新的 UI 描述)→ Reconcile(与当前 Fiber 树对比,标记差异)→ Commit(将差异应用到 DOM 并执行副作用)的三阶段流程。理解这个流程是理解"为什么组件被调用但 DOM 没更新""为什么 Effect 在 Commit 后执行""为什么 Render 必须纯净"的前提。
总结2:Fiber 让 React 可以"暂停"
Fiber 把协调工作拆分成以单个节点为粒度的小任务,通过 work loop 和 lane 优先级机制,让 React 可以在"更新一个包含 10000 行的列表"和"响应用户点击"之间做出取舍。这是 Concurrent Mode、Suspense、startTransition 等所有并发特性的底层基础。
总结3:Props 向下,事件向上,State 是快照
React 的数据流有且只有一个方向。不要把 props 复制进 state(除非你非常清楚同步语义),不要试图在子组件中修改父组件的数据,不要在渲染中执行副作用。这些规则不是"最佳实践建议"——它们是 React 能正确运行的前提。
总结4:key 是身份,不是索引
错误的 key 会导致 DOM 重建、输入状态错位、动画异常。key 应该来自业务数据的稳定标识符,而且只在当前兄弟列表中有意义。index 在静态列表中可以接受,但在任何可能重排序、插入、删除的列表中都会引发 bug。
总结5:先测量,再优化
React.memo、useMemo、useCallback 有自己的比较开销和内存开销。在没有性能问题的地方使用它们是净负收益。使用 React DevTools Profiler 定位真正的瓶颈,通常更好的组件拆分和状态提升比 memo 更有效。
章节测试
测试1:以下关于 React 渲染的描述哪个是正确的?
A. Render 阶段直接更新 DOM B. 组件函数被调用意味着对应的 DOM 一定被更新 C. Commit 阶段是同步且不可中断的 D. Reconcile 阶段执行 useEffect 回调
测试2:Fiber 架构的核心价值是什么?
A. 让 React 代码运行得更快 B. 让渲染过程可以被中断和恢复,主线程不被长时间占用 C. 替代虚拟 DOM D. 让组件可以使用 TypeScript
测试3:以下哪些操作在渲染期间是不允许的?(多选)
A. 读取 props B. 调用 setState 触发新渲染 C. 发起 fetch 请求 D. 计算派生值并返回 JSX E. 直接操作 DOM
测试4:以下关于 key 的说法哪个是错误的?
A. key 是兄弟节点之间的唯一身份标识 B. index 作为 key 在列表重排序时可能引发 bug C. key 会作为 props.key 传入组件 D. 不同父级下的 key 不需要全局唯一
测试5:以下关于并发特性的描述哪个是错误的?
A. startTransition 标记的更新可以被高优先级更新中断 B. useDeferredValue 适用于无法控制 setState 位置的场景 C. Strict Mode 的双重渲染只在开发环境执行 D. Suspense 只能用于代码分割,不能用于数据获取
测试6:关于 memo 的使用,以下哪个说法正确?
A. 所有组件都应该用 memo 包裹以获得最佳性能 B. memo 进行深层 props 比较 C. memo 的比较本身也有开销,应该先用 Profiler 测量再决定 D. memo 是 useMemo 的别名
测试7:受控组件和非受控组件的核心区别是什么?
A. 受控组件使用 TypeScript,非受控组件使用 JavaScript B. 受控组件由 React State 驱动值,非受控组件由 DOM 自己管理值 C. 受控组件更慢 D. 非受控组件不能使用 ref
参考答案
测试1答案
答案:C。Commit 阶段是同步且不可中断的(否则用户会看到半成品的 UI)。Render 阶段是纯粹的 JSX 计算,不操作 DOM。组件被调用(Render)不等于 DOM 一定更新——Reconcile 可能发现没有变化。useEffect 在 Commit 和浏览器绘制之后执行。
测试2答案
答案:B。Fiber 架构的核心价值是将递归的、不可中断的协调过程改为可中断的链表遍历。它让 React 可以在处理大型更新时暂停、把控制权还给浏览器处理用户输入,然后恢复或丢弃未完成的工作。这是 Concurrent Mode 的基石。
测试3答案
答案:B、C、E。渲染期间(Render Phase)调用 setState 可能触发无限循环,fetch 请求是副作用,直接操作 DOM 也是副作用。读取 props 和计算派生值是渲染期间的合法操作。所有副作用应该在 Event Handler、useEffect 或 useLayoutEffect 中执行。
测试4答案
答案:C。key 不会作为 props.key 传入组件。如果需要业务 ID 在组件内部使用,需要单独通过 props 传递。key 是 React 内部使用的标识符,专用于兄弟节点之间的 reconciliation。
测试5答案
答案:D。Suspense 既可以用于代码分割(React.lazy),也可以用于数据获取(需要支持 Suspense 的数据获取库如 TanStack Query 5+、Next.js App Router 等)。React 19 的 use() hook 进一步简化了 Suspense 数据获取的模式。
测试6答案
答案:C。memo 进行的是浅层 props 比较(Object.is),本身也有开销。如果父组件每次传入新的对象/函数引用(即使内容相同),memo 也会失败。应该先用 Profiler 确认存在性能问题,再考虑 memo。通常"组件拆分"和"状态提升"比 memo 更根本地解决问题。
测试7答案
答案:B。受控组件的值由 React State 驱动(value={state} + onChange={setState}),React 是单一数据源。非受控组件由 DOM 自身管理值,React 只在需要时通过 ref 读取。文件上传(<input type="file">)天然是非受控的,因为浏览器安全策略不允许 JS 设置文件路径。
相关笔记
- [[02-hooks-state-and-effects]] — Hooks 完整指南
- [[03-routing-forms-and-component-architecture]] — 表单与组件架构实践
- [[05-nextjs-app-router-and-rendering]] — Next.js App Router 与 RSC 渲染
- [[00-overview]] — React 生态全景与学习路线
下一步学习
- [ ] 用 React DevTools Profiler 分析一个包含列表和搜索的项目,观察 Render/Commit 的耗时分布
- [ ] 实现一个 Tabs 复合组件,体会 Context + Compound Components 的 API 设计
- [ ] 在一个有输入框和大型列表的页面中应用 startTransition,对比优化前后的输入响应差异
- [ ] 阅读 Hooks、状态与副作用 深入学习 useState、useEffect 和自定义 Hook
学习状态:🟡 开始学习