状态管理中的不可变数据并不是什么新鲜概念。无论是React的函数组件更新机制还是Redux的状态对比逻辑,都依赖于引用变化来判断是否需要重新渲染。手动维护不可变结构通常要写大量展开运算符,例如复制数组、复制嵌套对象,一旦层级加深代码就会迅速膨胀。更危险的是,某个分支忘记拷贝就修改原对象,最终导致界面不更新或出现难以复现的状态错乱。Immer提供了一种折中方案:你仍然用熟悉的可变写法修改数据,它负责生成全新的不可变状态树。

一、不可变数据为什么让状态管理更可靠
不可变数据意味着数据一旦创建就不能被修改,任何变更都会返回一个新的数据副本。前端框架之所以青睐这种模式,是因为引用比较的成本极低。React在判断组件是否需要重新渲染时,通常会对props和state进行浅比较。如果更新状态时直接修改原对象,引用不会发生变化,React会认为没有更新,从而跳过渲染,导致界面与实际状态不一致。
举一个常见的反面例子:在React组件中直接给状态对象的属性赋值,然后调用setState更新同一个引用。这种做法即使数据值已经变了,页面也不会触发预期的重渲染。更隐蔽的问题出现在多人协作或异步流程中,可变更新会让状态在不同执行阶段被意外篡改,排查起来非常痛苦。而不可变更新强制你每次生成新对象,配合浅比较就能准确感知变化,同时还为时间旅行调试、撤销重做等功能提供了基础。
手动编写不可变更新并非不可能,只是对嵌套结构很不友好。例如要更新一个二级嵌套对象,需要同时展开外层对象、展开内层对象、再修改目标字段。数组操作如按索引更新、插入或删除,同样需要组合slice、展开运算符和filter。代码一旦复杂起来,遗漏拷贝的概率直线上升,维护成本也会让团队成员逐渐失去耐心。
二、Immer的核心机制:草稿代理与结构共享
Immer的核心思想是把不可变数据的产生过程交给一个代理层。你只需要把当前状态和一个修改函数传给produce,在函数内部直接对草稿draft进行可变操作。Immer会跟踪这些操作,并在函数执行结束后生成一个新的不可变状态。草稿本身是一个深度代理,所有读写都被拦截,因此你可以安全地使用push、splice、对象属性赋值等可变语法,不用再手动展开每一层结构。
从原理上看,Immer利用了ES6的Proxy能力。每次对草稿中的节点进行修改时,Immer会复制该节点及其受影响的父级节点,未修改的部分则保持原引用,这种策略称为结构共享。例如一个包含用户信息和购物车列表的状态,当你只修改用户年龄时,购物车数组的引用不会变,用户对象的引用会变,外层状态对象引用也会变。这样既满足了不可变性的要求,又避免了整棵状态树的深拷贝开销。
下面用一段简单的代码展示produce的基本行为。注意修改草稿后,原状态不会被改变,但未修改的分支仍然保持原来的引用。
import { produce } from 'immer';
const baseState = {
user: { name: 'Alice', age: 25 },
items: [{ id: 1, value: 10 }]
};
const nextState = produce(baseState, draft => {
draft.user.age += 1;
draft.items[0].value = 20;
});
console.log(baseState.user.age); // 25
console.log(nextState.user.age); // 26
console.log(baseState === nextState); // false
console.log(baseState.items === nextState.items); // false
如果对一个很大的状态树只做小范围更新,Immer只会复制修改路径上的对象,其余节点复用原引用。这一点对手动展开运算符来说很难做到,手动写法往往需要逐层复制才能保证类型安全,稍有不慎就会丢失结构共享带来的性能优势。
三、实战:在React和Redux中用Immer简化更新逻辑
在React的useReducer场景中,Immer可以大幅降低reducer函数的复杂度。传统写法需要先复制state,再复制嵌套数组或对象,然后修改副本并返回。而使用produce后,reducer内部可以像操作普通可变数据一样直接修改draft。下面是一个待办事项列表的reducer示例,展示了添加和切换完成状态的写法。
import { useReducer } from 'react';
import { produce } from 'immer';
const initialState = {
todos: []
};
function reducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return produce(state, draft => {
draft.todos.push({ id: action.id, text: action.text, done: false });
});
case 'TOGGLE_TODO':
return produce(state, draft => {
const todo = draft.todos.find(t => t.id === action.id);
if (todo) {
todo.done = !todo.done;
}
});
case 'CLEAR_COMPLETED':
return produce(state, draft => {
draft.todos = draft.todos.filter(t => !t.done);
});
default:
return state;
}
}
可以看到,reducer内部没有出现任何展开运算符或slice调用,代码意图非常直白。尤其是嵌套更新场景,例如修改某个列表中对象的某个字段,传统写法需要先根据索引复制数组,再复制目标对象,而Immer只需要找到目标对象并给属性赋值即可。这种简化在状态结构复杂时收益特别明显。
Redux Toolkit已经内置了Immer,你在使用createSlice编写reducer时,其实就运行在Immer的草稿机制之上。因此在Redux Toolkit中,即使你直接修改state,底层仍然是不可变更新。但如果你的项目还在使用传统Redux,也可以单独引入produce来包裹reducer逻辑。需要注意的是,produce的返回值必须是新的状态,不要对原始state做任何修改,也不要忘记在匹配不到action时返回原state。
除了useReducer和Redux,Immer同样适用于Zustand、MobX等状态管理库。只要代码中存在手动构造不可变数据的环节,Immer都能提供更自然的表达方式。不过要记住,Immer并不是银弹,它的优势主要体现在嵌套深、更新频繁的场景。对于简单的一层状态对象,直接使用展开运算符可能更直观。
四、性能考量与常见误区
虽然Immer简化了代码,但代理机制会带来一定的运行时开销。每次调用produce都会创建代理对象,这对于高频更新场景(例如每帧触发的动画状态、大量并发的输入事件)可能形成性能瓶颈。好在Immer的代理只在当前produce执行期间存在,执行结束后状态树会被冻结,后续只读访问不会额外付出代理成本。如果性能测试发现Immer成为瓶颈,可以针对热点路径改用手动更新,或者减少produce的调用频率。
使用Immer时有一个常见误区:在草稿函数中执行副作用。produce的回调应该是纯修改逻辑,不要在里面发起网络请求、修改外部变量或读取实时变化的数据。因为这些操作会破坏可预测性,并且Immer可能无法正确追踪草稿之外的变更。另一个误区是返回一个全新的状态对象而不修改草稿。虽然这种写法也能工作,但会绕过Immer的结构共享优化,导致不必要的深层复制。
此外,自动冻结是Immer在开发环境下的默认行为。任何通过produce生成的状态都会被深度冻结,如果你在后续代码中尝试修改这些状态,会直接抛出错误。这有助于尽早发现问题,但在生产环境中可以通过配置关闭冻结以提升性能。值得强调的是,冻结只针对通过Immer生成的对象,原始状态不受影响,因此不要依赖冻结行为来保护原始数据。
总结来看,Immer的价值在于把不可变更新的心智负担从开发者转移到工具层。它让代码更易读、更易维护,同时保留了不可变数据带来的所有好处。是否引入Immer,取决于你的项目里手动不可变更新的痛苦程度。但掌握它的原理和使用边界,会让你的状态管理代码更加从容。

