immutability-helper vs seamless-immutable vs immer vs immutable
JavaScript 不可变数据管理库对比
immutability-helperseamless-immutableimmerimmutable

JavaScript 不可变数据管理库对比

immerimmutability-helperimmutableseamless-immutable 都是用于在 JavaScript 应用中实现不可变数据更新的工具库,旨在帮助开发者以更安全、更高效的方式处理状态变更。它们通过不同机制避免直接修改原始对象,从而减少副作用、提升可预测性,并与 React 等响应式框架良好集成。immer 采用“写时复制”策略,允许开发者使用熟悉的可变语法;immutability-helper 提供基于指令的更新语法;immutable 引入全新的不可变数据结构(如 Map、List);而 seamless-immutable 则在普通 JavaScript 对象和数组基础上提供不可变操作方法,保持原生 API 的熟悉感。

npm下载趋势

3 年

GitHub Stars 排名

统计详情

npm包名称
下载量
Stars
大小
Issues
发布时间
License
immutability-helper801,1885,260-66 年前MIT
seamless-immutable343,4195,350-548 年前BSD-3-Clause
immer028,912913 kB481 个月前MIT
immutable033,093710 kB12116 天前MIT

JavaScript 不可变数据管理:Immer、Immutability Helper、Immutable.js 与 Seamless Immutable 深度对比

在现代前端开发中,不可变数据是构建可预测、可调试状态管理的核心原则。immerimmutability-helperimmutableseamless-immutable 各自提供了不同的解决方案,但它们在设计理念、使用体验和性能特征上差异显著。本文将从真实开发场景出发,深入比较这四个库的关键能力。

🧠 核心理念:如何实现“不可变”?

immer 采用“写时复制”(copy-on-write)策略,通过 Proxy 拦截对草稿状态的修改,仅在必要时克隆受影响的路径。

import { produce } from 'immer';

const state = { user: { name: 'Alice', age: 30 } };

const nextState = produce(state, draft => {
  draft.user.age = 31; // 看似可变,实则生成新对象
});
// state 未被修改,nextState.user !== state.user

immutability-helper 使用指令式更新语法(类似 MongoDB 的操作符),通过 $set$push 等命令描述变更。

import update from 'immutability-helper';

const state = { user: { name: 'Alice', age: 30 } };

const nextState = update(state, {
  user: { age: { $set: 31 } }
});
// 返回新对象,state 保持不变

immutable 完全替换原生数据结构,提供 MapListSet 等不可变类型,所有操作返回新实例。

import { Map } from 'immutable';

const state = Map({ user: Map({ name: 'Alice', age: 30 }) });

const nextState = state.setIn(['user', 'age'], 31);
// 返回新的 Map,原 state 未变

seamless-immutable 扩展原生对象和数组,添加 setmerge 等方法,内部通过深拷贝实现不可变性。

import Immutable from 'seamless-immutable';

const state = Immutable({ user: { name: 'Alice', age: 30 } });

const nextState = state.setIn(['user', 'age'], 31);
// 返回新 Immutable 对象

⚙️ 更新嵌套数据:语法简洁性与可读性

假设我们要更新一个三层嵌套对象中的值:state.a.b.c = 42

immer 允许直接赋值,代码最接近原生 JavaScript:

produce(state, draft => {
  draft.a.b.c = 42;
});

immutability-helper 需要构造嵌套的指令对象,层级越深越繁琐:

update(state, {
  a: { b: { c: { $set: 42 } } }
});

immutable 使用 setIn 方法,路径以数组形式传入,清晰但需记住 API:

state.setIn(['a', 'b', 'c'], 42);

seamless-immutable 同样提供 setIn,用法与 immutable 类似:

state.setIn(['a', 'b', 'c'], 42);

💡 在复杂嵌套场景下,immer 的可读性和开发效率明显优于其他方案。

📦 数据结构兼容性:与原生 JS 的互操作

immer 完全兼容原生对象和数组,输入输出均为标准 JavaScript 类型,无需转换。

const plainObj = { x: 1 };
const updated = produce(plainObj, d => { d.x = 2 });
console.log(updated.x); // 2,仍是普通对象

immutability-helper 同样操作原生结构,返回普通对象。

immutable 返回的是 MapList 等特殊类型,若需与非 Immutable 代码交互,必须调用 .toJS() 转换,带来额外开销:

