讲解

「为什么不能直接改状态对象?」——因为 React 靠引用比较判断要不要重渲染。setState 时 React 用 Object.is 对比新旧状态:引用相同就认为没变,跳过渲染。你把原对象改了个属性再放回去,引用没变,React 认为无事发生,界面纹丝不动。这就是不可变更新(immutable update)的根本原因:不是规矩洁癖,是渲染机制的推论。

所以更新对象/数组永远是「创建新的」:对象用展开 setUser({ ...user, age: 19 });数组增 [...list, item]、删 list.filter(...)、改 list.map(...)、排序注意 [...list].sort()(sort 原地修改,要先拷贝)。嵌套结构要逐层展开:setUser({ ...user, address: { ...user.address, city: '上海' } }),嵌套三层以上就该考虑 useReducer(后面讲)或 Immer 库(让你写「看似直接改」的代码,底层自动生成不可变更新)。

渲染机制再补一层:setState 触发的是「组件重新执行」,React 拿新旧两棵元素树做 diff,只把差异提交到 DOM。组件重渲染会默认级联到所有子组件——父组件渲染,子组件无条件跟着渲染(除非子组件被 memo 包裹,性能章细讲)。所以「状态尽量下沉」是性能原则:状态放在真正需要它的最近组件里,避免无关子树跟着重渲染。

示例

验证引用比较如何决定重渲染,以及数组的不可变操作(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// React 的更新判断:Object.is(旧状态, 新状态),相同则跳过渲染
let renderCount = 0;
function createFiber(initial) {
  let state = initial;
  return {
    setState(next) {
      if (Object.is(state, next)) return false; // 引用相同 → 不渲染
      state = next;
      renderCount++;
      return true;
    },
    getState: () => state,
  };
}

const fiber = createFiber({ name: '小明', age: 18 });

// 直接改再放回:引用没变 → 渲染被跳过(bug!)
const s = fiber.getState();
s.age = 19;
assert.strictEqual(fiber.setState(s), false);
assert.strictEqual(renderCount, 0);

// 不可变更新:新引用 → 正常渲染
assert.strictEqual(fiber.setState({ ...fiber.getState(), age: 20 }), true);
assert.strictEqual(renderCount, 1);
assert.strictEqual(fiber.getState().age, 20);

// 数组的不可变增删改排
const list = [3, 1, 2];
const added = [...list, 4]; // 增
const removed = added.filter((n) => n !== 1); // 删
const updated = removed.map((n) => (n === 2 ? 20 : n)); // 改
const sorted = [...updated].sort((a, b) => a - b); // 排(先拷贝)
assert.deepStrictEqual(list, [3, 1, 2]); // 原数组纹丝不动
assert.deepStrictEqual(sorted, [3, 4, 20]);

console.log('直接改对象:渲染次数', 0, '(React 认为没变)');
console.log('不可变更新后渲染次数:', renderCount, ';原数组未被污染:', list.join(','));

真实组件里的嵌套更新:

import { useState } from 'react';

function AddressForm() {
  const [user, setUser] = useState({
    name: '小明',
    address: { city: '北京', zip: '100000' },
  });

  function moveTo(city) {
    setUser({
      ...user, // 第一层展开
      address: { ...user.address, city }, // 嵌套层也要展开
    });
  }

  const [tags, setTags] = useState(['前端']);
  return (
    <>
      <p>
        {user.name} 在 {user.address.city}
      </p>
      <button onClick={() => moveTo('上海')}>搬到上海</button>
      <button onClick={() => setTags([...tags, 'React'])}>加标签</button>
      <button onClick={() => setTags(tags.filter((t) => t !== '前端'))}>删前端</button>
    </>
  );
}

常见坑

  • 改了状态没反应:九成是原地修改,引用没变 React 不重渲染;永远 set 新对象/新数组。
  • sort/splice/push 直接用:这三个原地改数组,先 [...list] 拷贝再操作。
  • 嵌套只展开一层user.address.city = '上海' 藏在深处照样不渲染,路径上每层都要展开。
  • 以为 setState 是同步的:set 完立刻读 state 还是旧值,新值要等下一次渲染。
  • 全局状态全塞顶层:顶层一个 setState 整棵树重渲染;状态下沉 + memo 才可控。

小结

React 用引用比较决定渲染,所以更新必须不可变:对象展开、数组增删改排序全走新副本;嵌套逐层展开。下一章把这套规则用到表单上。