原文:http://javascript.info/object

学习完《数据类型》一章,我们知道 JavaScript 语言中一共包含七种数据类型,其中六种是“原始类型”,它们的值就是简单的一个数据(不管是字符串还是数字,或者其他什么)。

与此相反的是,对象是用来存储数据集合和更复杂的数据结构的,它几乎渗透到 JavaScript 这门语言的方方面面。因此深入语言之前,我们必须先熟悉对象才行。

对象使用一对花括号 {...} 创建,包含可选的属性序列。属性由“key: value”形式的键值对组成,这里的 key 是字符串(也称为“属性名”),value 则可以是任意类型值。

我们可以把对象想象成是一个文件柜。

image.png

对象里存储的每块数据对应一个个的文件夹,每个文件夹用 key 标识。我们通过 key 来查找文件夹,也可以添加/移出文件夹。

对象 - 图2

一个空对象(“空文件柜”)可以使用下面两种方式创建:

  1. let user = new Object(); // "构造器" 语法
  2. let user = {}; // "字面量" 语法

对象 - 图3

我们通常会使用花括号 {...} 创建对象,这种声明形式称为对象字面量

字面量和属性

使用 {...} 创建对象的时候,可以使用“key: value”的形式向里面添加属性。

  1. let user = { // 对象 `user`
  2. name: "John", // "name" 这个 key 存储了 "John" 这个字符串值
  3. age: 30 // "age" 这个 key 存储了值 30 这个数值
  4. };

“key: value” 表示的属性里,冒号 : 左边的叫属性键(也称为“属性名”或“标识符”),右边的叫属性值。

上面代码里的对象 user,包含两个属性:

  1. 第一个属性的属性名是 "name",值是 "John"

  2. 第二个属性的属性名是 "age",值是 30

我们可以把 user 对象想象成是一个装了两个文件夹的文件柜。一个文件夹标记为“name”,另一个文件夹标记为“age”。

对象 - 图4

我们可以在任何时间,从这个文件柜中拿和读文件夹的内容。

属性值使用点访问符 . 获取:

  1. // 获取对象的属性值:
  2. alert( user.name ); // John
  3. alert( user.age ); // 30

属性值是可以是任意类型的。我们再为 user 添加一个布尔属性:

  1. user.isAdmin = true;

对象 - 图5

删除属性,使用的是 delete 运算符:

  1. delete user.age;

对象 - 图6

也支持“多词”属性名,但属性名必须要用引号引起来:

  1. let user = {
  2. name: "John",
  3. age: 30,
  4. "likes birds": true // “多词”属性名必须要用引号引起来
  5. };

对象 - 图7

最后一个属性的末尾可以跟个逗号:

  1. let user = {
  2. name: "John",
  3. age: 30,
  4. }

这称为“尾”逗号(或“挂”逗号。译者注:有点皮)。这样我们在添/删/移动属性时,就不用考虑逗号了。

方括号

用点访问符不能访问多词属性:

  1. // 这里会报语法错误
  2. user.likes birds = true

这是因为点访问符要求后面跟的必须是有效的变量标识符,也就是说是不能出现空格或其他不在有效变量标识符范围内的字符。

有一个可选方案,就是使用“方括号”访问,而且没有字符串限制:

  1. let user = {};
  2. // 设置
  3. user["likes birds"] = true;
  4. // 获取
  5. alert(user["likes birds"]); // true
  6. // 删除
  7. delete user["likes birds"];

现在一切远转正常。需要注意的是,方括号里的字符串要加引号的(任何形式的引号都行)。

使用方括号访问属性还有一个强大的地方,就是可以使用表达式。方括号中的表达式会先被求值,结果作为属性名。注意下,要跟上面字符串字面量区分的,这里使用的是表达式(译者注:这里说得不严谨,其实上面单独的字符串字面量,也属于一个表达式,不过是最简单的那种——字面量表达式):

  1. let key = "likes birds";
  2. // 下面的写法跟 user["likes birds"] = true; 这种写法是一样的
  3. user[key] = true;

使用方括号语法的话,就比较灵活了,因为变量值是在运行时解析的。因此我们可以让用户输入,选择要访问哪个属性。

例如:

  1. let user = {
  2. name: "John",
  3. age: 30
  4. };
  5. let key = prompt("What do you want to know about the user?", "name");
  6. // 使用变量获取属性值
  7. alert( user[key] ); // John (如果输入的是 "name")

计算属性

可以在对象字面量中使用方括号设置属性,这种属性称为计算属性

例如:

  1. let fruit = prompt("Which fruit to buy?", "apple");
  2. let bag = {
  3. [fruit]: 5, // 属性名使用的是变量 fruit 的值
  4. };
  5. alert( bag.apple ); // 如果 fruit 的值是 "apple",那么这里得到结果是 5

计算属性很简单:[fruit] 表示这个属性名使用的是变量 fruit 的值。

因此,如果用户输入了 "apple"bag 就是 {apple: 5}

上面的代码本质上与下面代码一样:

  1. let fruit = prompt("Which fruit to buy?", "apple");
  2. let bag = {};
  3. // 使用变量 fruit 的值作为属性名
  4. bag[fruit] = 5;

这种书写更好看一些。

我们在方括号中还能用更复杂的表达式:

  1. let fruit = 'apple';
  2. let bag = {
  3. [fruit + 'Computers']: 5 // bag.appleComputers = 5
  4. };

方括号语法比点访问符更加强大,它允许设置任何名称的属性名,就是写起来有点麻烦。

多数时候,如果属性名已知、并且相对简单点的话,就使用点访问符。如果属性名是更复杂一些的,就改换使用方括号。

对象 - 图8 保留字可以作属性名使用

JavaScript 语言中是有保留字的,比如“for”、“let”、“return”等等,而变量名不能使用这些保留字的。

但对象属性名就没有这个限制,几乎任何名称都可以:

```javascript let obj = { for: 1, let: 2, return: 3 }

alert( obj.for + obj.let + obj.return ); // 6

  1. > <br />上面用的是“几乎”这个词,不是全部。是因为还是有个别属性因为历史原因,被使用或被特殊对待的。比如 __proto__”,我们就不能将它设置成非对象值:
  2. >
  3. > ```javascript
  4. let obj = {};
  5. obj.__proto__ = 5;
  6. alert(obj.__proto__); // [object Object], 并不是预想的 5

我们能看到,给 __proto__ 属性赋的值 5 被忽略了。

如果我们开始创建了一个对象,设想在里面能存储任意键值对,上面说的问题就会成为程序 bug/漏洞的来源。 这种情况下,用户可能使用 __proto__ 作为属性名存储数据,最后一赋值就发现问题了。

当然还是有方法存储像 __proto__ 这类在普通对象中被特殊对象的属性,比如数据结构 Map,它是支持任意属性名设置的,我们后面会讲到,现在先学熟对象再说。

属性简写形式

写代码的时候,我们会碰到使用一个变量作为对象属性使用。

例如:

  1. function makeUser(name, age) {
  2. return {
  3. name: name,
  4. age: age
  5. // ...其他属性
  6. };
  7. }
  8. let user = makeUser("John", 30);
  9. alert(user.name); // John

上面代码里,属性名跟变量名是一样的。这类场景遇到的还是挺多的,为了书写简便,引入了简写属性

我们不写成 name: name,而是写成 name

  1. function makeUser(name, age) {
  2. return {
  3. name, // 等同于 name: name
  4. age // 等同于 age: age
  5. // ...
  6. };
  7. }

可以在对象中同时使用普通属性和简写属性:

  1. let user = {
  2. name, // 等同于 name:name
  3. age: 30
  4. };

存在性检查

还有一个值得注意的点是,我们在对象上可以访问任意属性,不管这个属性在对象上有没有。如果访问的属性不存,也不会报错,而是返回 undefined。这就为判断属性是否存在提供了一种通用方法——获取属性后与 undefined 比较:

  1. let user = {};
  2. alert( user.noSuchProperty === undefined ); // true 表示 "没有这个属性"

还有一个特殊的 "in" 操作符用来检查属性是否存在。

语法是:

  1. "key" in object

例如:

  1. let user = { name: "John", age: 30 };
  2. alert( "age" in user ); // true, user.age 存在
  3. alert( "blabla" in user ); // false, user.blabla 不存在

需要注意的是,in 的左边必须是一个属性名,通常是个字符串。

如果忽略引号,那么检查就是变量值。例如:

  1. let user = { age: 30 };
  2. let key = "age";
  3. alert( key in user ); // true, 使用 key 值作为属性名,检查是否在对象里包含

