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 上,并执行 useLayoutEffect 和 useEffect。
Commit 阶段分为三个子阶段:
- Before Mutation: 读取 DOM 快照(如
getSnapshotBeforeUpdate) - Mutation: 实际修改 DOM
- 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 树,核心规则:
- 不同类型的元素: 完全销毁旧树并重建。
<div>变为<span>会导致整棵子树重新挂载 - 相同类型的 DOM 元素: 保留节点,只更新变化的属性
- 相同类型的组件: 实例保持不变,更新 props 后重新渲染
- 列表子元素: 通过
key识别哪些元素变化了
// 示例:类型变化导致重新挂载
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 引入了优先级系统。并非所有的状态更新都是平等的。
- 离散事件(Discrete): 点击、输入 - 高优先级,立即处理
- 连续事件(Continuous): 滚动、鼠标移动 - 中优先级
- 过渡更新(Transition): 标记为
startTransition的更新 - 低优先级
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 引用不变时才有效。这就是为什么我们需要 useCallback 和 useMemo——不是为了缓存计算结果,更多是为了保持对象引用地址不变,避免破坏下游组件的 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 调度和不可变数据流,能帮助我们跳出"黑盒",在遇到性能瓶颈或逻辑怪圈时,能够像外科医生一样精准定位病灶。
关键要点回顾:
- 渲染阶段必须保持纯粹,副作用只能在 Effect 中执行
- 理解状态快照机制,避免闭包陷阱,善用函数式更新
- 利用 Fiber 的并发特性,通过 useTransition 优化用户体验
- 严格模式是你的朋友,它帮你发现潜在问题
- 性能优化的核心是减少不必要的渲染,而非过度使用 memo
- 始终使用稳定的 key,列表性能和正确性都依赖它