跳到主要内容

不可变数据

不可变性(Immutability)是函数式编程的核心原则之一。理解不可变性不仅能帮助你写出更可靠的代码,还能让你更好地理解 React、Redux 等现代框架的工作原理。

什么是不可变性?

根据 MDN 的定义,不可变值是指其内容在不创建全新值的情况下无法被更改的值。换句话说,一旦数据被创建,它就不能被修改。任何看似"修改"的操作,实际上都是创建了一个新的数据副本。

JavaScript 中的可变与不可变

JavaScript 中的数据类型分为两类:

原始类型(Primitive Types)天生不可变

// 字符串是不可变的
let str = 'hello';
str[0] = 'H'; // 静默失败,str 仍然是 'hello'

// 数字是不可变的
let num = 42;
num = 100; // 这是重新赋值,不是修改

// 布尔值是不可变的
let flag = true;
flag = false; // 重新赋值

原始类型的值一旦创建就不能改变。当你给变量赋予新值时,变量指向了一个全新的值,原来的值并没有被修改。

引用类型(Reference Types)默认可变

// 对象是可变的
const user = { name: '张三', age: 25 };
user.age = 26; // 直接修改了原对象
console.log(user); // { name: '张三', age: 26 }

// 数组是可变的
const arr = [1, 2, 3];
arr.push(4); // 直接修改了原数组
console.log(arr); // [1, 2, 3, 4]

// 这就是"可变"的含义:数据本身被修改了

这种可变性在函数式编程中会带来问题。当多个地方引用同一个对象时,一处修改会影响所有引用,这种隐式的数据共享是 bug 的常见来源。

不可变操作 vs 可变操作

理解两者区别是掌握不可变性的关键:

const original = [1, 2, 3, 4, 5];

// 可变操作 - 修改原数组
const mutableExample = [...original]; // 先复制
mutableExample.push(6); // 再修改
mutableExample[0] = 0; // 再修改
// 原数组 original 未受影响,因为我们先复制了

// 但如果不小心忘记了复制...
const badExample = original;
badExample.push(6); // original 也被修改了!
console.log(original); // [1, 2, 3, 4, 5, 6]

// 不可变操作 - 总是返回新数据
const goodExample = [...original, 6]; // 创建新数组
// 或者使用 concat
const anotherWay = original.concat(6); // 创建新数组

console.log(original); // [1, 2, 3, 4, 5] 未改变
console.log(goodExample); // [1, 2, 3, 4, 5, 6]

不可变操作的核心思想是:永远不修改原数据,每次"修改"都创建新数据。这看起来可能浪费内存,但实际上有很多好处。

为什么需要不可变性?

1. 可预测性

当数据不可变时,你可以确信数据不会被意外修改。这大大降低了理解代码的难度。

// 问题:可变数据导致难以追踪的变化
let sharedData = { count: 0 };

function increment() {
sharedData.count++; // 隐式修改外部状态
}

function processData() {
increment(); // 这里修改了 sharedData
// ... 其他代码
// 调用者可能不知道 sharedData 已经变了
}

// 解决:不可变数据让变化显式化
function incrementImmutable(data) {
return { count: data.count + 1 }; // 返回新对象
}

function processDataImmutable(data) {
const newData = incrementImmutable(data); // 显式接收新数据
// 原来的 data 不会变,变化都在 newData 中
return newData;
}

2. 易于调试和时间旅行

不可变数据天然支持"快照"功能。每次状态变化都是一次新的快照,你可以轻松回溯到任意历史状态。

// 使用不可变数据实现简单的状态历史
class StateHistory {
constructor(initialState) {
this.history = [initialState];
this.currentIndex = 0;
}

// 获取当前状态
get current() {
return this.history[this.currentIndex];
}

// 更新状态(不可变方式)
update(newSnapshot) {
// 丢弃当前位置之后的历史(如果有的话)
this.history = this.history.slice(0, this.currentIndex + 1);
// 添加新快照
this.history.push(newSnapshot);
this.currentIndex++;
}

// 撤销
undo() {
if (this.currentIndex > 0) {
this.currentIndex--;
}
return this.current;
}

// 重做
redo() {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
}
return this.current;
}
}

// 使用示例
const history = new StateHistory({ count: 0 });
history.update({ count: 1 }); // 更新
history.update({ count: 2 }); // 更新
console.log(history.current); // { count: 2 }
console.log(history.undo()); // { count: 1 }
console.log(history.redo()); // { count: 2 }

Redux DevTools 的"时间旅行调试"功能正是基于这个原理实现的。

3. 性能优化:引用比较

在 React 中,不可变数据使得组件更新判断变得非常高效。

// React 组件更新判断
function shouldComponentUpdate(nextProps, nextState) {
// 如果数据不可变,直接比较引用即可
// 引用相同 = 数据没变 = 不需要重新渲染
return nextProps.data !== this.props.data;
}

// 如果数据可变,就需要深比较
function deepEqual(a, b) {
// 递归比较每个属性...性能开销大
}

// 使用不可变数据的好处
const oldState = { user: { name: '张三' } };
const newState = { user: { name: '李四' } }; // 新对象

// 引用比较,O(1) 时间复杂度
oldState === newState // false,状态变了

// React 的 PureComponent 和 memo 就是基于这个原理
const MyComponent = React.memo(function MyComponent({ data }) {
// 只有当 data 引用变化时才重新渲染
return <div>{data.name}</div>;
});

4. 并发安全

在多线程或异步环境中,不可变数据消除了数据竞争的问题。因为数据不会被修改,多个"线程"可以安全地读取同一份数据。

// 模拟并发问题
let sharedState = { value: 0 };

// 可变数据的并发问题
async function problematicConcurrent() {
const promise1 = Promise.resolve().then(() => {
sharedState.value += 1; // 修改共享状态
});
const promise2 = Promise.resolve().then(() => {
sharedState.value += 1; // 同时修改
});
await Promise.all([promise1, promise2]);
// sharedState.value 可能不是预期的 2
}

// 不可变数据的并发安全
function safeConcurrent(currentState) {
// 每个操作都基于当前状态创建新状态
// 不会相互干扰
const newState1 = { value: currentState.value + 1 };
const newState2 = { value: currentState.value + 1 };
// 然后通过合并策略决定最终结果
return { value: currentState.value + 2 };
}

JavaScript 中的不可变操作

数组的不可变操作

const original = [1, 2, 3, 4, 5];

// ========== 添加元素 ==========
// 在末尾添加
const pushed = [...original, 6];
// [1, 2, 3, 4, 5, 6]

// 在开头添加
const unshifted = [0, ...original];
// [0, 1, 2, 3, 4, 5]

// 在指定位置插入
const insertAt = (arr, index, value) => [
...arr.slice(0, index),
value,
...arr.slice(index)
];
const inserted = insertAt(original, 2, 2.5);
// [1, 2, 2.5, 3, 4, 5]

// ========== 删除元素 ==========
// 删除末尾元素
const popped = original.slice(0, -1);
// [1, 2, 3, 4]

// 删除开头元素
const shifted = original.slice(1);
// [2, 3, 4, 5]

// 删除指定位置元素
const removeAt = (arr, index) => [
...arr.slice(0, index),
...arr.slice(index + 1)
];
const removed = removeAt(original, 2);
// [1, 2, 4, 5]

// 删除指定值
const withoutValue = original.filter(x => x !== 3);
// [1, 2, 4, 5]

// ========== 修改元素 ==========
// 修改指定位置
const updated = original.map((x, i) => i === 2 ? 99 : x);
// [1, 2, 99, 4, 5]

// 条件修改
const doubled = original.map(x => x * 2);
// [2, 4, 6, 8, 10]

// ========== 其他操作 ==========
// 反转(创建新数组)
const reversed = [...original].reverse();
// [5, 4, 3, 2, 1]

// 排序(创建新数组)
const sorted = [...original].sort((a, b) => b - a);
// [5, 4, 3, 2, 1]

// 去重
const unique = [...new Set([1, 1, 2, 2, 3])];
// [1, 2, 3]

// 扁平化
const flattened = [[1, 2], [3, 4]].flat();
// [1, 2, 3, 4]

对象的不可变操作

const original = { a: 1, b: 2, c: 3 };

// ========== 添加属性 ==========
const withD = { ...original, d: 4 };
// { a: 1, b: 2, c: 3, d: 4 }

// ========== 修改属性 ==========
const modifiedB = { ...original, b: 20 };
// { a: 1, b: 20, c: 3 }

// ========== 删除属性 ==========
// 使用解构赋值
const { b, ...withoutB } = original;
// withoutB: { a: 1, c: 3 }

// 使用函数
const omit = (obj, key) => {
const { [key]: _, ...rest } = obj;
return rest;
};
const withoutC = omit(original, 'c');
// { a: 1, b: 2 }

// ========== 嵌套对象更新 ==========
const nested = {
user: {
name: '张三',
profile: {
age: 25,
address: {
city: '北京'
}
}
}
};

// 更新深层嵌套的值
const updated = {
...nested,
user: {
...nested.user,
profile: {
...nested.user.profile,
address: {
...nested.user.profile.address,
city: '上海'
}
}
}
};

// 这种嵌套更新确实繁琐,后面会介绍更好的方案

Object.freeze 的使用与局限

Object.freeze() 可以让对象变得"只读",但它是浅冻结:

// 浅冻结示例
const frozen = Object.freeze({
name: '张三',
address: { city: '北京' }
});

frozen.name = '李四'; // 静默失败(或严格模式下报错)
console.log(frozen.name); // '张三',无法修改

// 但嵌套对象仍然可变!
frozen.address.city = '上海';
console.log(frozen.address.city); // '上海',被修改了!

// 深冻结函数
function deepFreeze(obj) {
// 先冻结所有嵌套对象
Object.keys(obj).forEach(key => {
const value = obj[key];
if (value && typeof value === 'object') {
deepFreeze(value);
}
});
// 再冻结当前对象
return Object.freeze(obj);
}

const deeplyFrozen = deepFreeze({
name: '张三',
address: { city: '北京' }
});

deeplyFrozen.address.city = '上海'; // 无效
console.log(deeplyFrozen.address.city); // '北京'

Object.freeze 的局限:

  1. 只能冻结对象自身的可枚举属性
  2. 浅冻结,嵌套对象需要递归处理
  3. 性能有一定影响,通常只在开发环境使用

不可变数据结构库

当处理复杂的嵌套数据或需要高性能时,原生 JavaScript 的展开运算符可能不够用。这时可以使用专门的不可变数据结构库。

Immutable.js

Immutable.js 是 Facebook 开发的不可变数据结构库,提供了持久化数据结构(Persistent Data Structures)。

什么是持久化数据结构?

持久化数据结构在"修改"时会保留旧版本,并通过结构共享(Structural Sharing)来节省内存:

const { Map, List, fromJS } = require('immutable');

// ========== 基本使用 ==========
// Map 代替普通对象
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 50); // 返回新的 Map

console.log(map1.get('b')); // 2,原 Map 未改变
console.log(map2.get('b')); // 50

// List 代替数组
const list1 = List([1, 2, 3]);
const list2 = list1.push(4); // 返回新的 List

console.log(list1.toArray()); // [1, 2, 3]
console.log(list2.toArray()); // [1, 2, 3, 4]

// ========== 嵌套数据操作 ==========
// fromJS 将普通 JS 对象转换为 Immutable 对象
const data = fromJS({
users: [
{ id: 1, name: '张三', active: true },
{ id: 2, name: '李四', active: false }
]
});

// getIn/setIn/updateIn 用于深层操作
const updated = data.setIn(['users', 0, 'name'], '王五');
console.log(updated.getIn(['users', 0, 'name'])); // '王五'

// 原数据不变
console.log(data.getIn(['users', 0, 'name'])); // '张三'

// 更新深层值
const incremented = data.updateIn(['users', 0, 'id'], id => id + 10);
console.log(incremented.getIn(['users', 0, 'id'])); // 11

// ========== 合并操作 ==========
const mapA = Map({ a: 1, b: 2 });
const mapB = Map({ b: 3, c: 4 });