const plain = state.toJS(); // 深拷贝,性能敏感场景需谨慎

seamless-immutable 返回的是包装后的对象,虽可通过 .asMutable() 转回原生,但默认行为仍需注意类型检查问题。

🚫 维护状态:是否推荐用于新项目?

根据官方仓库和 npm 页面信息:

  • immutability-helper 自 2018 年后无实质性更新,GitHub README 明确写道:“This library is no longer actively maintained. We recommend using immer instead.” 新项目不应选用
  • immerimmutableseamless-immutable 均处于活跃维护状态,但社区趋势明显偏向 immer

🔍 性能考量:结构共享 vs 深拷贝

immerimmutable 利用结构共享(structural sharing),仅复制变更路径上的节点,其余部分共享引用,内存和性能表现优异。

seamless-immutable 在每次更新时对整个对象树进行深拷贝(除非使用 setIn 且路径存在),在大型状态树中可能导致显著性能下降。

immutability-helper 内部也使用浅拷贝+递归更新,但不如 immer 的 Proxy 机制高效。

🧪 类型支持:TypeScript 体验

  • immer 提供一流 TypeScript 支持,produce 能正确推导草稿和返回类型。
  • immutable 有完善的泛型支持,但类型系统与原生 JS 不同,需适应。
  • seamless-immutable 类型定义较弱,常需类型断言。
  • immutability-helper 几乎无类型安全保证。

🔄 与 React 状态管理集成

  • Redux Toolkit 官方内置 immer,推荐作为 reducer 编写标准。
  • ZustandJotai 等现代状态库均优先适配 immer
  • immutable 可用于 Redux,但需自定义序列化逻辑,且 DevTools 支持较差。
  • seamless-immutableimmutability-helper 在现代生态中几乎无官方集成。

📊 总结:关键差异一览

特性immerimmutability-helperimmutableseamless-immutable
核心机制Proxy + 写时复制指令式更新自定义不可变数据结构原生对象扩展 + 深拷贝
语法直观性⭐⭐⭐⭐⭐(可变风格)⭐⭐(嵌套指令)⭐⭐⭐(需学习新 API)⭐⭐⭐(类似原生)
原生兼容性完全兼容完全兼容.toJS() 转换包装对象,需注意类型
性能高(结构共享)高(结构共享)低(深拷贝)
TypeScript优秀良好一般
新项目推荐✅ 强烈推荐❌ 已废弃⚠️ 仅特定场景⚠️ 小型项目可考虑

💡 最终建议

  • 绝大多数新项目应首选 immer:它平衡了开发体验、性能和生态兼容性,已成为现代前端不可变更新的事实标准。
  • 避免使用 immutability-helper:官方已弃用,无理由在新代码中引入。
  • 仅当需要强类型不可变集合且能接受转换成本时,才考虑 immutable:例如处理大量列表操作或需要持久化数据结构的场景。
  • seamless-immutable 适用于轻量级需求:如果你无法引入新数据类型,又不愿写更新逻辑,可作为过渡方案,但需警惕性能陷阱。

不可变性的目标是让状态变更更安全、更可预测。选择工具时,不仅要考虑功能,更要评估长期维护成本和团队认知负担。在这一点上,immer 凭借其“看似可变,实则不可变”的哲学,为开发者提供了最佳平衡点。

如何选择: immutability-helper vs seamless-immutable vs immer vs immutable

  • immutability-helper:

    选择 immutability-helper 仅当你维护一个遗留项目且已深度依赖其 $push$splice 等指令式更新语法。该库自 2018 年起已不再积极维护,官方文档明确建议迁移到 immer。新项目应避免使用,因其 API 设计较为冗长,且缺乏现代不可变库的性能优化和类型支持。

  • seamless-immutable:

    选择 seamless-immutable 如果你希望在保持使用普通 JavaScript 对象和数组的同时获得不可变语义,且项目对性能要求不高。它不引入新数据类型,API 接近原生(如 setmerge),但内部仍会进行深拷贝,在大型或深层嵌套数据上可能产生性能瓶颈。适用于中小型应用或对迁移成本敏感的场景。

  • immer:

    选择 immer 如果你希望用最接近原生 JavaScript 的方式编写不可变更新逻辑,同时享受自动结构共享和性能优化。它特别适合与 Redux 或 Zustand 等状态管理库配合使用,能显著减少样板代码,并降低学习成本。其 produce 函数允许你在回调中直接“修改”草稿状态,非常适合习惯命令式编程的团队。

  • immutable:

    选择 immutable 如果你需要强类型的、经过严格验证的不可变数据结构(如 List、Map、Set),并愿意接受其学习曲线和与原生 JavaScript 对象的互操作成本。它在大型复杂状态树中表现稳定,支持高效的结构共享和持久化,但需注意序列化/反序列化开销,以及与非 Immutable.js 代码交互时的转换负担。

