沐光

记录在前端之路的点点滴滴

JavaScript 对象

前言

对象是 JavaScript 的七种主要类型中的一种(string、boolean、number、null、undefined、symbol、object),也是这七种主要类型中最为复杂的一种类型,了解对象对学习 JavaScript、了解 this和作用域链,以及之后的“类”的概念都十分重要,而此篇就从边边角角来介绍 JavaScript 中的对象

基础内容

基本语法

对象写法有两种:声明(文字)形式和构造形式。(一般很少使用构造形式)

1
2
3
4
5
// 声明文字形式
const obj = {};

// 构造形式
const obj = new Object();

写法区别:声明文字形式能传递更多的键/值,构造形式得一个个的添加。

类型

为什么要单独强调类型?因为在最开始,我也是认为 JS 中万物接对象,其实不然,像我们所熟悉的简单基本类型(string、boolean、undefined、null 和 number)就并非对象(字面量形式)。函数就是对象的一个子类型(技术角度来说即“可调用的对象”);同样的,数组也是,只不过是具备一些额外的行为。

typeof null === 'object' 是语言本身的 bug,在 JavaScript 中二进制前三位都为 0 的话会被判断为 object ,由于 null 的二进制表示是全 0,因此在做类型判读时会出现“误判”的问题。

基础类型与对象类型的主要区别就是对象的生存期,使用 new 操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中,而自基本类型则只存在于一行代码的执行瞬间,然后立即被销毁,这意味着我们不能在运行时为基本类型添加属性和方法。如:

1
2
3
let name = 'ConardLi';
name.color = 'red';
console.log(name.color); // undefined
内置对象

JavaScript 中还有一些内置对象,例如:

  • String
  • Boolean
  • Number
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

它们可以类比为 Java 中的 Class(但从 JavaScript 角度来说,它们只是一些可以当作构造函数的内置函数)。但是在使用 JavaScript 时,更为常用的方式是直接声明一个字面量,如:

1
2
3
4
5
6
7
8
9
10
11
const str1 = 'This is a string';
const str2 = new String('This is a object');

typeof str1 === 'string'; // true
typeof str2 === 'object'; // true

str1 instanceof String; // false
str2 instanceof String; // true

Object.prototype.toString.call(str1); // [object String]
Object.prototype.toString.call(str2); // [object String]

这里解释了字面量并非由 String 的构造函数得到的对象,实际上 str1 为何其表现形式和 str2 无任何不同(从字符串的操作的层面),是因为引擎会自动将字面量转换成其对应的对象类型,因此可以访问对应类型的属性和方法。

属性与方法

对象的内容是由一些存储在特定命名位置的值组成的,我们称之为属性(可类比指针来理解)。对于基础类型来说,其所存储的值对应一块固定的空间,不会动态改变;而对于对象这样的较为复杂的类型来说,它们存储的为一块内存区域的引用。

通俗的来说,基础类型就像手机店货架上的手机壳,它们是实物,“所见即所得”;而复杂类型就像是手机店柜子内的高价手机,展示的是模型机,其象征这这一型号的所有手机,需要的时候通过此去对应仓库获取真机。

访问方式

对象属性的访问方式分为两种,例如:

1
2
3
4
5
6
7
const obj = { name: 1 };

// 属性访问
obj.name;

// 键访问
obj['name'];

两种方式各有优劣,前者在写的时候较为方便,但是得满足标识符的命名规范,比较固定;后者的写法比较多样(ES6 支持可计算属性名),缺点是没有前者直接。

需要注意的是,属性名永远都是字符串。如果使用字符串之外的值作为属性名,那么其会先转换成字符串,例如:

1
2
3
4
5
6
7
8
const obj = {};
obj[true] = 'boolean';
obj[{}] = 'object';
obj[3] = 'number';

obj['true']; // boolean
obj['[object object]']; // object
obj['3']; // number
函数属性

有时候对象中的属性为一个函数,我们习惯上称这样的属性为“函数的方法”(类比 C 语言)。然而从技术角度来说,在 JavaScript 中,函数永远也不会“属于”一个对象,因为 this 是动态绑定至对象上的(先前有写过这么一篇文章),函数与对象间的关系并不稳定,因此对象与函数最多也只能称作为间接关系。最为保险的说法可能是“函数”和“方法”在 JavaScript 中是可以互换的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const obj = {
foo: function () {
console.log(1);
},
};

// 基本上可以等价于
function foo() {
console.log(1);
}

const obj1 = {
foo: foo,
};

数组

我们知道,在 JS 中数组也是“对象”,但是相对于对象来说,数组有一套更为结构化的值存储机制(通过索引的方式),例如:

1
2
3
const arr = [1, 2, 3];

arr[0]; // 1

由于数组也是一个对象,因此我们也可以给数组添加属性,如:

1
2
// 沿用上例
arr.bar = 'bar';

