Skip to content

不可变性(Immutability):为什么不能 push,而要用 concat?

——避免状态突变带来的“意料之外”的修改,保证数据流可追踪

“可变性是程序 bug 的‘量子纠缠’——
一处修改,处处崩溃,却不知源头在哪。”

一、一个致命的 push,引发的血案

js
const users = ['Alice', 'Bob'];

function addAdmin(list) {
  list.push('Admin'); // 祸根在此
  return list;
}

const admins = addAdmin(users);

console.log(users);   // ['Alice', 'Bob', 'Admin']
console.log(admins);  // ['Alice', 'Bob', 'Admin']

users 被意外修改了!
addAdmin 不再是“添加管理员”,而是“污染原始用户列表”。

这就是副作用(Side Effect):函数修改了外部状态。

二、不可变性的核心原则

永远不直接修改原始数据
每次“修改”都返回一个新对象

正确做法:用 concat

js
const users = ['Alice', 'Bob'];

function addAdmin(list) {
  return list.concat('Admin'); // 返回新数组
}

const admins = addAdmin(users);

console.log(users);   // ['Alice', 'Bob'] 原始数据完好
console.log(admins);  // ['Alice', 'Bob', 'Admin'] 新数据

concat 不修改原数组,而是创建一个新数组。

三、为什么 push 危险?—— 突变的三大罪状

罪状 1:破坏引用相等性(Reference Equality)

js
const original = { name: 'Alice' };
const updated = original;
updated.age = 25; // 突变

original === updated; // true —— 但数据已不同!

你无法通过 === 判断数据是否变化,因为引用没变。

而在不可变世界:

js
const updated = { ...original, age: 25 };
original === updated; // false —— 引用不同,数据不同,逻辑一致

罪状 2:导致“幽灵修改”(Ghost Mutation)

js
// 组件 A
const config = { theme: 'dark', lang: 'en' };

// 组件 B(第三方库)
function enhance(config) {
  config.plugins = ['seo', 'analytics']; // 突变
  return config;
}

// 组件 A 再次使用 config
renderApp(config); // 主题没变?不,plugins 被偷偷加了!

突变让数据契约失效,你无法信任任何传入的对象。

罪状 3:破坏时间旅行与可追溯性

js
let state = { count: 0 };

function increment() {
  state.count++; // 突变
}

increment();
increment();

你想回溯 state 的历史?不可能。
因为 state 始终是同一个引用,过去的值已被覆盖。

而在 Redux 中:

js
// 每次返回新 state
const newState = { ...state, count: state.count + 1 };

你可以轻松实现撤销、重做、时间旅行调试

四、不可变性如何拯救代码?

1. 可预测的数据流

js
const nextState = reducer(prevState, action);

你知道:

  • prevState 不会被修改
  • nextState 是一个全新的状态
  • 变化是显式的、可追踪的

2. 安全的共享与传递

js
// 你可以放心地把数据传给子组件、API、worker
renderProfile(user); // 不用担心 profile 组件会修改 user

因为你知道:任何“修改”都会返回新对象

3. 高效的变更检测

在 React 中:

text
// 浅比较即可
shouldComponentUpdate(nextProps) {
  return this.props.user !== nextProps.user;
}

如果 user 是可变的,即使内容变了,引用也可能相同,导致更新遗漏

五、JS 中实现不可变性的工具

1. 数组操作(避免 push/pop/splice

可变方法不可变替代
pusharr.concat(item)[...arr, item]
poparr.slice(0, -1)
splicearr.filter(...)arr.slice() + concat
js
const newArr = [...oldArr.slice(0, i), newItem, ...oldArr.slice(i+1)];

2. 对象操作(避免直接赋值)

js
// 
obj.name = 'New';

// 
const newObj = { ...obj, name: 'New' };

3. 深层更新

js
// 使用 immer(推荐)或递归
import produce from 'immer';

const nextState = produce(state, draft => {
  draft.users[0].age = 26; // 看似突变,实则生成新对象
});

六、不可变性的“性能焦虑”与真相

“每次创建新对象,不会很慢吗?”

真相:现代 JS 引擎和函数式库已优化:

  • 结构共享(Structural Sharing):新旧对象共享未变部分
  • 惰性求值:只在需要时才计算
  • V8 优化:短生命周期对象的 GC 非常高效

示例:Immutable.js 的树结构

js
// 两个数组共享大部分节点,只新增一条路径
const list1 = Immutable.List([1,2,3,4,5]);
const list2 = list1.set(2, 99); // 只创建新分支,其他节点复用

七、工程实践:拥抱不可变性

1. 使用 ESLint 规则禁止突变

text
// eslint-plugin-functional
'functional/no-mutating-methods': 'error'

2. 使用 const + 不可变模式

js
const users = ['Alice'];
// const 防止重新赋值,但不防突变
// 需配合编程习惯

3. 在 Redux、React Hooks 中强制使用

js
setState(prev => ({ ...prev, count: prev.count + 1 }));

结语:不可变性是“函数式编程的基石”

它让程序从“状态机的混沌”走向“数据流的秩序”

concat 不是语法选择,而是一种哲学

  • 数据是值(Value),不是变量(Variable)
  • 变化是转换(Transformation),不是修改(Mutation)
  • 函数是映射(Map),不是指令(Command)

当你放弃 push,你失去的只是一时的便利;
你获得的,是可预测、可测试、可追溯的代码宇宙。