immutability-helper的README

immutability-helper

NPM version Build status Test coverage Downloads Minified size Gzip size

Mutate a copy of data without changing the original source

Setup via NPM

npm install immutability-helper --save

This is a drop-in replacement for react-addons-update:

// import update from 'react-addons-update';
import update from 'immutability-helper';

const state1 = ['x'];
const state2 = update(state1, {$push: ['y']}); // ['x', 'y']

Note that this module has nothing to do with React. However, since this module is most commonly used with React, the docs will focus on how it can be used with React.

Overview

React lets you use whatever style of data management you want, including mutation. However, if you can use immutable data in performance-critical parts of your application it's easy to implement a fast shouldComponentUpdate() method to significantly speed up your app.

Dealing with immutable data in JavaScript is more difficult than in languages designed for it, like Clojure. However, we've provided a simple immutability helper, update(), that makes dealing with this type of data much easier, without fundamentally changing how your data is represented. You can also take a look at Facebook's Immutable.js and React’s Using Immutable Data Structures section for more detail on Immutable.js.

The Main Idea

If you mutate data like this:

myData.x.y.z = 7;
// or...
myData.a.b.push(9);

You have no way of determining which data has changed since the previous copy has been overwritten. Instead, you need to create a new copy of myData and change only the parts of it that need to be changed. Then you can compare the old copy of myData with the new one in shouldComponentUpdate() using triple-equals:

const newData = deepCopy(myData);
newData.x.y.z = 7;
newData.a.b.push(9);

Unfortunately, deep copies are expensive, and sometimes impossible. You can alleviate this by only copying objects that need to be changed and by reusing the objects that haven't changed. Unfortunately, in today's JavaScript this can be cumbersome:

const newData = Object.assign({}, myData, {
  x: Object.assign({}, myData.x, {
    y: Object.assign({}, myData.x.y, {z: 7}),
  }),
  a: Object.assign({}, myData.a, {b: myData.a.b.concat(9)})
});

While this is fairly performant (since it only makes a shallow copy of log n objects and reuses the rest), it's a big pain to write. Look at all the repetition! This is not only annoying, but also provides a large surface area for bugs.

update()

update() provides simple syntactic sugar around this pattern to make writing this code easier. This code becomes:

import update from 'immutability-helper';

const newData = update(myData, {
  x: {y: {z: {$set: 7}}},
  a: {b: {$push: [9]}}
});

While the syntax takes a little getting used to (though it's inspired by MongoDB's query language) there's no redundancy, it's statically analyzable and it's not much more typing than the mutative version.

The $-prefixed keys are called commands. The data structure they are "mutating" is called the target.

Available Commands

  • {$push: array} push() all the items in array on the target.
  • {$unshift: array} unshift() all the items in array on the target.
  • {$splice: array of arrays} for each item in arrays call splice() on the target with the parameters provided by the item. Note: The items in the array are applied sequentially, so the order matters. The indices of the target may change during the operation.
  • {$set: any} replace the target entirely.
  • {$toggle: array of strings} toggles a list of boolean fields from the target object.
  • {$unset: array of strings} remove the list of keys in array from the target object.
  • {$merge: object} merge the keys of object with the target.
  • {$apply: function} passes in the current value to the function and updates it with the new returned value.
  • {$add: array of objects} add a value to a Map or Set. When adding to a Set you pass in an array of objects to add, when adding to a Map, you pass in [key, value] arrays like so: update(myMap, {$add: [['foo', 'bar'], ['baz', 'boo']]})
  • {$remove: array of strings} remove the list of keys in array from a Map or Set.

Shorthand $apply syntax

Additionally, instead of a command object, you can pass a function, and it will be treated as if it was a command object with the $apply command: update({a: 1}, {a: function}). That example would be equivalent to update({a: 1}, {a: {$apply: function}}).

Limitations

