2、不可变数据结构:不可变数据的概念、在React/Vue中的应用、浅拷贝与深拷贝
聊到状态复用,绕不开一个基础话题——不可变数据。说实话,我刚入行那会儿,觉得这东西有点玄乎。不就是个对象嘛,改就改了,为啥非要搞个新的出来?直到我在一个中型项目中,因为不小心修改了 Vuex 里的某个嵌套对象,导致三个组件同时出现诡异的渲染 bug……嗯,从那以后,我再也不敢轻视不可变数据了。
2.1 不可变数据到底是什么?
简单说,不可变数据就是:数据一旦创建,就不能被修改。你想改变它?只能创建一个新的副本,在副本上做修改,然后替换掉原来的。
你想想看,这跟 JavaScript 的默认行为完全相反。JS 里对象和数组都是引用类型,你改了一个地方,所有引用它的地方都会跟着变。这就是很多 bug 的根源。
核心区别:
- 可变(Mutable):直接修改原数据,所有引用方受影响
- 不可变(Immutable):创建新数据替换旧数据,旧数据不受影响
举个例子,你就能立刻明白:
// 可变方式 —— 危险!
const user = { name: '张三', age: 25 };
const updateUser = (u) => { u.age = 26; };
updateUser(user);
console.log(user.age); // 26 —— 原对象被改了
// 不可变方式 —— 安全
const user2 = { name: '张三', age: 25 };
const updateUserSafe = (u) => ({ ...u, age: 26 });
const newUser = updateUserSafe(user2);
console.log(user2.age); // 25 —— 原对象完好
console.log(newUser.age); // 26 —— 新对象
2.2 为什么 React 和 Vue 都离不开它?
这两个框架虽然设计理念不同,但在状态管理上,都强烈依赖不可变数据。原因其实很直接:它们需要一种高效的方式来判断状态是否发生了变化。
在 React 中
React 的 useState 和 useReducer,本质上都是通过比较前后状态的引用是否相同,来决定是否重新渲染。如果你直接修改了 state 对象,React 发现引用没变,就不会触发更新。
// ❌ 错误做法 —— 不会触发重新渲染
const [todos, setTodos] = useState(['吃饭', '睡觉']);
todos.push('打豆豆'); // 直接修改原数组
setTodos(todos); // React 认为引用没变,跳过渲染
// ✅ 正确做法 —— 创建新数组
const [todos, setTodos] = useState(['吃饭', '睡觉']);
setTodos([...todos, '打豆豆']); // 展开运算符创建新数组
我个人习惯在写 React 时,所有状态更新都走展开运算符或者 immer 库。尤其是处理深层嵌套的对象时,手动展开真的很痛苦。
在 Vue 中
Vue 2 用的是 Object.defineProperty 做响应式,直接修改对象属性是可以触发更新的。但到了 Vue 3,底层换成了 Proxy,虽然也能检测到属性修改,但官方依然推荐不可变模式。为什么?
因为 Vue 的 computed 和 watch 在做依赖追踪时,如果数据是不可变的,就能更精确地判断哪些依赖需要重新计算。我在项目中遇到过一个问题:一个 watch 监听一个深层对象,因为内部属性被直接修改,导致 watch 无法正确触发回调。改成不可变方式后,问题立刻消失。
// Vue 3 中推荐的做法
const state = reactive({
user: { name: '李四', address: { city: '北京' } }
});
// ❌ 直接修改 —— 可能引起追踪问题
state.user.address.city = '上海';
// ✅ 不可变方式 —— 创建新对象
state.user = {
...state.user,
address: { ...state.user.address, city: '上海' }
};
2.3 浅拷贝 vs 深拷贝 —— 你选对了吗?
说到创建新对象,就不得不提浅拷贝和深拷贝。很多新手在这里翻车,包括我当年。
| 对比维度 | 浅拷贝 | 深拷贝 |
|---|---|---|
| 拷贝层级 | 只拷贝第一层属性 | 递归拷贝所有层级 |
| 嵌套对象 | 共享引用 | 完全独立 |
| 性能 | 快,O(n) | 慢,O(n²) 或更差 |
| 常用方法 | Object.assign、展开运算符 |
JSON.parse(JSON.stringify)、structuredClone |
| 适用场景 | 扁平对象、性能敏感 | 深层嵌套、需要完全隔离 |
我曾经踩过的坑: 用 JSON.parse(JSON.stringify(obj)) 做深拷贝,结果 obj 里有个 Date 类型,拷贝完变成了字符串。还有一次,对象里有 undefined 和函数,直接被丢掉了。所以,JSON 方案只适用于纯数据对象。
来看看具体表现:
const original = {
name: '王五',
address: { city: '广州', district: '天河' }
};
// 浅拷贝
const shallowCopy = { ...original };
shallowCopy.address.city = '深圳';
console.log(original.address.city); // '深圳' —— 原对象也被改了!
// 深拷贝
const deepCopy = JSON.parse(JSON.stringify(original));
deepCopy.address.city = '深圳';
console.log(original.address.city); // '广州' —— 原对象不受影响
为什么会这样?因为浅拷贝只复制了第一层属性,address 这个属性本身是一个引用,拷贝后两个对象仍然指向同一个内存地址。深拷贝则递归地创建了全新的嵌套对象。
2.4 实际项目中的选择策略
我个人在项目中的经验是:
- 80% 的场景用浅拷贝就够了。比如表单数据、列表项、简单的配置对象。展开运算符一把梭,性能好,代码也干净。
- 15% 的场景需要深拷贝。比如从后端拿到的复杂嵌套数据,你要做本地编辑、撤销回退等操作。这时候我推荐用
structuredClone(现代浏览器都支持)或者lodash.cloneDeep。 - 5% 的场景用 Immer。当嵌套层级特别深,手动展开代码变得又臭又长时,Immer 的
produce函数能让你用可变的方式写不可变逻辑,非常爽。
小技巧: 如果你用 TypeScript,可以配合 Readonly<T> 类型约束,让编译器帮你检查是否不小心修改了不可变数据。我在团队里强制要求所有 Redux reducer 的 state 参数都标注为 Readonly,效果立竿见影。
2.5 一张图看懂不可变数据
下面这张图展示了可变与不可变的核心差异,以及它们在 React/Vue 中的渲染判断逻辑:
2.6 总结一下
不可变数据不是什么高深理论,它就是一套编程习惯。说白了,就是别改原数据,要改就造个新的。这个习惯一旦养成,你会发现很多 bug 自然就消失了,代码的可预测性也大大提升。
浅拷贝和深拷贝的选择,取决于你的数据结构有多深。扁平数据用展开运算符,嵌套数据用 structuredClone 或工具库。记住一点:不要为了省事而直接修改状态,那点便利性,远不及调试 bug 时浪费的时间。