React 渲染与状态更新的直觉解释

React 的声明式 UI 让我们习惯了"写状态,界面自动变"。但如果不理解底层的渲染流水线,很容易写出性能低下的组件,或遇到"为什么 Effect 跑了两次"、"为什么状态没更新"等怪异现象。本文将深入 React 内部的 Render-Commit 循环及并发渲染机制,建立正确的心智模型。

1. 渲染流水线:Render vs Commit

React 的更新过程严格分为两个阶段:

1.1 Render 阶段:纯计算的世界

Render 阶段(可中断): React 调用你的组件函数,构建新的 Fiber 树,并与旧树进行 Diff (Reconciliation),计算出需要进行的 DOM 变更。这个过程是纯计算的,不涉及任何 DOM 操作,可能会被 React 暂停、废弃或重新执行。

function MyComponent({ count }) {
  console.log('Rendering...'); // 在并发模式下可能打印多次!

  // 渲染阶段只是创建虚拟 DOM
  return <div>{count}</div>;
}

关键点: Render 阶段必须保持纯粹(Pure)。绝对不要在组件函数体内直接发起网络请求或修改 DOM,否则在并发模式下会导致不可预测的副作用。

常见陷阱: 新手容易在渲染期间执行副作用:

// ❌ 错误:在渲染期间修改外部状态
let globalCounter = 0;

function BadComponent() {
  globalCounter++; // 在并发模式下会导致计数不准确
  return <div>{globalCounter}</div>;
}

// ✅ 正确:使用 useEffect 执行副作用
function GoodComponent() {
  useEffect(() => {
    globalCounter++;
  }, []);
  return <div>组件已挂载</div>;
}

1.2 Commit 阶段:与真实 DOM 交互

Commit 阶段(不可中断): React 将 Render 阶段计算出的变更(增删改 DOM)一次性应用到真实 DOM 上,并执行 useLayoutEffectuseEffect

Commit 阶段分为三个子阶段:

  1. Before Mutation: 读取 DOM 快照(如 getSnapshotBeforeUpdate)
  2. Mutation: 实际修改 DOM
  3. Layout: 执行 useLayoutEffect,然后异步调度 useEffect
function EffectDemo() {
  useLayoutEffect(() => {
    console.log('1. Layout Effect - DOM 已更新,但浏览器未绘制');
  });

  useEffect(() => {
    console.log('2. Effect - 浏览器绘制后异步执行');
  });

  return <div>测试</div>;
}

// 输出顺序: 1 → 2

1.3 Reconciliation (协调) 算法

React 使用启发式的 O(n) 算法比较新旧虚拟 DOM 树,核心规则:

// 示例:类型变化导致重新挂载
function Parent({ useDiv }) {
  if (useDiv) {
    return <div><ExpensiveChild /></div>; // 销毁并重建
  }
  return <span><ExpensiveChild /></span>; // ExpensiveChild 被完全重新创建
}

2. 状态更新的批处理与快照机制

setState 不是同步的 setter。它更像是一个"请求"。

2.1 自动批处理 (Automatic Batching)

React 18 之前: 只有在事件处理器中的多次 setState 会被批处理。
React 18 之后: 甚至在 Promise、setTimeout 中的多次 setState 也会被自动合并为一次渲染,极大地减少了不必要的重绘。

// React 18+ 会自动批处理
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 只触发一次渲染,而不是两次
}

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 18: 批处理,只渲染一次
  // React 17: 不批处理,渲染两次
}, 1000);

// 如果需要强制同步刷新(不推荐)
import { flushSync } from 'react-dom';

flushSync(() => {
  setCount(c => c + 1);
}); // 立即渲染
setFlag(f => !f); // 再次渲染

2.2 快照机制 (State Snapshot)

组件的每一次渲染都有自己独立的 Props 和 State。handleClick 中读取的 count 是该次渲染时的常量。

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // 预期:3  实际:1
    // 因为三次 setCount 都读取的是同一个快照 count=0
  }

  return <button onClick={handleClick}>{count}</button>;
}

// ✅ 正确:使用函数式更新
function handleClickCorrect() {
  setCount(c => c + 1); // c = 0, 新值 1
  setCount(c => c + 1); // c = 1, 新值 2
  setCount(c => c + 1); // c = 2, 新值 3
  // 最终: 3
}

闭包陷阱: 异步回调中访问状态时要特别小心。

function DelayedCounter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      // ❌ 这里的 count 永远是点击时的快照
      setCount(count + 1);
    }, 3000);
  }

  // 快速点击 3 次,3 秒后 count 只会变成 1,而不是 3

  // ✅ 解决方案 1: 函数式更新
  setTimeout(() => {
    setCount(c => c + 1);
  }, 3000);

  // ✅ 解决方案 2: 使用 ref
  const countRef = useRef(count);
  useEffect(() => { countRef.current = count; });

  setTimeout(() => {
    setCount(countRef.current + 1);
  }, 3000);
}

