在 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、时间旅行调试这些概念,都会变得顺理成章。