React 不可变数据:为什么页面“明明数据变了却不更新

在 React 项目里,有一类 bug 特别折磨人:它不报错,数据也确实改了,但界面就是不更新 。更让人抓狂的是,当你把修改后的数据打印出来,发现一切正常,于是开始怀疑人生。这类问题的根源,几乎都指向同一个认知盲区------不可变数据。

我们知道,如果在写电商网站的购物车删除商品操作时,按如下写法:

jsx 复制代码
const removeItem = (targetId) => {
  const index = cartItems.findIndex(item => item.id === targetId);
  cartItems.splice(index, 1);   // ❌ 直接修改了原数组
  setCartItems(cartItems);      // ❌ 引用没变,React 认为状态没更新
};

用户点击"删除",控制台里 cartItems 确实少了一个元素,但页面上那个商品依然稳稳地待在那里。只有刷新页面,它才消失------因为刷新重新初始化了 state。

这就是最经典的不可变数据 Bug:修改了数据的内容,但没有改变它的引用,React 无法感知变化。

根源:React 靠"引用对比"判断变化

要理解这个问题,得先知道 React 是怎么决定"要不要重新渲染"的。

React 并不关心你的对象内部长什么样。当你调用 setState 时,它会用 Object.is 对新旧状态的引用做一次比较:

  • 如果引用相同(prevState === nextState),React 认为"什么都没变",直接跳过渲染。
  • 如果引用不同,React 才认为状态更新了,安排重新渲染。

这也就解释了为什么 splice 和直接赋值属性会导致页面不更新------它们修改的是同一个对象/数组,引用自始至终没变。React 一看:"还是原来那个东西嘛",于是什么也不做。

这不是 React 的缺陷,而是一种刻意的设计。 正因为有了引用对比,React 才能用极低的成本判断状态是否变化,从而实现 React.memo、useMemo、PureComponent 这些性能优化手段。代价就是:你必须通过"创建新对象"来告诉 React 变化发生了。

场景一:直接修改数组/对象,视图不更新

错误示例

jsx 复制代码
// 数组:push 修改原数组
const addTodo = (text) => {
  todos.push(text);
  setTodos(todos);       // 引用没变,不触发更新
};

// 对象:直接改属性
const updateAge = () => {
  user.age = 26;
  setUser(user);         // 同样不触发更新
};

正确示例:始终创建新引用

jsx 复制代码
// 数组添加:展开运算符
const addTodo = (text) => {
  setTodos(prev => [...prev, text]);   // 全新数组
};

// 对象更新:展开运算符
const updateAge = () => {
  setUser(prev => ({ ...prev, age: prev.age + 1 }));
};

对于数组,常用的还有:

jsx 复制代码
// 删除:filter
setItems(prev => prev.filter(item => item.id !== id));

// 修改某个元素:map
setItems(prev => prev.map(item =>
  item.id === id ? { ...item, done: true } : item
));

// 排序:先拷贝再排序
setItems(prev => [...prev].sort((a, b) => a.price - b.price));

总结:凡是会修改原数组的方法(push、pop、splice、sort、reverse),在 React 里都要用"先拷贝,再操作"的方式替代。

场景二:React.memo / useMemo 失效

不可变数据的影响不止于"视图不更新",它还会让性能优化手段彻底失灵。

React.memo 失效

jsx 复制代码
const UserList = React.memo(({ users }) => {
  console.log('UserList 渲染了');
  return <div>{users.join(',')}</div>;
});

function App() {
  const [users, setUsers] = useState(['Alice', 'Bob']);

  const addUser = () => {
    users.push('Charlie');   // ❌ 修改原数组
    setUsers(users);         // ❌ 引用不变
  };

  return (
    <div>
      <UserList users={users} />
      <button onClick={addUser}>添加用户</button>
    </div>
  );
}

React.memo 会对 props 做浅比较。因为 users 的引用始终没变,即使内容变了,memo 也会认为"没有变化",跳过子组件渲染。结果是数据变了,界面却毫无反应。

useMemo 依赖失效

jsx 复制代码
<Calculator number={{ value: 10 }} />
jsx 复制代码
function Calculator({ number }) {
  const result = useMemo(() => heavyCalculation(number), [number]);
  return <div>{result}</div>;
}

每次父组件渲染,{ value: 10 } 都是一个全新的对象 ,即使内容完全一样,引用也不同。于是 useMemo 发现依赖"变了",每次都重新执行 heavyCalculation,缓存形同虚设。

这两个例子揭示了不可变数据的双向要求:

  • 更新时,必须创建新引用,React 才能感知变化。
  • 缓存时,必须保持引用稳定,才能避免重复计算。

该变的时候产生新对象,不该变的时候保持引用不变------这正是不可变更新模式的核心。


场景三:误用 splice 与直接修改嵌套属性

场景还原:用户地址管理

你正在做一个用户资料页,地址信息嵌套在 user 对象中:

jsx 复制代码
const [user, setUser] = useState({
  name: '张三',
  address: {
    province: '浙江省',
    city: '杭州市',
    district: '拱墅区',
    detail: '莫干山路188号'
  }
});

用户点击"设为默认收货地址",需要把城市改成 '北京市'。有些开发者会直接点对点修改:

jsx 复制代码
const setDefaultAddress = () => {
  user.address.city = '北京市';   // ❌ 修改了嵌套属性
  setUser(user);                  // ❌ 顶层引用没变,不触发更新
};

点击后,控制台打印 user,你会发现 address.city 确实已经变成了 '北京市',但页面显示的仍然是旧的 '杭州市'。这是因为 React 对比的是 user 对象的顶层引用,而它根本没变。

正确做法一:逐层展开

jsx 复制代码
const setDefaultAddress = () => {
  setUser(prev => ({
    ...prev,                       // 浅拷贝 user 第一层
    address: {
      ...prev.address,             // 浅拷贝 address,产生新引用
      city: '北京市'               // 覆盖需要修改的属性
    }
  }));
};

每一层被修改过的对象,都必须生成新引用,否则 React 在某一层就会因为引用相同而"短路"。

正确做法二:用 Immer 简化嵌套更新

层级一深,逐层展开的代码就会变得又长又容易出错。这时 Immer 就是救星------它允许你用"可变"的写法,自动产生不可变的结果:

jsx 复制代码
import produce from 'immer';

const setDefaultAddress = () => {
  setUser(produce(user, draft => {
    draft.address.city = '北京市'; // 看似直接修改,实则生成新对象
  }));
};

正确做法三:useImmer 处理复杂状态

在组件中,更推荐使用 useImmer,它的用法和 useState 几乎一样,但更新函数接收的是一个可以随意修改的 draft:

jsx 复制代码
import { useImmer } from 'use-immer';

function Profile() {
  const [user, updateUser] = useImmer({
    name: '张三',
    address: { province: '浙江省', city: '杭州市', district: '西湖区' }
  });

  const setDefaultAddress = () => {
    updateUser(draft => {
      draft.address.city = '北京市'; // ✅ 直接赋值,Immer 负责不可变
    });
  };

  const addHobby = (hobby) => {
    updateUser(draft => {
      draft.hobbies.push(hobby);    // ✅ push 也没问题,draft 是代理对象
    });
  };
}

在嵌套层级较深、更新逻辑复杂的状态中,useImmer 能显著减少样板代码,让逻辑更接近人的直觉。


不可变数据为什么让"时间旅行调试"成为可能

不可变数据的价值不止于让 React 正常工作,它还带来了一项强大能力:时间旅行调试。

因为每次更新都产生全新对象,旧状态不会被破坏,所以我们可以把所有历史状态保存成一个数组:

jsx 复制代码
function useTimeTravel(initialState) {
  const [history, setHistory] = useState([initialState]);
  const [index, setIndex] = useState(0);

  const current = history[index];

  const pushState = (newState) => {
    const nextHistory = history.slice(0, index + 1);
    setHistory([...nextHistory, newState]);
    setIndex(nextHistory.length);
  };

  const undo = () => setIndex(Math.max(0, index - 1));
  const redo = () => setIndex(Math.min(history.length - 1, index + 1));

  return {
    current, pushState, undo, redo,
    canUndo: index > 0,
    canRedo: index < history.length - 1
  };
}

为什么必须不可变?

如果直接修改原对象,历史数组中的所有快照都会指向同一个引用,导致"过去"的状态和"现在"完全一样,撤销功能彻底失效。只有每次更新生成新对象,才能真实地保存下每个瞬间的状态。

Redux DevTools 的"状态时间旅行"、各种编辑器的撤销重做、协同编辑中的版本回溯,都建立在这个基础之上。


总结:把"新对象"当成一种习惯

回顾所有场景,不可变数据的核心原则可以概括为一句话:

永远不要修改 state 的对象或数组本身,而是创建一个包含所需变化的新对象或新数组。

理解不可变数据,不只是为了修好眼前这个"页面不更新"的 Bug,更是为了建立起 React 的心智模型:状态是某个瞬间的快照,更新意味着产生一个新的快照,而不是擦掉旧的重新画。 当真正接受了这个模型,React.memo、useMemo、时间旅行调试这些概念,都会变得顺理成章。

相关推荐
行者全栈架构师1 小时前
【共创稿事节】双引擎智能相册:HarmonyOS 7(API 26)文搜图 × 图像超分端侧 AI 联动实战
前端·算法·架构
chromoany1 小时前
agent 写的 README 为什么装不上:一条“没有出处就删掉”的规则
前端
deli0071 小时前
商品条形码最后一位怎么来的?Code128 校验位实测
前端
Gizzap_Tech1 小时前
双语内容审阅台怎么做?用内容指纹保留核验记录
javascript
小蒜学长1 小时前
基于SpingBoot+Vue的定向越野赛事管理系统的设计与开发(代码+数据库+LW)
java·前端·spring boot·后端·前后端分离·定向越野赛事
nyaomaru1 小时前
is-kit 达成 100 Stars ⭐🎉 从 50 到 100,发生了什么?
前端·typescript
陈随易1 小时前
副业搞钱点子之 Umami 统计如何赚钱
前端·后端·程序员
美狐美颜sdk2 小时前
直播APP开发中的视频帧处理:美颜SDK接入需要注意哪些细节?
大数据·前端·人工智能·音视频·美颜sdk
随风一样自由2 小时前
【React+TS71007】Next.js “use client” 误用导致 TS71007:客户端/服务端引入问题的完整排查与修复
前端·javascript·react.js