JavaScript 中的不可变对象

在面向对象或函数式编程中,不可变对象指的是在创建后它的状态不能改变的对象。相应的,状态可以被改变的对象,则被称为可变对象。

JavaScript 默认对象

JavaScript 只有一种结构,那就是对象。那么在 JavaScript 中的默认对象具有什么特性呢?

1
2
3
4
const person = {
name: 'A',
age: 18,
};

上例声明了一个 person 对象。需要注意的是 const person = {} 是对象声明的语法糖,相当于 const person = new Object() 。JavaScript 的 Object 提供了一个 getOwnPropertyDescriptor() 的方法,该方法返回指定对象上一个自有属性对应的属性描述符)。我们接下来查看一下 person 对象 name 属性的属性描述符:

1
2
3
4
5
console.log(Object.getOwnPropertyDescriptor(person, 'name'));
// { value: 'A',
// writable: true,
// enumerable: true,
// configurable: true }

JavaScript 对象里目前存在的属性描述符有两种主要形式:数据描述符存取描述符数据描述符是一个具有值的属性,该值可能是可写的,也可能不是可写的。存取描述符是由 getter-setter 函数对描述的属性。

数据描述符和存取描述符均具有以下可选键值:

键值 描述
value 该属性对应的值。可以是任何有效的 JavaScript 值。默认为 undefined
writable 当且仅当该属性的writabletrue时,value 才能被赋值运算符改变。默认为 false
enumerable 当且仅当该属性的enumerabletrue时,该属性才能够出现在对象的枚举属性中。默认为 false
configurable 当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false

存取描述符同时具有以下可选键值

键值 描述
get 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined
set 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined

需要注意的是:如果一个描述符不具有 value, writable, get 和 set 任意一个关键字,那么它将被认为是一个数据描述符。如果一个描述符同时有(value 或 writable)和( get 或 set)关键字,将会产生一个异常。

下表为描述符可同时具有的键值:

configurable enumerable value writable get set
数据描述符 Yes Yes Yes Yes No No
存取描述符 Yes Yes No No Yes Yes

可以看到,我们一般创建一个对象,其数据描述符默认均为 true。

JavaScript 中的不可变对象

在 JavaScript 对对象的操作无非是增删改查,而不可变对象的指的是创建后它的状态不能改变的对象。

在数据描述符中对应可以找到:writable 可以控制改的属性,configurable 控制删的属性。

而对于增的操作只能通过 Object 提供的 preventExtensions 来改变了。Object.preventExtensions()方法让一个对象变的不可扩展,也就是永远不能再添加新的属性。

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。我们可以通过这个方法来设置 writableconfigurable 属性。

我们接下来重写一下 person 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const person = {};
Object.defineProperty(person, 'name', {
value: 'A',
writable: false,
enumerable: true,
configurable: false,
});
Object.defineProperty(person, 'age', {
value: 18,
writable: false,
enumerable: true,
configurable: false,
});
Object.preventExtensions(person);

console.log(person); // { name: 'A', age: 18 }

person.name = 'B';
console.log(person.name); // A

delete person.name;
console.log(person); // { name: 'A', age: 18 }

person.height = 170;
console.log(person); // { name: 'A', age: 18 }

从上例可以看到,更改 person 对象的 name 属性为 B、删除 person 对象的 name 属性和给 person 对象添加 height 属性均没有操作成功。

实现一个不可变对象构造器

在 facebook 的实现的不可变对象库 Immutable 中,Map 是相当于 JavaScript 中的对象。这里我们也实现一个 ImmutableMap ,这里假设传入的参数均为对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function ImmutableMap(object) {
Object.keys(object).forEach((key) => {
Object.defineProperty(this, key, {
value: object[key],
writable: false,
enumerable: true,
configurable: false,
});
});
Object.preventExtensions(this);
}

const person = new ImmutableMap({ name: 'A', age: 18 });
console.log(person); // ImmutableMap { name: 'A', age: 18 }

person.name = 'B';
console.log(person.name); // A

delete person.name;
console.log(person); // ImmutableMap { name: 'A', age: 18 }

person.height = 170;
console.log(person); // ImmutableMap { name: 'A', age: 18 }

可以看到我们的 ImmutableMap 实现了不可变对象的需求。

在另一个不可变对象库 seamless-immutable 中使用了 JavaScript Object 提供的方法 freeze

Object.freeze() 方法可以冻结一个对象,冻结指的是不能向这个对象添加新的属性,不能修改其已有属性的值,不能删除已有属性,以及不能修改该对象已有属性的可枚举性、可配置性、可写性。也就是说,这个对象永远是不可变的。该方法返回被冻结的对象。

Object.freeze() 函数执行下面的操作:

  1. 使对象不可扩展,这样便无法向其添加新属性。
  2. 为对象的所有属性将 configurable 特性设置为 false
  3. 为对象的所有数据属性将 writable 特性设置为 false

另外 Object 还提供一个 seal 方法。跟 freeze 相比,不会将 writable 特性设置为 false ,也就是说用 Object.seal() 密封的对象可以改变它们现有的属性。


参考资料: