在React项目中维护不可变数据一直是让开发者头疼的问题。手写展开运算符容易遗漏嵌套层级,Immutable.js虽然功能强大,却引入了一整套自定义数据结构和API,让代码与原生JavaScript生态割裂。Immer的出现改变了这一局面:它允许你用可变的写法生成不可变的结果,代码可读性大幅提升。本文将系统地讲解为什么值得迁移、Immer的底层原理,以及具体如何完成这场迁移。

为什么要离开Immutable.js:三个现实痛点
第一个痛点是包体积。Immutable.js压缩后约60KB,gzip后也有20KB以上,对于一个只需要更新列表或表单状态的应用来说代价不小。而Immer的核心库gzip后不到5KB,配合原生对象使用,几乎不产生额外体积负担。
第二个痛点是数据结构的割裂感。Immutable.js的Map和List不是原生对象,在与第三方库交互时必须反复调用toJS()和fromJS()。调试时控制台里看到的也是一堆嵌套的结构对象,排查问题相当费劲。Immer操作的就是普通JavaScript对象和数组,没有转换成本。
第三个痛点是TypeScript类型推断体验。Immutable.js的泛型系统复杂,深层嵌套的类型声明冗长,且在不同版本之间发生过断裂式变化。Immer对类型系统完全透明,一个interface可以同时用于可变草稿和不可变结果,类型代码量显著减少。
Immer的核心原理:基于Proxy的写时复制
Immer的魔力来自ES6的Proxy。当你调用produce时,Immer会为原始状态创建一个代理对象(草稿)。你对草稿的所有修改都会被拦截记录,但并不会真正改动原对象。只有当修改发生后,Immer才会沿着被修改的路径浅拷贝相关节点,最终生成一个新的冻结对象。
import { produce } from 'immer';
const baseState = {
user: { name: '张三', tags: ['前端'] },
settings: { theme: 'dark' }
};
// 直接修改草稿,产出的是不可变新状态
const nextState = produce(baseState, draft => {
draft.user.tags.push('React');
draft.user.name = '李四';
});
console.log(baseState.user.name); // 张三,原对象未被污染
console.log(nextState.user === baseState.user); // false,被修改的分支是新引用
console.log(nextState.settings === baseState.settings); // true,未修改分支共享引用注意上面代码中一个关键细节:settings分支没有被动过,新旧状态共享同一个引用。这种按需复制的结构共享机制,意味着深层数据的拷贝成本只发生在实际被修改的路径上,性能通常优于手动展开多层对象。
另外一个容易被忽视的特性是自动冻结。开发环境下Immer会对结果对象执行Object.freeze,一旦你在状态外部试图修改它,控制台会立刻抛错。这相当于免费获得了运行时的不可变性校验,比Immutable.js需要手动调用isImmutable检查方便得多。
逐步迁移实战:从fromJS到produce的完整改写
第一步:替换状态初始化与Reducer
迁移的核心工作集中在Reducer层。Immutable.js风格的Reducer通常长这样:
// Immutable.js 旧代码
import { fromJS } from 'immutable';
const initialState = fromJS({
items: [],
loading: false
});
function reducer(state = initialState, action) {
switch (action.type) {
case 'ADD_ITEM':
return state.update('items', items =>
items.push(action.payload)
);
case 'TOGGLE_LOADING':
return state.set('loading', !state.get('loading'));
default:
return state;
}
}迁移到Immer后,代码变成原生写法:
// Immer 新代码
import { produce } from 'immer';
const initialState = {
items: [],
loading: false
};
const reducer = produce((draft, action) => {
switch (action.type) {
case 'ADD_ITEM':
draft.items.push(action.payload);
break;
case 'TOGGLE_LOADING':
draft.loading = !draft.loading;
break;
}
}, initialState);这里用了一个技巧:把produce包在Reducer外面,让它充当柯里化的Reducer生成器。第一个参数是初始状态,之后的dispatch调用会自动把当前状态注入草稿。这种写法消除了所有get、set、update链式调用,代码行数通常能减少三分之一以上。
第二步:处理组件中的取值与提交
组件层的改动主要是去掉.get()和.toJS()。React Redux在7.x版本之后默认浅比较,使用Immutable.js时往往需要react-redux-firebase之类的适配方案,而Immer产出的就是原生对象,useSelector可以直接按属性取值:
function TodoList() {
const items = useSelector(state => state.items);
// 直接map遍历,不需要toJS()
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}第三步:在Hooks中搭配useState使用
Immer还提供了useImmer这个Hook,需要单独安装use-immer包。它把函数式更新也简化到了极致:
import { useImmer } from 'use-immer';
function Profile() {
const [profile, updateProfile] = useImmer({
name: '',
address: { city: '北京', district: '朝阳' }
});
const changeCity = city => {
updateProfile(draft => {
draft.address.city = city; // 不再需要三层展开运算符
});
};
return <input value={profile.address.city} onChange={e => changeCity(e.target.value)} />;
}迁移中的常见坑与注意事项
第一类坑是新旧数据的混合。迁移往往是渐进式的,某些模块可能还保留Immutable.js。此时切记不要把Map对象直接塞进Immer的草稿里修改,Proxy无法正确代理Immutable.js的自定义结构。过渡期的做法是在边界处统一调用toJS()转换后再交给Immer处理。
第二类坑是异步修改。Immer的草稿只在produce回调执行期间有效,回调返回后草稿被回收。如果在草稿上发起异步操作再回来修改,会抛出异常。正确做法是先在回调中同步收集需要的变更,或者用produce的带配方参数形式,在Promise完成后生成新状态:
// 错误写法:异步中操作已失效的草稿
const bad = produce(state, async draft => {
draft.data = await fetchData(); // 会报错
});
// 正确写法:produce返回Promise,最终结果仍是不可变对象
const good = await produce(state, async draft => {
draft.data = await fetchData();
});第三类坑是性能层面的误用。虽然结构共享让拷贝成本可控,但每次produce仍有Proxy创建开销。对于高频触发的小状态更新(如鼠标移动坐标),可以把多个状态合并进同一个produce调用,减少创建草稿的次数。
最后建议迁移时保留Immutable.js的依赖一至两个迭代周期,通过灰度验证各模块数据流正常后再彻底移除,同时别忘了删除babel-plugin-transform-immutable之类的编译插件。整体迁移完成后,包体积下降、类型代码精简、调试体验提升,这三项收益会让团队很快感受到选择Immer的价值。
ReactImmutable.jsImmer修改时间:2026-09-04 22:00:47