2.3 状态更新的优先级

React 18 引入了优先级系统。并非所有的状态更新都是平等的。

import { startTransition } from 'react';

function SearchResults() {
  const [input, setInput] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // 高优先级:输入框立即响应
    setInput(e.target.value);

    // 低优先级:搜索结果可以稍后渲染
    startTransition(() => {
      setResults(expensiveSearch(e.target.value));
    });
  }

  return (
    <>
      <input value={input} onChange={handleChange} />
      <ResultList results={results} />
    </>
  );
}

3. Fiber 架构与并发渲染 (Concurrent React)

Fiber 是 React 16 引入的虚拟 DOM 节点实现,它将渲染任务切分为一个个单元。这奠定了 React 18 并发特性的基础。

3.1 Fiber 节点结构

每个 Fiber 节点是一个 JavaScript 对象,包含:

// 简化的 Fiber 结构
{
  type: 'div',           // 元素类型
  key: 'unique-key',     // 用于 Diff
  props: {...},          // 属性
  stateNode: DOMNode,    // 真实 DOM 引用

  // 链表结构:形成 Fiber 树
  return: parentFiber,   // 父节点
  child: firstChild,     // 第一个子节点
  sibling: nextSibling,  // 下一个兄弟节点

  // 双缓冲
  alternate: oldFiber,   // 对应的旧 Fiber

  // 副作用
  flags: Update | Deletion,  // 标记需要的 DOM 操作
}

3.2 可中断渲染

浏览器主线程需要响应用户输入。Fiber 允许 React 在执行几毫秒渲染工作后,把控制权还给浏览器,等浏览器空闲了再继续。

// React 内部的简化调度逻辑
function workLoop(deadline) {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) {
    // 还有工作未完成,等待下一帧
    requestIdleCallback(workLoop);
  } else {
    // 工作完成,提交到 DOM
    commitRoot();
  }
}

requestIdleCallback(workLoop);

实际应用: 在渲染大型列表时,React 可以在每渲染几行后暂停,让浏览器处理用户滚动,避免页面卡顿。

3.3 useTransition 与 useDeferredValue

这两个 Hook 允许开发者将更新标记为"低优先级"。

// useTransition: 主动标记低优先级更新
function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('about');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab); // 低优先级,可被打断
    });
  }

  return (
    <>
      {isPending && <Spinner />}
      {tab === 'about' && <About />}
      {tab === 'posts' && <Posts />}
    </>
  );
}

// useDeferredValue: 延迟值的更新
function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query);

  // query 立即更新(用于输入框)
  // deferredQuery 延迟更新(用于搜索结果)

  return (
    <>
      <input value={query} />
      <Results query={deferredQuery} />
    </>
  );
}

最佳实践:useTransition 包裹会触发慢速渲染的状态更新(如切换到复杂页面),用 useDeferredValue 延迟派生值的计算。

3.4 并发特性的边界情况

常见陷阱: 并发渲染意味着组件可能被渲染多次后最终被废弃。

function Component() {
  const ref = useRef(0);

  ref.current++; // ❌ 在并发模式下,这个计数会不准确

  // ✅ 正确:在 Effect 中执行副作用
  useEffect(() => {
    ref.current++;
  });
}

4. 严格模式 (Strict Mode) 的良苦用心

很多开发者抱怨严格模式下 useEffect 执行两次。这是 React 特意为之。

4.1 双重调用 (Double Invocation)

它通过双重调用组件和 Effect,强制暴露不纯的副作用和未正确清理的 Effect (如忘记清除定时器)。

<React.StrictMode>
  <App />
</React.StrictMode>

// 开发模式下的执行顺序:
// 1. Mount 组件
// 2. 运行 Effect
// 3. 立即 Cleanup
// 4. 再次运行 Effect

function Example() {
  useEffect(() => {
    console.log('Effect 运行');
    return () => console.log('Cleanup');
  }, []);
}

// 开发环境输出:
// Effect 运行
// Cleanup
// Effect 运行

// 生产环境输出:
// Effect 运行

4.2 检测不纯的渲染

严格模式会在开发环境下双重调用渲染函数,帮助发现意外的副作用。

// ❌ 不纯的组件:会被严格模式暴露
function ImpureComponent() {
  const items = [];
  items.push(Math.random()); // 每次渲染结果都不同
  return <div>{items[0]}</div>;
}

// ✅ 纯组件
function PureComponent() {
  const [random] = useState(Math.random());
  return <div>{random}</div>;
}