const merged = mapA.merge(mapB);
// Map { a: 1, b: 3, c: 4 }

// 深度合并
const deepA = fromJS({ user: { name: '张三', age: 25 } });
const deepB = fromJS({ user: { age: 26, city: '北京' } });

const deepMerged = deepA.mergeDeep(deepB);
// { user: { name: '张三', age: 26, city: '北京' } }

// ========== 与原生 JS 互转 ==========
const immutable = Map({ a: 1, b: 2 });

// 转为普通对象
const jsObject = immutable.toJS();
// { a: 1, b: 2 }

// 转为数组
const immutableList = List([1, 2, 3]);
const jsArray = immutableList.toArray();
// [1, 2, 3]

Immutable.js 的优势

  • 高效的结构共享,内存占用低
  • 丰富的 API,操作便捷
  • 内置值相等性比较

Immutable.js 的劣势

  • 学习成本较高
  • 需要在 Immutable 对象和原生 JS 之间转换
  • 调试时显示的是对象结构,不太直观

Immer

Immer 采用了一种更直观的方式:让你用可变的写法,自动生成不可变的数据。

Immer 的工作原理

Immer 创建了一个当前状态的代理(draft),你在这个代理上进行修改,Immer 会记录所有更改,然后生成一个新的不可变状态。

import { produce } from 'immer';

// ========== 基本使用 ==========
const baseState = [
{ title: '学习 TypeScript', done: true },
{ title: '学习 Immer', done: false }
];

// 使用 produce 创建新状态
const nextState = produce(baseState, draft => {
// 在 draft 上可以直接"修改"
draft[1].done = true; // 看起来是修改
draft.push({ title: '分享学习心得' }); // 看起来是添加

// 但实际上 baseState 没有被修改
});

console.log(baseState[1].done); // false,原状态不变
console.log(nextState[1].done); // true
console.log(nextState.length); // 3

// ========== 处理嵌套数据 ==========
const nested = {
user: {
profile: {
name: '张三',
address: {
city: '北京',
district: '朝阳'
}
}
}
};

// 使用 Immer 简化深层更新
const updated = produce(nested, draft => {
// 直接写,不需要一层层展开
draft.user.profile.address.city = '上海';
});

// 对比原生写法
const nativeUpdate = {
...nested,
user: {
...nested.user,
profile: {
...nested.user.profile,
address: {
...nested.user.profile.address,
city: '上海'
}
}
}
};

// ========== 在 React 中使用 ==========
import { produce } from 'immer';
import { useState } from 'react';

function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '学习 React', done: false },
{ id: 2, text: '学习 Immer', done: false }
]);

// 不使用 Immer
const toggleTodoOld = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};

// 使用 Immer,更直观
const toggleTodo = (id) => {
setTodos(produce(todos, draft => {
const todo = draft.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}));
};

// 添加新 todo
const addTodo = (text) => {
setTodos(produce(todos, draft => {
draft.push({ id: Date.now(), text, done: false });
}));
};

return (
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
);
}

// ========== 在 Redux 中使用 ==========
// 传统 reducer
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: action.id, text: action.text, done: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
default:
return state;
}
};

// 使用 Immer 的 reducer
import { produce } from 'immer';

const todosReducerImmer = (state = [], action) =>
produce(state, draft => {
switch (action.type) {
case 'ADD_TODO':
draft.push({ id: action.id, text: action.text, done: false });
break;
case 'TOGGLE_TODO':
const todo = draft.find(t => t.id === action.id);
if (todo) todo.done = !todo.done;
break;
}
});

// ========== React Hook: useImmer ==========
import { useImmer } from 'use-immer';

function TodoApp() {
const [todos, updateTodos] = useImmer([
{ id: 1, text: '学习', done: false }
]);

// 更直接的状态更新方式
const toggle = (id) => {
updateTodos(draft => {
const todo = draft.find(t => t.id === id);
if (todo) todo.done = !todo.done;
});
};

// ...
}

Immer 的优势

  • 写法直观,学习成本低
  • 自动冻结生成的对象
  • 支持原生 JS 对象,无需转换
  • 出色的 TypeScript 支持