对象 - 图9 属性值为 **undefined** 的属性使用 **in** 检查

一般来说,使用 "=== undefined" 检查属性存在性是够的。但有一种情况就不行,而要用 in 运算符。

这种情况是指:对象属性值是 undefined 的时候。

```javascript let obj = { test: undefined };

alert( obj.test ); // 得 undefined, 但真没有这个属性吗?

alert( “test” in obj ); // true, 这个属性确实存在!

  1. > > 上面代码里,属性 `test` 从技术角度上讲是存在的,使用 `=== undefined` 就区分不出来,而要用 `in` 才行。
  2. > <br />这种情况比较少见,因为 `undefined` 一般用来表示是没有赋值。通常情况下,我们使用 `null` 表示“空”或“未知”值,`in` 运算符在代码不太常见。<br />
  3. <a name="dk3vpa"></a>
  4. ## for..in 循环
  5. `for..in` 循环用来遍历对象属性名。与之前学习的 `for(;;)` 结构完全不同。
  6. 语法为:
  7. ```javascript
  8. for(key in object) {
  9. // 这里是对对象的每个属性名进行操作的地方
  10. }

例如,下面代码输出了对象 user 的所有属性:

  1. let user = {
  2. name: "John",
  3. age: 30,
  4. isAdmin: true
  5. };
  6. for(let key in user) {
  7. // 属性名
  8. alert( key ); // name, age, isAdmin
  9. // 与属性名对应的属性值
  10. alert( user[key] ); // John, 30, true
  11. }

注意,所有的“for”结构都允许我们在循环内部声明循环变量,像这里的 let key

当然,我们也可以使用其他不叫 key 的变量名,比如 "for(let prop in obj)" 这种形式也常被使用。

对象属性的输出顺序

对象是有序的吗?换句话说,如果我们遍历一个对象,遍历出来的属性顺序是声明时的顺序吗?是否可以依赖输出的属性顺序?

简短的回答是:“排列顺序有点特殊”——整数属性是按照排序后的顺序输出的,其他属性则是按照声明先后输出的。细节如下:

假设有一个国际电话区号对象:

  1. let codes = {
  2. "49": "Germany",
  3. "41": "Switzerland",
  4. "44": "Great Britain",
  5. // ..,
  6. "1": "USA"
  7. };
  8. for(let code in codes) {
  9. alert(code); // 1, 41, 44, 49
  10. }

这个对象可以用作电话区号的提示列表。如果我们做了一个德国网页,那么希望 49 是提示列表里的第一项。

但在执行代码后发现:

  • USA(1) 是第一个。

  • 接着是 Switzerland(41) 等等。

这里的区号都整数,结果按照升序排列,最后得到的遍历顺序是 1414449

对象 - 图10整型属性是什么意思?

这里的“整型属性”是指属性名从字符串转成整数的时候,形式上没有修改。

比如, "49" 是一个整数属性名,因为转成整数是 49 与字符串形式仍是一样的,但 +491.2 就不是。

  1. // Math.trunc 是一个内置操作数字的方法,用于截取数字的整数部分,丢弃小数部分
  2. alert( String(Math.trunc(Number("49"))) ); // "49", same, integer property
  3. alert( String(Math.trunc(Number("+49"))) ); // "49", not same "+49" ⇒ not integer property
  4. alert( String(Math.trunc(Number("1.2"))) ); // "1", not same "1.2" ⇒ not integer property

译者注:经测试发现,负整数不适用于此规则——即负整数是按照添加顺序遍历输出的。

另一方面,如果属性名是非整数,那么遍历顺序就是添加顺序,例如:

  1. let user = {
  2. name: "John",
  3. surname: "Smith"
  4. };
  5. user.age = 25; // 再添加一个
  6. // 非整数属性是按照添加顺序遍历出来的
  7. for (let prop in user) {
  8. alert( prop ); // name, surname, age
  9. }

因此,为了解决上面电话区号的问题,可以稍作修改用非整数来“欺骗”一下——在每个区号之前加一个加号“+”就够了。

  1. let codes = {
  2. "+49": "Germany",
  3. "+41": "Switzerland",
  4. "+44": "Great Britain",
  5. // ..,
  6. "+1": "USA"
  7. };
  8. for (let code in codes) {
  9. alert( +code ); // 49, 41, 44, 1
  10. }