注意:如果试图像数组内添加一个属性,而属性“看起来”像一个数字(如字符串数子),那么它会变成一个数值下标

提升内容

属性描述符

最早接触到属性描述符的时候还是在读 JS 红宝书的时候,不过那时候不是很懂有啥用,随着接触的东西逐渐变多,源码也看了一部分,对属性描述符也有了一定的程度的理解。

自 ES5 以来,JS 的所有属性都具备了属性描述符,通过 Object.getOwnPropertyDescription(<obj>, <prop>) 能够拿到对应属性的描述,包含内容如下:

1
2
3
4
5
6
{
value: 'xxx', // 值
writable: true, // 是否可写(默认)
enumerable: true, // 是否可枚举(默认)
configurable: true // 是否可配置(默认)
}

当然,我们也可以通过 Object.defineProperty(<obj>, <prop>, {/* */}) 来修改属性描述符。

writable

writable 决定是否可以修改属性的值,如果设置为 false,那么修改属性值时会静默失效

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {};

Object.defineProperty(obj, 'a', {
enumerable: true,
configurable: true,
writable: false,
value: 'static',
});

obj.a = 'public';

obj.a; // static(严格模式下会报错)

然而,当配置中有配置 getter/setter 时,这个属性会被定义为“访问描述符”(与“数据描述符”相对),对于访问描述符来说, JavaScript 会忽略其 value 和 writable 属性(具体来说此),例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = {
a: 2,
};

Object.defineProperty(obj, 'a', {
enumerable: true,
configurable: true,
get() {
return 3;
},
});

obj.a; // 3
configurable

configurable 决定对象的属性是否可配置。与 writable 不同,writable 控制的是对象属性值能否改变,而 configurable 配置的是属性描述符能否改变,即后者是单向的(某个对象的 configurable 设置为 false 后就无法将其再设置为 true 了)。

除此以外,其还禁止删除此属性,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const obj = {
a: 'static',
};

obj.a; // 2

delete obj.a;
obj.a; // undefined

Object.defineProperty(obj, 'a', {
enumerable: true,
configurable: false,
writable: true,
value: 'static',
});

obj.a; // 2

delete obj.a;
obj.a; // 2

注: delete 仅是一个删除对象属性的操作,并非一个释放内存的工具

特例:当 configurable 设置为 false 之后,writable 可由 true 更改为 false,但无法由 false 更改为 true。

Enumerable

顾名思义,此描述符控制的事属性是否会在遍历对象时,将其枚举。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const obj = {
bar: 1,
foo: 2,
};

Object.defineProperty(obj, 'c', {
enumerable: false,
configurable: true,
writable: true,
value: 3,
});

for (const key in obj) {
console.log(key, obj[key]); // 没有 [c 3]
}

'c' in obj; // true
obj.hasOwnProperty('c'); // true

for .. in 循环可以遍历对象的可枚举属性列表(包括[[Prototype]]链),而遍历属性的值我们通常用的是 for .. of,如:

1
2
3
4
5
const arr = [1, 2, 3];

for (const value of arr) {
console.log(value); // 1, 2, 3
}

for .. of 首选会向被访问的对象请求一个迭代器对象,然后通过迭代器对象的 next() 方法来遍历返回值,数组内默认会带有迭代器,因此我们可以直接将 for .. of 运用至数组中。但是对象却没有,我么可以这样造一个自定义迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const obj = {
a: 1,
b: 2
};
Object.defineProperty(obj, Symbol.iterator, {
enumerable: false,
configurable: true,
writable: false,
value: function() {
const self = this;
let idx = 0;
const ks = Object.keys(self);

return {
next: function() {
value: self[ks[idx++]],
done: (idx > ks.length )
}
};
}
})

遍历数组下标使用的是数字顺序,但是遍历对象时其顺序是不确定的。因此在不同环境要想数据展现形式保持一致,那么一定不要相信任何观察到的顺序,因为它们是不可靠的。

不变性

对象常量

我们可以通过设置对象属性的 writable 来设置对象常量,这样能做到真正意义上的不可修改(类比 const 声明)

禁止扩展

对象默认是可扩展的,加入想要禁止一个对象的扩展性,即不能后期新增属性,那么可以使用 Object.preventExtensions(<obj>)

密封

Object.seal(<obj>) 会创建一个密封对象,其会在一个现有对象上调用 Object.preventExtensions() 并将所有的属性标记为 configurable: false。其不仅不能添加新属性,也不能重新配置或删除原有属性(可修改属性值)。

冻结

Object.freeze(<obj>) 会创建一个冻结对象,其会在对象上调用 Object.seal() 并将所有的属性标记为 writable: false,这样就无法修改其值。

注意:对象中引用的对象无法被密封和冻结,只有非引用对象受影响最为全面,并且是浅操作。

参考文档

  • 《你所不知道的 JavaScript》