Immer 的工作流程

  1. 创建当前状态的代理(draft)
  2. 在 draft 上执行修改操作
  3. 根据 draft 上的修改记录,生成新的不可变状态
  4. 返回新状态(原状态保持不变)

性能考虑

结构共享

专业的不可变库使用结构共享技术来优化内存使用:

// 原生方式:每次都创建全新的数据结构
const original = { a: 1, b: 2, c: { d: 3, e: 4 } };
const modified = { ...original, a: 10 };

// original 和 modified 的 c 属性指向同一个对象
// 这就是结构共享的一种形式
console.log(original.c === modified.c); // true

// Immutable.js 内部使用 Trie 数据结构实现高效的结构共享
// 当修改某个节点时,只需要重新创建从根到该节点路径上的节点
// 其他节点可以复用

选择性使用

不是所有数据都需要使用不可变库:

// 简单场景:原生方法足够
const arr = [1, 2, 3];
const newArr = [...arr, 4]; // 简单直接

// 复杂场景:使用 Immer
const complexState = {
users: { /* 大量数据 */ },
posts: { /* 大量数据 */ },
comments: { /* 大量数据 */ }
};

const updated = produce(complexState, draft => {
draft.users.byId[123].profile.avatar = 'new-avatar.png';
});

性能对比

// 大数据量测试
const largeArray = Array(100000).fill(0).map((_, i) => i);

// 方式一:展开运算符(每次创建新数组)
console.time('spread');
for (let i = 0; i < 100; i++) {
const newArr = [...largeArray, i];
}
console.timeEnd('spread');

// 方式二:Immutable.js(结构共享)
const { List } = require('immutable');
const immutableList = List(largeArray);

console.time('immutable');
for (let i = 0; i < 100; i++) {
immutableList.push(i);
}
console.timeEnd('immutable');
// 通常 Immutable.js 更快,因为它使用结构共享

最佳实践

1. 使用 const 声明

// 好:const 防止变量被重新赋值
const state = { count: 0 };
const newState = { count: state.count + 1 };

// 不好:let 允许重新赋值
let state = { count: 0 };
state = { count: 1 }; // 容易混淆

2. 分离读取和写入

// 读取:直接访问
function getUserName(state) {
return state.user.name;
}

// 写入:返回新对象
function setUserName(state, name) {
return {
...state,
user: { ...state.user, name }
};
}

3. 使用工具函数简化嵌套更新

// 通用嵌套更新函数
const updateIn = (obj, path, fn) => {
if (path.length === 0) return fn(obj);

const [head, ...tail] = path;
return {
...obj,
[head]: updateIn(obj[head], tail, fn)
};
};

// 使用
const state = {
user: {
profile: {
name: '张三',
age: 25
}
}
};

const updated = updateIn(state, ['user', 'profile', 'age'], age => age + 1);
// { user: { profile: { name: '张三', age: 26 } } }

4. 开发环境使用冻结

// 在开发环境中冻结状态
function createStore(initialState) {
let state = initialState;

return {
getState: () => state,
setState: (newState) => {
if (process.env.NODE_ENV === 'development') {
state = deepFreeze(newState);
} else {
state = newState;
}
}
};
}

5. 选择合适的工具

场景推荐方案
简单状态更新展开运算符、Object.assign
复杂嵌套数据Immer
高性能大数据量Immutable.js
React 状态管理Immer + useImmer
Redux 状态管理Immer 或 Immutable.js

小结

不可变性是函数式编程的基石:

  • 核心概念:数据一旦创建就不应被修改,每次"修改"都创建新数据
  • 主要好处:可预测性、易于调试、性能优化、并发安全
  • 实现方式:展开运算符、Object.freeze、Immer、Immutable.js
  • 最佳实践:使用 const、分离读写、选择合适的工具

不可变性虽然需要改变编程习惯,但它带来的好处是值得的。在现代前端开发中,React、Redux、MobX 等框架都依赖不可变性来实现高效的状态管理。理解不可变性,是成为高级前端开发者的必修课。

参考资源