现在遍历顺序就是添加顺序啦。

引用复制

对象与原始值有一个最基本的不同在于,对象是“以引用的形式”存储和复制的。

原始值包括:字符串、数字和布尔值——赋值(或说复制)的是“整个值”。

例如:

  1. let message = "Hello!";
  2. let phrase = message;

结果得到的是两个独立的变量,每个变量中存储了一个值 "Hello!"

对象 - 图11

对象就不是这样的。

变量存储的不是对象本身,而是它的“内存地址”,也就是“引用”。

这有一张图:

  1. let user = {
  2. name: "John"
  3. };

对象 - 图12

在这张图里,对象存在了内存中的某个位置。user “引用”了它。

复制对象变量,复制的不是对象本身,而是对象在内存中的地址,会多出一个地址,但对象还是一个。

我们把对象想成是一个文件柜,对象变量就是打开这个柜子的钥匙,将一个对象变量赋值给另一个变量,相当于配了一把钥匙,而不是多出来一个文件柜。

例如:

  1. let user = { name: "John" };
  2. let admin = user; // 赋值引用

现在我们有两个变量,都引用同一个对象:

对象 - 图13

我们可以使用两者中的一个来访问文件柜并修改内容:

  1. let user = { name: 'John' };
  2. let admin = user;
  3. admin.name = 'Pete'; // 通过 "admin" 这个引用来修改
  4. alert(user.name); // 'Pete', 修改在 "user" 中也能看见

上面的代码就说明了我们只有一个对象,而有两把打开这个“柜子”的钥匙,使用其中一把钥匙(admin)打开柜子然后修改内容,用另一把钥匙(user)再一次打开,就能看见之前做的修改。

比较引用

相等运算符 == 和严格相等运算符 === 在比较对象时,行为一致。

对象相等比较比的是是否指向同一对象。

如果两个变量引用同一个对象,那么它们就是相等的:

  1. let a = {};
  2. let b = a; // 复制引用
  3. alert( a == b ); // true, 两个变量引用一个相同的对象
  4. alert( a === b ); // true

如果两个变量引用的对象是不同的,彼此独立的,即便都是空对象,也不相等:

  1. let a = {};
  2. let b = {}; // 两个独立的空对象
  3. alert( a == b ); // false

当以 obj1 > obj2 的形式比较对象,或者把对象与一个原始值比较(比如 obj === 5),此时就不是简单的比较引用是相同了,而是先会把对象转成原始值,再进行比较。后面我们将研究对象转换的工作原理,但说实话,这样的比较是比较少见的,如果出现的话基本上就是编码问题了。

常量对象

const 声明的对象是可以修改的。

例如:

  1. const user = {
  2. name: "John"
  3. };
  4. user.age = 25; // (*)
  5. alert(user.age); // 25

我们可能认为 (*) 这个地方会报错,但不会,这是正确的。const 只表示变量 user 本身是不可修改的。 此处的 user 存储的是对象在内存中的地址。在 (*) 这个地方我们进入到了对象内部,并不是对 user 重新赋值。

例如:

  1. const user = {
  2. name: "John"
  3. };
  4. // Error (不能重新赋值 user)
  5. user = {
  6. name: "Pete"
  7. };

但是如果我们想要实现对象属性不可修改,怎么做呢?就是说,如果设置 user.age = 25 的话会报错,这也能做到,我们会在后面《属性标志和描述符》一章里介绍。

Object.assign:克隆 & 合并

复制一个对象变量,得到的是对同一个对象的引用。但如果真的需要当前对象的一个副本,或者说一个克隆,该如何做呢?

也能做到,就是稍微稍微难一点,JavaScript 没有为此提供内置方法,实际上,也很少需要。在大多数情况下,复制引用就够用了。

如果我们真的需要复制一个副本,就需要创建一个新对象,通过遍历源对象属性,将属性结构赋给新对象。类似这样:

  1. let user = {
  2. name: "John",
  3. age: 30
  4. };
  5. let clone = {}; // 新的空对象
  6. // 将所有 user 属性赋给 clone
  7. for (let key in user) {
  8. clone[key] = user[key];
  9. }
  10. // 新的 clone 指向一个完全独立的对象
  11. clone.name = "Pete"; // 修改对象属性
  12. alert( user.name ); // John 仍然存在于原始对象里

我们还可以使用 Object.assign 方法实现此功能。

语法是:

  1. Object.assign(dest[, src1, src2, src3...])
  • 参数 dest,和 src1, ..., srcN(任意多个)都是对象。

  • 此方法将复制 src1, ..., srcN 中的属性到 dest 中,然后返回 dest

我们利用这个方法,将多个对象的属性合并到一个对象里:

  1. let user = { name: "John" };
  2. let permissions1 = { canView: true };
  3. let permissions2 = { canEdit: true };
  4. // 复制 permissions1 和 permissions2 里的属性到 user 中
  5. Object.assign(user, permissions1, permissions2);
  6. // 现在 user 变为 { name: "John", canView: true, canEdit: true } 了

如果目标对象中已存在同名属性,就会被重写:

  1. let user = { name: "John" };
  2. // 重写 name, 添加 isAdmin
  3. Object.assign(user, { name: "Pete", isAdmin: true });
  4. // 现在 user = { name: "Pete", isAdmin: true }

也可以使用 Object.assign 替换循环,做简单克隆。

  1. let user = {
  2. name: "John",
  3. age: 30
  4. };
  5. let clone = Object.assign({}, user);

上面代码与循环遍历属性赋给新对象结果一样,但更加简短。

到现在为止,我们都是假设 user 中的属性都是原始类型的,但如果属性是对象类型的该如何处理呢?

比如下面这样:

  1. let user = {
  2. name: "John",
  3. sizes: {
  4. height: 182,
  5. width: 50
  6. }
  7. };
  8. alert( user.sizes.height ); // 182

现在使用 clone.sizes = user.sizes 复制属性就不行了,因为 user.sizes 是个对象,是通过引用复制的。就是说 cloneuser 共享了同一个尺寸对象 { height: 182, width: 50 }

  1. let user = {
  2. name: "John",
  3. sizes: {
  4. height: 182,
  5. width: 50
  6. }
  7. };
  8. let clone = Object.assign({}, user);
  9. alert( user.sizes === clone.sizes ); // true, 同一个对象
  10. // lone 和 user 共享了同个尺寸对象
  11. user.sizes.width++; // 在一个地方修改
  12. alert(clone.sizes.width); // 51, 在另一个地方也能看见

为了解决这个问题,我们在赋值属性的时候,需要检查 user[key] 是否是个对象,如果是,就再对这个对象克隆,以此类推……这被称为“深度克隆”。

有一个用于深度克隆的标准算法可以处理上述甚至更复杂的情况,叫结构化克隆算法。为了避免重复造轮子,我们可以使用 JavaScript Lodash 库的实现方法 _.cloneDeep(obj)

总结

对象是具有多个特殊功能的关联数组。

存储属性(键值对):

  • 属性名必须是字符串或 Symbol 值(通常是字符串)。

  • 值可以是任意类性的。

访问属性使用:

  • 点访问符:obj.property

  • 方括号运算符:obj["property"]。方括号允许我们使用变量访问属性,比如 obj[varWithKey]

其他操作:

  • 删除属性:delete obj.prop

  • 检查某个属性是否存在:"key" in obj。

  • 遍历对象:for (let key in obj)

对象是通过引用传递的。换句话说,变量存储的不是“对象本身”,而是对象的“引用”(即对象在内存中的地址)。所以复制这个对象变量或以参数形式传给函数时,否是以引用的方式传递的,而非直接传递对象本身。所有通过引用进行的操作(比如:添加/删除属性)都是在操作同一个对象。

“真实复制”(或克隆)一个对象,我们可以使用 Object.assign 或者 _.cloneDeep(obj) 方法。

JavaScript 中还有许多其他类型的对象:

  • Array:存储有序数据集合。

  • Date:存储日期和时间的信息。

  • Error:存储错误信息。

  • 等等……

它们各有各的特征,我们以后会学到。有时会听人说“数组类型、“日期类型”啥的,其实并不是的,这些都是“对象”类型,只是在以不同的方式扩展它而已。

JavaScript 对象非常强大,本篇介绍的只是冰山一角,接下来我们还会更加频繁的操作对象,并会学到关于对象方面的更多知识。

(完)