JavaScript浅析 -- 可变对象和不可变对象

一、可变对象

把对象a赋值给对象b,更改对象b的属性值,被引用的对象a也随之改变,这就是可变对象。

var a = {name: '张三', age: 18},
    b = a;
b.name = '李四';
a.name; // "李四"

为啥会这样呢?其他字符串和数值怎么不会变?要搞懂这个问题,我们必须了解下其内部的存储逻辑。

JavaScript有7种基本数据类型:number、string、boolean、null、undefined、object 以及 ES6 新增的symbol除了 object 为引用类型,其他都是基本类型。基本类型将值存在栈中,而 object 存在栈中的是地址值而不是本身的内容值,该地址值指向堆里的一块区域,该区域存的才是本身的内容值。

var value = 1,
    obj = {name: '张三', age: 18},
    value2 = value,
    obj2 = obj;
变量实际存储内存图

可以看到对于基本类型 number,其存值是栈里单独存一块,即使赋值也是copy一份再单独赋值,所以两者改变互不影响。但对于引用类型的 object,虽然也是在栈里单独存一块并copy值赋值,但是其存的是地址值,由于copy的地址值相同,所以最后两个obj指向的内容区域也相同,所以修改会同步。当修改 obj.name 改的是堆内存里面的 name,改完后, obj2.name 拿着同样的地址值去找堆内存对应区域,此时 obj2 看到的是已经改后的 name,于是返回改后的 name

var obj = {name: '张三', age: 18},
    obj2 = {name: '张三', age: 18};
obj2.name = '李四';
obj.name; // “张三"

上面这个例子,改了 obj2.nameobj.name 却不受影响,这又是为啥呢?我们来看看内存图。

变量存储内存图

从内存图可以看到,当创建一个新的引用类型变量时,会生成一个新的地址值,由于objobj2 地址不同指向的区域不同,所以修改之后互不影响。

array、function 等也都属于 object,也都是引用类型,所以修改数组的时候,原理和上面的一样。所以为了使两个引用类型互不影响,一般我们不直接将旧的 obj 赋值给新的 obj,而是生成新的引用类型变量,然后将原先对象的属性值一个个复制填充到新的引用类型变量里,这就是所谓的深拷贝。

二、不可变对象

竟然对象可能会被其他拥有同样引用地址的对象同步改变,那么除了上述说的深拷贝一个新对象之外,有没有办法让原对象也不可改写?有的,JavaScript 确实提供了一些原生的方法来将可变对象变成不可变对象,通过控制对象属性的增删改来实现,由弱到强依次分为三种:Object.preventExtensions(不可扩展)、Object.seal(密封)、Object.freeze(冻结)。

1. 不可扩展

Object.preventExtensions() 可以使一个对象不可再添加新的属性,参数为目标对象,返回修改后的对象。

var obj = Object.preventExtensions({});

// 直接定义新的属性会报错
Object.defineProperty(obj, 'content', {
  value: 'hello'
}); // TypeError: Cannot define property:p, object is not extensible.

// 非严格模式下通过点符号添加不会报错,但会静默失败,原对象仍然没有 content 属性
obj.content = 'hello';
obj.content; // undefined

对应的,Object.isExtensible() 可以判断一个对象是否可扩展,即是否可以添加新的属性。参数是目标对象,返回布尔值, true 代表可扩展,false 不可扩展。

var obj = new Object();
Object.isExtensible(obj); // true
Object.preventExtensions(obj);
Object.isExtensible(obj); // false
2. 密封

Object.seal() 可以使一个对象无法添加新属性的同时,也无法删除旧属性。参数是目标对象,返回修改后的对象。

其本质是通过修改属性的 configurablefalse 来实现的。在属性描述对象里讲到,configurablefalse 时,其他配置不可改变,writable 只能 truefalse,且属性无法被删除。而由于只要 writableconfigurable 其中之一为 true,则 value 可改,所以密封之后的对象还是可以改属性值的。

var obj = {content: 'hello'};
Object.getOwnPropertyDescriptor(obj, 'content');
// Object {
//   value: "hello",
//   writable: true,
//   enumerable: true,
//   configurable: true
// }
Object.seal(obj);
Object.getOwnPropertyDescriptor(obj, 'content'); // seal 后 configurable 变为 false
// Object {
//   value: "hello",
//   writable: true,
//   enumerable: true,
//   configurable: false
// }

对应的,Object.isSealed() 可以检测一个对象是否密封,即是否可以增删属性。参数是目标对象,返回布尔值,true 代表被密封不可增删属性,false 代表没被密封可增删属性。

var obj = new Object();
Object.isExtensible(obj); // true
Object.isSealed(obj); // false
Object.seal(obj);
Object.isExtensible(obj); // false,注意 seal 后对象的 isExtensible() 也随之改变
Object.isSealed(obj); // true
3. 冻结

Object.freeze() 可以使对象一个对象不能再添加新属性,也不可以删除旧属性,且不能修改属性的值。参数是目标对象,返回修改后的对象。

var obj = Object.freeze({name: 'example'});

// 直接定义新的属性会报错
Object.defineProperty(obj, 'content', {
  value: 'hello'
}); // TypeError: Cannot define property:p, object is not extensible.

// 非严格模式下通过点符号添加不会报错,但会静默失败,原对象仍然没有 content 属性
obj.content = 'hello';
obj.content; // undefined

delete obj.name; // 删除失败,返回 false

obj.name = 'hello';
obj.name; // 仍然是 "example"

对应的,Object.isFrozen() 可以检测一个对象是否冻结,即是否可以增删改。参数是目标对象,返回布尔值,true 表示已经冻结不可再增删改,false 反之。

var obj = new Object();
Object.isExtensible(obj); // true
Object.isSealed(obj); // false
Object.isFrozen(obj); // false
Object.freeze(obj);
Object.isExtensible(obj); // false,注意 freeze 后对象的 isExtensible() 也随之改变
Object.isSealed(obj); // true,注意 freeze 后对象的 isSealed() 也随之改变
Object.isFrozen(obj); // true
4. 注意要点

无论是不可扩展,密封,还是冻结,都是浅层控制的,即只控制对象本身属性的增删改。如果对象属性是一个引用类型,比如数组 subArr 或对象 subObj等,虽然subArr、subObj 的不可被删改,但subArr、subObj属性仍然可增删改。

var obj = Object.freeze({
  content: {name: 'example'}
});
obj.content = new Object();
obj.content; // {name: "example"},content 本身不可改
obj.content.name = 'test';
obj.content; // {name: "test"},但 content 的属性仍可改,因为冻结的是 obj 而不是 obj.content

由于每个对象都有一个属性 __proto__,该属性的值是该对象的原型对象,也是引用类型,由于冻结是浅层的所以原型对象并不会被连着冻结,仍然可以通过给对象的原型对象加属性达到给当前对象新增属性的效果。所以如果想进一步冻结还需要把原型对象也冻结上。

var obj = Object.freeze({});
obj.content = 'hello';
obj.content; // undefined,增加失败

var proto = Object.getPrototypeOf(obj);
proto.content = 'hello';
obj.content; // "hello",增加成功

Object.freeze(proto);
proto.name = 'example';
obj.name; // undefined,冻结原型之后增加失败

推荐阅读更多精彩内容