说说你对 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.memouseMemouseCallbackPureComponentshouldComponentUpdate 等优化手段都依赖浅比较(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 实现:

  1. produce 接收 baseState,创建一个 Proxy 包装的 draft
  2. 用户对 draft 的所有修改操作被 Proxy 拦截并记录
  3. 修改完成后,Immer 基于记录的变化生成新的对象
  4. 未被修改的部分通过结构共享(structural sharing)保持原引用
baseState
  ├── user ─────────────── nextState.user(未修改,同一引用)
  ├── todos[0] ─────────── nextState.todos[0](未修改,同一引用)
  └── todos[1] (修改)      nextState.todos[1](新对象)

这意味着 Immer 只会为被修改的路径上的节点创建新对象,性能开销很小。

四、Redux Toolkit 中的 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.jsImmer
数据结构自定义(Map/List)原生 JS 对象/数组
API专有(get/set/update)原生写法
学习成本
Bundle 大小~63KB~5KB
与第三方库兼容差(需要 toJS 转换)好(原生对象)
性能(大数据)优秀(Trie 结构)良好
TypeScript 支持一般

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.memouseMemo 可以高效工作——未被修改的子树不会触发子组件重渲染。

七、实用模式

避免常见错误

// 错误:忘记数组是引用类型
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、依赖比较)正确工作。

实践建议:

  1. 简单场景:展开运算符 + ES2023 数组方法足够
  2. 中等复杂度:引入 Immer,用 produce 简化嵌套更新
  3. Redux 项目:RTK 内置 Immer,直接用可变写法
  4. 遗留项目:如果已在使用 Immutable.js,按需逐步迁移到 Immer
  5. 始终牢记:不要直接修改 state 和 props,每次更新都要返回新引用