说说你对 immutable 的理解?如何应用在 React 项目中?
一、为什么不可变性对 React 至关重要
React 的渲染优化核心依赖引用比较:当 state 或 props 的引用没有变化时,React 认为数据没变,跳过重渲染。这就要求每次状态更新都必须返回一个新的引用,而非修改原对象。
function App() {
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleBirthday = () => {
// 错误:直接修改原对象,引用不变,React 不会重渲染
user.age += 1;
setUser(user); // user === user,React 认为没变化
// 正确:创建新对象
setUser({ ...user, age: user.age + 1 });
};
return <div>{user.name} - {user.age}</div>;
}
React.memo、useMemo、useCallback、PureComponent、shouldComponentUpdate 等优化手段都依赖浅比较(shallow comparison),如果违反不可变性原则,这些优化全部失效。
二、浅拷贝的困境
展开运算符的局限
对于嵌套对象,展开运算符只做浅拷贝:
const state = {
user: {
name: 'Alice',
address: {
city: 'Beijing',
district: 'Haidian',
},
},
settings: { theme: 'dark' },
};
// 修改深层嵌套属性,需要逐层展开
const newState = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: 'Shanghai',
},
},
};
层级越深,代码越冗长,越容易出错。这是 React 项目中的常见痛点。
数组的不可变更新
const [items, setItems] = useState([
{ id: 1, name: 'A', count: 0 },
{ id: 2, name: 'B', count: 0 },
{ id: 3, name: 'C', count: 0 },
]);
// 添加
setItems([...items, { id: 4, name: 'D', count: 0 }]);
// 删除
setItems(items.filter(item => item.id !== 2));
// 修改某一项的嵌套属性
setItems(items.map(item =>
item.id === 2 ? { ...item, count: item.count + 1 } : item
));
// 排序(sort 会修改原数组,必须先拷贝)
setItems([...items].sort((a, b) => a.name.localeCompare(b.name)));
三、Immer — 用可变写法产出不可变数据
Immer 是目前最流行的不可变数据方案。它的核心 API 是 produce 函数:接收原始对象和一个"修改函数",在修改函数中可以像操作可变对象一样编写代码,Immer 会基于修改记录生成一个新的不可变对象。
基本用法
import { produce } from 'immer';
const baseState = {
user: {
name: 'Alice',
address: { city: 'Beijing', district: 'Haidian' },
},
todos: [
{ id: 1, text: '学 React', done: true },
{ id: 2, text: '学 Immer', done: false },
],
};
const nextState = produce(baseState, draft => {
draft.user.address.city = 'Shanghai';
draft.todos.push({ id: 3, text: '学 TypeScript', done: false });
draft.todos[1].done = true;
});
console.log(baseState.user.address.city); // 'Beijing'(原数据不变)
console.log(nextState.user.address.city); // 'Shanghai'
console.log(baseState.todos.length); // 2
console.log(nextState.todos.length); // 3
// 结构共享:未修改的部分保持引用相同
console.log(baseState.user.name === nextState.user.name); // true(字符串)
在 React 中使用
import { produce } from 'immer';
import { useState, useCallback } from 'react';
function TodoApp() {
const [state, setState] = useState({
todos: [],
filter: 'all',
});
const addTodo = useCallback((text) => {
setState(produce(draft => {
draft.todos.push({ id: Date.now(), text, done: false });
}));
}, []);
const toggleTodo = useCallback((id) => {
setState(produce(draft => {
const todo = draft.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}));
}, []);
const setFilter = useCallback((filter) => {
setState(produce(draft => {
draft.filter = filter;
}));
}, []);
return (
<div>
<AddTodo onAdd={addTodo} />
<FilterBar current={state.filter} onChange={setFilter} />
<TodoList todos={state.todos} onToggle={toggleTodo} filter={state.filter} />
</div>
);
}
Immer 的工作原理
Immer 使用 ES6 Proxy 实现:
produce 接收 baseState,创建一个 Proxy 包装的 draft
- 用户对 draft 的所有修改操作被 Proxy 拦截并记录
- 修改完成后,Immer 基于记录的变化生成新的对象
- 未被修改的部分通过结构共享(structural sharing)保持原引用
baseState
├── user ─────────────── nextState.user(未修改,同一引用)
├── todos[0] ─────────── nextState.todos[0](未修改,同一引用)
└── todos[1] (修改) nextState.todos[1](新对象)
这意味着 Immer 只会为被修改的路径上的节点创建新对象,性能开销很小。
RTK 的 createSlice 内部已集成 Immer,reducer 中可以直接"修改" state:
const todosSlice = createSlice({
name: 'todos',
initialState: {
list: [],
loading: false,
},
reducers: {
addTodo(state, action) {
// 直接 push,Immer 在幕后处理不可变更新
state.list.push({
id: Date.now(),
text: action.payload,
done: false,
});
},
toggleTodo(state, action) {
const todo = state.list.find(t => t.id === action.payload);
if (todo) {
todo.done = !todo.done;
}
},
removeTodo(state, action) {
const index = state.list.findIndex(t => t.id === action.payload);
if (index !== -1) {
state.list.splice(index, 1);
}
},
setLoading(state, action) {
state.loading = action.payload;
},
},
});
在 RTK 的 reducer 中,有两种写法都合法:
reducers: {
// 写法一:直接修改 draft(Immer 方式)
increment(state) {
state.value += 1;
},
// 写法二:返回全新对象(传统方式)
reset() {
return { value: 0 };
},
}
注意:不要同时修改 draft 又返回新对象,这会导致 Immer 报错。
五、Immutable.js
Immutable.js 是 Facebook 开发的老牌不可变数据库,提供持久化数据结构(Persistent Data Structures):
import { Map, List, fromJS } from 'immutable';
const state = fromJS({
user: { name: 'Alice', age: 25 },
todos: [
{ id: 1, text: '学习', done: false },
],
});
// 链式更新
const newState = state
.setIn(['user', 'age'], 26)
.updateIn(['todos'], list =>
list.push(Map({ id: 2, text: '复习', done: false }))
);
console.log(state.getIn(['user', 'age'])); // 25
console.log(newState.getIn(['user', 'age'])); // 26
console.log(state.get('todos').size); // 1
console.log(newState.get('todos').size); // 2
Immutable.js vs Immer
Immutable.js 的主要问题是数据结构不兼容原生 JavaScript——你不能对 Immutable Map 使用展开运算符或 Object.keys(),与组件库、工具库的集成需要频繁 toJS() 转换,这带来性能和心智负担。
现代项目推荐 Immer 而非 Immutable.js。
六、结构共享(Structural Sharing)
无论是 Immer 还是 Immutable.js,都采用结构共享来优化内存和性能。原理是:只重新创建被修改路径上的节点,未修改的子树保持原引用。
const state1 = {
a: { x: 1 },
b: { y: 2 },
c: { z: 3 },
};
// 只修改 b.y
const state2 = produce(state1, draft => {
draft.b.y = 20;
});
state1.a === state2.a // true — 结构共享
state1.b === state2.b // false — b 被修改,创建了新对象
state1.c === state2.c // true — 结构共享
state1 === state2 // false — 根节点总是新的
这种特性让 React 的 React.memo 和 useMemo 可以高效工作——未被修改的子树不会触发子组件重渲染。
七、实用模式
避免常见错误
// 错误:忘记数组是引用类型
function Parent() {
const [items, setItems] = useState([1, 2, 3]);
const handleSort = () => {
items.sort(); // 修改了原数组!
setItems(items); // 引用没变,不会重渲染
};
// 正确
const handleSortCorrect = () => {
setItems([...items].sort());
// 或者用 toSorted()(ES2023)
setItems(items.toSorted());
};
}
不可变数组操作速查
// ES2023+ 非破坏性数组方法
const sorted = arr.toSorted((a, b) => a - b); // 替代 sort()
const reversed = arr.toReversed(); // 替代 reverse()
const spliced = arr.toSpliced(1, 1, newItem); // 替代 splice()
const updated = arr.with(2, newValue); // 替代 arr[2] = newValue
这些新方法返回新数组而不修改原数组,与 React 的不可变性要求天然契合。
八、总结
不可变性是 React 性能优化的基石。理解它不仅是为了"避免 bug",更是为了让 React 的渲染优化机制(memo、shouldComponentUpdate、依赖比较)正确工作。
实践建议:
- 简单场景:展开运算符 + ES2023 数组方法足够
- 中等复杂度:引入 Immer,用
produce 简化嵌套更新
- Redux 项目:RTK 内置 Immer,直接用可变写法
- 遗留项目:如果已在使用 Immutable.js,按需逐步迁移到 Immer
- 始终牢记:不要直接修改 state 和 props,每次更新都要返回新引用