4.3 为未来的 Concurrent Features 做准备

React 未来计划引入"卸载后重新挂载"的优化(如缓存组件状态)。严格模式的双重调用模拟了这种场景,确保你的代码能正确处理组件的重新挂载。

如果你在开发环境下发现问题,千万不要通过移除严格模式来掩耳盗铃,而应修复代码逻辑。

5. 性能优化的黄金法则

不要过早优化,但要懂得何时优化。

5.1 组件结构优化:状态下放

State Colocation (状态就近): 如果只有子组件需要更新,不要在父组件管理状态。

// ❌ 状态提升过度:输入框变化导致整个表单重渲染
function Form() {
  const [name, setName] = useState('');
  const [address, setAddress] = useState('');
  const [phone, setPhone] = useState('');

  return (
    <>
      <ExpensiveHeader /> {/* 不必要的重渲染 */}
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={address} onChange={e => setAddress(e.target.value)} />
      <input value={phone} onChange={e => setPhone(e.target.value)} />
    </>
  );
}

// ✅ 状态下放:每个输入框独立管理状态
function Form() {
  return (
    <>
      <ExpensiveHeader /> {/* 不会重渲染 */}
      <FormField name="name" />
      <FormField name="address" />
      <FormField name="phone" />
    </>
  );
}

function FormField({ name }) {
  const [value, setValue] = useState('');
  return <input value={value} onChange={e => setValue(e.target.value)} />;
}

5.2 引用稳定性:React.memo 的正确使用

React.memo 只有在 Props 引用不变时才有效。这就是为什么我们需要 useCallbackuseMemo——不是为了缓存计算结果,更多是为了保持对象引用地址不变,避免破坏下游组件的 memo 优化。

const ExpensiveList = React.memo(function ExpensiveList({ items, onItemClick }) {
  console.log('ExpensiveList 渲染');
  return items.map(item => (
    <div key={item.id} onClick={() => onItemClick(item)}>
      {item.name}
    </div>
  ));
});

function Parent() {
  const [count, setCount] = useState(0);
  const [items] = useState([...]);

  // ❌ 每次渲染都创建新函数,破坏 memo
  const handleClick = (item) => {
    console.log(item);
  };

  // ✅ 使用 useCallback 保持引用稳定
  const handleClick = useCallback((item) => {
    console.log(item);
  }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveList items={items} onItemClick={handleClick} />
    </>
  );
}

5.3 列表 Key 的重要性

Key 必须唯一且稳定。 严禁使用数组索引 (Index) 作为 Key,除非列表完全静态。不稳定的 Key 会导致 React 销毁并重建组件状态,甚至引发输入焦点丢失等 Bug。

// ❌ 使用索引作为 key
{items.map((item, index) => (
  <TodoItem key={index} todo={item} />
))}

// 当删除第一项时:
// 之前: [0: A, 1: B, 2: C]
// 之后: [0: B, 1: C]
// React 认为 key=0 的项从 A 变成 B,key=1 从 B 变成 C,key=2 被删除
// 导致 B 和 C 的组件状态被销毁并重建

// ✅ 使用唯一 ID
{items.map(item => (
  <TodoItem key={item.id} todo={item} />
))}

5.4 虚拟化长列表

渲染成千上万的 DOM 节点会导致页面卡顿。使用虚拟滚动只渲染可见区域。

import { FixedSizeList } from 'react-window';

function LargeList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].name}
        </div>
      )}
    </FixedSizeList>
  );
}

// 10000 个项目,但只渲染可见的约 12 个

5.5 避免内联对象和数组

每次渲染都创建新对象会破坏 memo 和依赖比较。

// ❌ 每次渲染都创建新对象
<Component style={{ margin: 10 }} />
<Component options={['a', 'b']} />

// ✅ 提取到组件外部或使用 useMemo
const STYLE = { margin: 10 };
const OPTIONS = ['a', 'b'];

<Component style={STYLE} />
<Component options={OPTIONS} />

// 或对于动态值
const style = useMemo(() => ({ margin: spacing }), [spacing]);

5.6 使用 Profiler 定位性能瓶颈

import { Profiler } from 'react';

function onRenderCallback(
  id,
  phase,    // "mount" 或 "update"
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  console.log(`${id} 的 ${phase} 阶段耗时 ${actualDuration}ms`);
}

<Profiler id="Dashboard" onRender={onRenderCallback}>
  <Dashboard />
</Profiler>

小结

React 的渲染机制是从"同步递归"向"异步可中断遍历"的演进。理解快照模型、Fiber 调度和不可变数据流,能帮助我们跳出"黑盒",在遇到性能瓶颈或逻辑怪圈时,能够像外科医生一样精准定位病灶。

关键要点回顾: