React中修改 state 时必须返回一个新对象 (immutable update)
文章目录
- 前言
- 核心原因
- 第一步:你调用 setState
- 第二步:setState 将更新入队 (enqueueSetState)
- 第三步:创建更新对象 (createUpdate)
- 第四步:处理更新 (processUpdateQueue)
- 第五步:检查状态变化 (checkShouldComponentUpdate)
- 第六步:浅比较 (shallowEqual) 的原理
- 关键点总结与你的问题
- 扩展
- 核心流程概览
- 为什么函数组件 useState 也强调返回新对象?
- 类组件:setState 合并对象后,使用 shallowEqual(浅比较) 比较新旧状态对象 本身 的引用和一级属性的引用/值。
- 函数组件:useState 直接比较 整个状态值 (newState vs oldState) 使用 Object.is。
- 源码级总结 (useState 的 "Object.is" 哲学):
前言
从 React 源码层面剖析一下 为什么修改 state 时必须返回一个新对象 (immutable update) 才能真正触发页面刷新 (Re-render)。
核心原因
在于:React 使用“浅比较”(shallow comparison) 来检测状态是否变化,以此决定是否需要重新渲染组件。 直接修改现有状态对象的属性不会改变该对象的引用地址,导致浅比较失效。
让我们结合核心源码流程来解释(基于 React 18.2.0 的核心逻辑简化):
第一步:你调用 setState
// 你的代码:在一个类组件中
this.setState({ count: this.state.count + 1 });
第二步:setState 将更新入队 (enqueueSetState)
setState 最终调用 enqueueSetState 函数 (位于 react/src/ReactBaseClasses.js 附近逻辑,实际实现与 Fiber 架构绑定更复杂)。
// React 源码 (简化示意)
Component.prototype.setState = function(partialState) {// ... 其他逻辑this.updater.enqueueSetState(this, partialState);
};
第三步:创建更新对象 (createUpdate)
React 会为这次状态更新创建一个更新对象 (Update),并将你传入的 partialState 保存在其中。
// React 源码 (fiber 相关逻辑,react-reconciler 包中,简化示意)
function enqueueSetState(instance, partialState) {const fiber = getFiber(instance);const update = createUpdate(); // 关键点:创建一个新的更新对象update.payload = partialState; // 把你的 partialState ({count: newValue}) 存进去enqueueUpdate(fiber, update); // 将更新对象加入 fiber 的更新队列scheduleUpdateOnFiber(fiber); // 安排后续的渲染工作
第四步:处理更新 (processUpdateQueue)
在后续的渲染阶段 (调度器决定何时执行),React 会遍历 Fiber 节点上的更新队列。
// React 源码 (fiber 更新处理,react-reconciler 包中,极度简化)
function processUpdateQueue(workInProgress) {const queue = workInProgress.updateQueue;let newState = workInProgress.memoizedState; // 当前 fiber 的旧状态while (queue.firstUpdate !== null) {const update = queue.firstUpdate;const partialState = update.payload; // 取出你之前传的 partialState// 关键合并逻辑:使用 Object.assign 合并状态 (浅层合并)!!!if (partialState ! null && partialState ! undefined) {newState = Object.assign({}, newState, partialState); // 核心合并!!!
// ... 移动队列指针等操作// 最终得到 newStateworkInProgress.memoizedState = newState; // 设置新的状态到 fiber
第五步:检查状态变化 (checkShouldComponentUpdate)
在组件准备重新渲染前,React 会检查状态或 Props 是否变化。核心检查发生在 checkShouldComponentUpdate 或类似逻辑中 (最终调用 shallowEqual)。
// React 源码 (判断是否更新, react-reconciler 或 ReactFiberClassComponent, 简化)
function checkShouldComponentUpdate(workInProgress, newProps, newState) {const instance = workInProgress.stateNode;const oldProps = workInProgress.memoizedProps;const oldState = workInProgress.memoizedState;// 1. 先检查生命周期 shouldComponentUpdate (如果有)if (typeof instance.shouldComponentUpdate === 'function') {return instance.shouldComponentUpdate(newProps, newState, nextContext);
// 2. PureComponent 或默认行为:浅比较 Props 和 Statereturn (!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState) // 这里是关键!!!);
第六步:浅比较 (shallowEqual) 的原理
shallowEqual 是核心关键 (代码通常在 shared/shallowEqual.js)。
// React 源码 (shared/shallowEqual.js)
function shallowEqual(objA, objB) {if (is(objA, objB)) { // is() 就是 Object.is (处理基础值和对象引用)return true; // 相同引用或相同的原始值 => 没变化
// 如果其中一个不是对象或是 null,返回 false (有变化)if (typeof objA ! 'object' |objA = nulltypeof objB ! 'object'
| objB = null) {return false;
const keysA = Object.keys(objA);const keysB = Object.keys(objB);if (keysA.length !== keysB.length) {return false; // 属性个数不同 => 有变化
// 关键循环:只比较最外层属性的值是否引用相等 (或基本值相等)for (let i = 0; i < keysA.length; i++) {const key = keysA[i];if (!hasOwnProperty.call(objB, key) || // B 没有 A 的属性?!is(objA[key], objB[key]) // 关键!!! 属性值引用是否相等? (浅比较)) {return false; // 存在不一致 => 有变化
}return true; // 浅层判断相等 => 没变化
关键点总结与你的问题
合并状态: processUpdateQueue 中,React 将你的 partialState (如 { count: newValue }) 浅合并 (Object.assign({}, oldState, partialState)) 到旧状态 (oldState) 的一个新对象副本上,生成 newState。
对象引用对比 (浅比较核心): shallowEqual 函数在检查状态 (state) 是否变化时:
先检查 oldState 和 newState 这两个对象本身是不是同一个引用 (Object.is(oldState, newState))。
如果是同一个引用,它会立即认为状态没有变化。
如果不是同一个引用,它才会进行下一步浅层属性比对。
错误方式的问题:
// 错误!! 直接修改旧 state 对象this.state.count = this.state.count + 1;// 然后调用 setState (此时传入的 partialState 是空对象或没变化的对象)this.setState({}); // 或 this.setState(this.state)
this.state.count = … 修改了 this.state 内部的属性值,但 this.state 对象本身的引用地址没有变!它还是原来的那个对象。
在 processUpdateQueue 中,你传入的 partialState 是空对象 {}。Object.assign({}, oldState, {}) 返回的 newState 其实就是 oldState 的一个副本。但是这个副本操作使用的是 Object.assign,它进行的是浅拷贝。
浅拷贝意味着:newState 是一个新对象 (引用地址和 oldState 不同了),但它内部属性 count 指向的值地址还是修改前的值地址吗?不,在这个错误代码示例中,有微妙点:
你 之前 已经直接修改了 oldState.count(原对象的属性)。Object.assign 创建了一个新对象 newState,并将 oldState 的所有 可枚举自身属性 复制到这个新对象上。所以,newState.count 的值会是你修改后的值(假设你是 this.state.count = newValue; this.setState({}))。此时:
newState !== oldState(引用不同,所以浅比较 shallowEqual 的第一步引用比较返回 false)。
shallowEqual 会继续检查属性:属性数量相同吗?相同。属性名都存在吗?存在。那么比较属性值:is(oldState.count, newState.count)
但是,在错误示例中,oldState.count 和 newState.count 都是同一个数字(基本类型,值相等,Object.is 比较为 true)。
所以,shallowEqual 会返回 true,认为 oldState 和 newState 浅层相等?! 这似乎与预期的修改矛盾?那为什么有时这样写能看到 UI 更新?这个错误方式的行为在 React 中是不可靠的 (Unreliable) 且被禁止的,原因如下:
批量更新问题: React 的更新可能是批量的。setState({}) 可能不会立刻触发 processUpdateQueue 和后续比较。如果你在同一个事件循环中多次 this.state.count = …; this.setState({});,由于直接修改的是同一 this.state 对象,每次的修改都会覆盖上一次的状态,只有最后一次修改会“可能”被保留到 newState 中(因为浅拷贝发生在processUpdateQueue时),导致状态丢失和不一致。你无法依赖中间状态。
PureComponent 失效: PureComponent 和 React.memo 默认依赖浅比较来优化。如果像上面错误示例中,恰好 newState.count === oldState.count(对于原始值)且其他属性也没变,即使你修改了 oldState.count,浅比较也会认为相等而跳过更新。但如果 newState.count 是新计算的值(比如 oldState.count + 1),并且它与修改后的 oldState.count 相等(数值相等),浅比较可能通过(原始值比较),组件可能更新。但这种更新是巧合和不可预测的,依赖于值比较的结果。
异步状态访问: React 的 this.state 在状态更新真正提交 (commit 阶段) 前,指向的始终是旧的 fiber.memoizedState。直接修改 this.state 会立即污染这个值(旧状态),导致组件内部访问 this.state 得到的是修改后的值,但这个值并不一定会在后续提交中被真正应用(因为批处理和状态队列),造成组件逻辑混乱和难以调试。
并发模式 (Concurrent Mode) 下行为未定义: 在 React 18 的并发渲染模式下,直接修改可变状态会导致在渲染过程中状态被外部突变,破坏渲染的“一致性快照”原则,导致 UI 错误和崩溃的风险极高。
结论 : Object.assign 浅拷贝创建的 newState 对象 引用 改变了(触发浅比较第一步 false),但属性值可能是基本值(浅比较第二步 is 比较值相等)或者是被修改的旧对象的属性引用(对于对象/数组)。浅比较在第二步属性值比较时,对比的是 值的引用或基本值本身。在错误示例中,如果你修改的是基本值(如数字 count),且 Object.assign 复制后新对象属性值与旧状态属性值(这个值已经被你之前突变改变了)在基本值层面相等,那么浅比较最终 仍然会返回 true ,认为状态没变化!但如果你的 state 包含嵌套对象,而你只修改了嵌套对象内部的属性,那么浅比较在第二步比较该嵌套对象属性的 引用 时,发现引用没变(还是同一个嵌套对象),同样会返回 true。无论如何,直接修改 this.state 并调用 setState 传递旧对象或其引用是危险的、不可预测的、违背 React 不可变原则的,并且会导致潜在的 bug 和性能问题(不必要的渲染或漏渲染)。
正确方式的原因:
// 正确方式:传入一个包含变化属性的新对象this.setState({ count: this.state.count + 1 });
在 processUpdateQueue 中,Object.assign({}, oldState, { count: newValue }) 会创建一个全新的状态对象 (newState)。
这个 newState 对象的引用 (ref) 一定不同于 oldState 的引用。
shallowEqual(oldState, newState) 在第一步判断两个对象的引用是否相同时 (Object.is) 就立即返回了 false(因为 newState 是新对象)!
既然 shallowEqual 返回 false(状态改变),React 就会确定这个组件需要重新渲染。
即使 oldState 和 newState 内部的 count 属性值在数值上是相同的(比如 this.state.count + 1 恰好等于原来的值,概率极低但理论上可能),shallowEqual 也不会进入属性比较阶段,因为在第一步对象引用比较时就已经返回 false 了。 这样保证了只要有 setState 传入非空对象,只要对象引用发生变化(这是创建新对象的必然结果),状态改变就会被检测到。
图解流程:
你的代码:setState({ count: newValue })
vReact: enqueueSetState(fiber, { count: newValue })
v 创建更新对象 (Update)创建 update: { payload: { count: newValue } }
venqueueUpdate(fiber, update) -> 加入到 fiber 的更新队列
vscheduleUpdateOnFiber(fiber) -> 安排渲染
| (调度器决定何时处理)processUpdateQueue(fiber):newState = Object.assign({}, oldState, update.payload) // 创建一个全新的状态对象
vcheckShouldComponentUpdate(fiber):shallowEqual(oldState, newState) // 比较引用
v因为 newState !== oldState (新对象引用) -> shallowEqual 返回 false -> 需要更新!
v标记组件需要重新渲染 (Re-render)
结论:
从源码层面看,React 强制要求状态更新必须是不可变的 (immutable),通过在 processUpdateQueue 中合并状态时创建一个新对象 (Object.assign 或类似方式),并在后续通过 shallowEqual 进行非常快速的引用检查 来决定是否更新。这既保证了简单场景下高效的更新判断(只需比较对象引用),也避免了开发者直接修改状态带来的潜在混乱和错误,为性能优化(如 PureComponent、React.memo)提供了基础保障。直接修改 this.state 会使 shallowEqual 在第一步引用比较就认为状态没变化(除非你特意生成了一个新对象给 setState),从而导致组件不更新或更新行为不可预测且易出错。
扩展
想更深入看函数组件 useState 的 Hooks 实现吗?其核心的浅比较逻辑是相通的。
重点剖析 useState 在函数组件中如何管理状态以及如何触发重新渲染。基于 React 18.2.0 源码(主要关注 react-reconciler 包),我们来拆解它如何工作的。
核心流程概览
- useState 调用: 你的函数组件执行,调用 useState(initialValue)。
- 挂载 (mount) / 更新 (update) 分支: React 根据组件是首次渲染还是后续渲染,走不同的逻辑。
- Hook 对象链表: Hooks 的状态存储在 Fiber 节点的 memoizedState 属性上,是一个链表结构。每个 Hook
(useState, useEffect 等) 对应链表中的一个节点。 - 挂载阶段 (mount): 创建 Hook 对象,初始化状态,绑定 dispatch 函数。
- 更新阶段 (update): 获取更新队列,计算新状态,标记 Fiber 需要更新。
- 状态更新 (dispatch): 当你调用 setState(newValue) (即 dispatch 函数)
时,将更新加入队列,并调度渲染。 - 渲染阶段: React 处理队列中的更新,计算新状态,进行新旧值比较 (Object.is)
- 重新渲染决策: 如果新状态与旧状态引用不同(或基本值不等),则标记组件需要重新渲染。
深入源码(极度简化,突出关键路径)
文件:react-reconciler/src/ReactFiberHooks.js
useState 函数入口
function useState(initialState) {// 当前执行的函数组件对应的 Fiber 节点const fiber = currentlyRenderingFiber;// 当前 Hook 在链表中的索引const hookIndex = workInProgressHookIndex++;// 判断是首次渲染(mount)还是更新渲染(update)if (currentlyRenderingFiber.memoizedState === null) {// mount 阶段: 初始化 Hook 对象链表const hooks = mountWorkInProgressHook();currentlyRenderingFiber.memoizedState = hooks;
else {// update 阶段: 获取已存在的 Hook 链表const hooks = currentlyRenderingFiber.memoizedState;
// 根据是 mount 还是 update 执行对应的逻辑if (isMount) {return mountState(initialState);
else {return updateState();
}
挂载阶段 (mountState) - 首次渲染
function mountState(initialState) {// 1. 创建新的 Hook 节点 (链表中的一个节点)const hook = mountWorkInProgressHook();// 2. 确定初始状态值let initialStateValue;if (typeof initialState === 'function') {initialStateValue = initialState(); // 支持函数式初始值
else {initialStateValue = initialState;
// 3. 将初始状态保存在 Hook 节点的 memoizedState 和 baseState 上hook.memoizedState = hook.baseState = initialStateValue;// 4. 初始化 Hook 的更新队列 (queue)const queue = {pending: null, // 指向最新的未处理更新 (环状链表)dispatch: null, // 即将赋值的 dispatch 函数 (用于 setState)lastRenderedReducer: basicStateReducer, // 用于计算新状态的状态计算函数lastRenderedState: initialStateValue, // 上一次渲染时使用的状态};hook.queue = queue;// 5. 创建并返回 dispatch 函数 (setState) 的引用const dispatch = (queue.dispatch = dispatchAction.bind(null,currentlyRenderingFiber, // 绑定当前 Fiberqueue // 绑定该 Hook 的更新队列));// 6. 返回初始状态和 setState 函数return [hook.memoizedState, dispatch];dispatchAction - 执行 setState(newState)
这是触发状态更新的核心函数。当你调用 const [state, setState] = useState(0); setState(1) 时,实际执行的是 dispatchAction(fiber, queue, action),其中 action 是你传入的值或函数(1 或 prev => prev + 1)。
function dispatchAction(fiber, queue, action) {// 1. 创建一个更新对象 (update)const update = {action, // 你传入的值或函数 (newValue 或 updaterFunction)next: null, // 用于指向下一个更新 (构成环状链表)};// 2. 将更新加入队列 (pending 是一个环状链表,指向最新的update)const pending = queue.pending;if (pending === null) {// 第一个更新: 自身构成环update.next = update;
else {// 插入到队列尾部 (环状链表的插入操作)update.next = pending.next;pending.next = update;
queue.pending = update; // 更新队列的 pending 指针指向最新的 update// 3. 关键调度: 标记 Fiber 需要更新,并加入调度器scheduleUpdateOnFiber(fiber);
scheduleUpdateOnFiber 是 React 协调过程的核心入口,它会将这次更新加入调度器的队列。调度器(如 ReactDOM 默认的调度器)会根据优先级、浏览器空闲时间等因素决定何时开始协调(Reconciliation)过程。
更新阶段 (updateState) - 后续渲染
当函数组件因为状态或属性变化需要重新执行时,再次调用 useState() 会进入 updateState 逻辑。
function updateState() {// 1. 获取当前 Fiber 上对应的 Hook 对象const hook = updateWorkInProgressHook();// 2. 准备计算新状态const queue = hook.queue;let newState = hook.baseState; // 基于 baseState (初始状态或上次被接受的基状态)let pendingQueue = queue.pending;// 3. 关键: 处理更新队列if (pendingQueue !== null) {// 重置更新队列(计算完成后)queue.pending = null;// 将环状队列展开成单向链表let firstUpdate = pendingQueue.next;let update = firstUpdate;// 4. 循环遍历队列中的每个更新,应用它们do {const action = update.action;// 应用当前更新: 使用 reducer 计算新状态 (对于 useState 是 basicStateReducer)// 如果 action 是函数 (updater function), 则执行它: action(newState)// 如果 action 是值,则直接使用它: newState = actionnewState = queue.lastRenderedReducer(newState, action);update = update.next;
while (update ! null && update ! firstUpdate); // 遍历整个队列// 更新 Hook 的 memoizedState 和 baseState (新的基状态)hook.memoizedState = newState;hook.baseState = newState;queue.lastRenderedState = newState; // 记录这次渲染使用的状态
// 5. 关键比较与决策: Bail out?const dispatch = queue.dispatch;// React 18 的核心优化: 即使状态计算完成,也可能跳过渲染!// 比较: newState vs hook.memoizedState (当前渲染前的状态) vs currentHook.memoizedState (current树上的状态)?// 最终决策依赖于 reconcileChildren 和 checkScheduledUpdateOrContext// 但最核心的比较还是 Object.is(newState, currentlyCommittedState)// 6. 返回新状态和 dispatch 函数return [hook.memoizedState, dispatch];// basicStateReducer - useState 的状态计算函数
function basicStateReducer(state, action) {// 如果 action 是函数 (updater function),调用它并传入当前状态// 返回计算结果作为新状态if (typeof action === 'function') {return action(state);
// 否则,直接返回 action 作为新状态return action;
useState 如何判断是否需要重新渲染?(核心)
在协调过程 (beginWork/completeWork) 中,React 会检查每个函数组件 Fiber 节点是否真正需要重新渲染。这个过程发生在 renderWithHooks 和后续的工作中。关键比较点有两个:
在 updateFunctionComponent -> renderWithHooks 流程中:
在计算 useState 等 Hook 的新状态后(如上一步 updateState 完成),React 会比较这次计算出的 Hook.memoizedState(新状态)与 Fiber current 树(前一次提交时对应的树)上对应 Hook 的 memoizedState(旧状态)。
比较方式是严格相等 (Object.is)。
如果 Object.is(newState, oldState) === true,那么这个 Hook 本身的状态就被认为 没有变化。
React 还会检查是否有 useContext 订阅的上下文变化了。
如果该 Fiber 的 所有 Hook 状态 (memoizedState) 通过 Object.is 比较都未变,并且没有上下文变化 (didReceiveUpdate = false),React 会尝试执行 “bailout” 优化——跳过该组件的子树的协调和渲染!直接复用上次渲染的结果。这是 React 18 优化性能的关键手段。
如果 Object.is(newState, oldState) === false,或者有上下文变化,或者父组件强制更新等原因(didReceiveUpdate = true),React 就会 进入协调/渲染流程。
在应用单个更新计算新状态时 (basicStateReducer / updateState 循环中):
即使 Object.is(newState, oldState) 在全局 bailout 检查中比较失败导致组件被渲染了,React 在处理更新队列时,还是会依次应用每一个更新(无论新计算出的中间状态是否与某个之前的状态 Object.is 相等)。
目的:保证每个更新(setState 调用)都被执行并计算状态,即使最终渲染结果可能相同(如果最终计算出的状态和上次一样,bailout 仍然可能发生)。这在 action 是函数 (prevState => …) 时尤其重要,必须执行所有传入的更新函数来保证计算流程的正确性,即使最终值未变。
为什么函数组件 useState 也强调返回新对象?
比较基础不同:
类组件:setState 合并对象后,使用 shallowEqual(浅比较) 比较新旧状态对象 本身 的引用和一级属性的引用/值。
函数组件:useState 直接比较 整个状态值 (newState vs oldState) 使用 Object.is。
基本值 (number, string, boolean): Object.is(5, 5) -> true; Object.is(5, 6) -> false。没问题。
对象/数组: Object.is({}, {}) -> false (因为它们是两个不同的对象,引用地址不同)。Object.is([], []) -> false。
结论:
对于对象或数组状态:
错误方式: 直接修改对象的属性 (state.someProp = 123) 或数组的项 (state[0] = ‘a’),然后调用 setState(state)(或 setState(oldObj)):
你传入的新状态值 action 是同一个旧对象的引用。
在更新队列计算新状态时,newState = action(直接等于旧对象引用)。
新旧状态比较 (Object.is(oldStateRef, newStateRef)):true (引用相同) -> React 会认为状态没变 -> 组件可能被 bailout,不渲染!(除非其他原因导致 didReceiveUpdate = true)
正确方式: 总是返回一个新对象/数组:
setState(prevState => ({ ...prevState, someProp: 123 }));setState(newArray); // e.g., [...oldArray, newItem]
你传入的新状态值是一个全新的对象/数组引用。
新旧状态比较 (Object.is(oldRef, newRef)):false (引用不同) -> React 知道状态变了 -> 触发重新渲染!
对于基本值状态:直接赋值即可 (setState(123)),因为 Object.is 会比较值本身,引用比较在此不适用。
源码级总结 (useState 的 “Object.is” 哲学):
- 状态存储: 函数组件的状态(每个 useState Hook)存储在 Fiber 节点的 memoizedState 链表上的 Hook
对象中。 - 更新触发: dispatchAction 创建更新对象并入队,然后调度协调过程 (scheduleUpdateOnFiber)。
- 状态计算: 在更新阶段 (updateState),React 会遍历并应用队列中的所有更新,使用
basicStateReducer(处理值或函数式更新)计算最新的单一状态值 (newState)。 - 渲染决策: 在全局协调流程中,React 核心决策点是:用 Object.is 比较 Fiber current 树上的 旧状态
(memoizedState) 和刚刚计算出的 新状态 (memoizedState) (来自 workInProgress 树上的
Hook)。
Object.is(newState, oldState) === true -> 可能 bailout (复用上次渲染结果)。Object.is(newState, oldState) === false -> 需要重新协调和渲染。
- 不可变要求: 对于对象/数组状态,必须创建新引用以改变其引用地址,才能使 Object.is 比较结果为
false,从而确保状态变化能被检测到并触发重新渲染。直接修改旧对象的属性不会改变其引用地址,导致 Object.is 结果为 true
和潜在的 bailout 优化(意外跳过渲染)。这就是函数组件也必须遵守不可变更新的根本原因。
理解 useState 的源码关键在于把握两点:
1)Hooks 通过 Fiber 节点上的链表管理状态;
2)Object.is 作为状态变化检测和 bailout 优化的基石。