:warning: update only works for data properties, not for accessor properties defined with Object.defineProperty. It just does not see the latter, and therefore might create shadowing data properties which could break application logic depending on setter side effects. Therefore update should only be used on plain data objects that only contain data properties as descendants.

Examples

Simple push

const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]

initialArray is still [1, 2, 3].

Nested collections

const collection = [1, 2, {a: [12, 17, 15]}];
const newCollection = update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
// => [1, 2, {a: [12, 13, 14, 15]}]

This accesses collection's index 2, key a, and does a splice of one item starting from index 1 (to remove 17) while inserting 13 and 14.

Updating a value based on its current one

const obj = {a: 5, b: 3};
const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
// This is equivalent, but gets verbose for deeply nested collections:
const newObj2 = update(obj, {b: {$set: obj.b * 2}});

(Shallow) Merge

const obj = {a: 5, b: 3};
const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}

Computed Property Names

Arrays can be indexed into with runtime variables via the ES2015 Computed Property Names feature. An object property name expression may be wrapped in brackets [] which will be evaluated at runtime to form the final property name.

const collection = {children: ['zero', 'one', 'two']};
const index = 1;
const newCollection = update(collection, {children: {[index]: {$set: 1}}});
// => {children: ['zero', 1, 'two']}

Removing an element from an array

// Delete at a specific index, no matter what value is in it
update(state, { items: { $splice: [[index, 1]] } });

Autovivification

Autovivification is the auto creation of new arrays and objects when needed. In the context of javascript that would mean something like this

const state = {}
state.a.b.c = 1; // state would equal { a: { b: { c: 1 } } }

Since javascript doesn't have this "feature", the same applies to immutability-helper. The reason why this is practically impossible in javascript and by extension immutability-helper is the following:

var state = {}
state.thing[0] = 'foo' // What type should state.thing have? Should it be an object or array?
state.thing2[1] = 'foo2' // What about thing2? This must be an object!
state.thing3 = ['thing3'] // This is regular js, this works without autovivification
state.thing3[1] = 'foo3' // Hmm, notice that state.thing2 is an object, yet this is an array
state.thing2.slice // should be undefined
state.thing2.slice // should be a function

If you need to set something deeply nested and don't want to have to set each layer down the line, consider using this technique which is shown with a contrived example:

var state = {}
var desiredState = {
  foo: [
    {
      bar: ['x', 'y', 'z']
    },
  ],
};

const state2 = update(state, {
  foo: foo =>
    update(foo || [], {
      0: fooZero =>
        update(fooZero || {}, {
          bar: bar => update(bar || [], { $push: ["x", "y", "z"] })
        })
    })
});

console.log(JSON.stringify(state2) === JSON.stringify(desiredState)) // true
// note that state could have been declared as any of the following and it would still output true:
// var state = { foo: [] }
// var state = { foo: [ {} ] }
// var state = { foo: [ {bar: []} ] }

You can also choose to use the extend functionality to add an $auto and $autoArray command:

import update, { extend } from 'immutability-helper';

extend('$auto', function(value, object) {
  return object ?
    update(object, value):
    update({}, value);
});
extend('$autoArray', function(value, object) {
  return object ?
    update(object, value):
    update([], value);
});

var state = {}
var desiredState = {
  foo: [
    {
      bar: ['x', 'y', 'z']
    },
  ],
};
var state2 = update(state, {
  foo: {$autoArray: {
    0: {$auto: {
      bar: {$autoArray: {$push: ['x', 'y', 'z']}}
    }}
  }}
});
console.log(JSON.stringify(state2) === JSON.stringify(desiredState)) // true

Adding your own commands

The main difference this module has with react-addons-update is that you can extend this to give it more functionality:

import update, { extend } from 'immutability-helper';

extend('$addtax', function(tax, original) {
  return original + (tax * original);
});
const state = { price: 123 };
const withTax = update(state, {
  price: {$addtax: 0.8},
});
assert(JSON.stringify(withTax) === JSON.stringify({ price: 221.4 }));

Note that original in the function above is the original object, so if you plan making a mutation, you must first shallow clone the object. Another option is to use update to make the change return update(original, { foo: {$set: 'bar'} })

If you don't want to mess around with the globally exported update function you can make a copy and work with that copy:

import { Context } from 'immutability-helper';

const myContext = new Context();

myContext.extend('$foo', function(value, original) {
  return 'foo!';
});

myContext.update(/* args */);