学习完《数据类型》一章,我们知道 JavaScript 语言中一共包含七种数据类型,其中六种是“原始类型”,它们的值就是简单的一个数据(不管是字符串还是数字,或者其他什么)。
与此相反的是,对象是用来存储数据集合和更复杂的数据结构的,它几乎渗透到 JavaScript 这门语言的方方面面。因此深入语言之前,我们必须先熟悉对象才行。
对象使用一对花括号 {...}
创建,包含可选的属性序列。属性由“key: value”形式的键值对组成,这里的 key
是字符串(也称为“属性名”),value
则可以是任意类型值。
我们可以把对象想象成是一个文件柜。
对象里存储的每块数据对应一个个的文件夹,每个文件夹用 key
标识。我们通过 key
来查找文件夹,也可以添加/移出文件夹。
一个空对象(“空文件柜”)可以使用下面两种方式创建:
let user = new Object(); // "构造器" 语法
let user = {}; // "字面量" 语法
我们通常会使用花括号 {...}
创建对象,这种声明形式称为对象字面量。
字面量和属性
使用 {...}
创建对象的时候,可以使用“key: value”的形式向里面添加属性。
let user = { // 对象 `user`
name: "John", // "name" 这个 key 存储了 "John" 这个字符串值
age: 30 // "age" 这个 key 存储了值 30 这个数值
};
“key: value” 表示的属性里,冒号 :
左边的叫属性键(也称为“属性名”或“标识符”),右边的叫属性值。
上面代码里的对象 user
,包含两个属性:
第一个属性的属性名是
"name"
,值是"John"
。第二个属性的属性名是
"age"
,值是30
。
我们可以把 user
对象想象成是一个装了两个文件夹的文件柜。一个文件夹标记为“name”,另一个文件夹标记为“age”。
我们可以在任何时间,从这个文件柜中拿和读文件夹的内容。
属性值使用点访问符 .
获取:
// 获取对象的属性值:
alert( user.name ); // John
alert( user.age ); // 30
属性值是可以是任意类型的。我们再为 user
添加一个布尔属性:
user.isAdmin = true;
删除属性,使用的是 delete
运算符:
delete user.age;
也支持“多词”属性名,但属性名必须要用引号引起来:
let user = {
name: "John",
age: 30,
"likes birds": true // “多词”属性名必须要用引号引起来
};
最后一个属性的末尾可以跟个逗号:
let user = {
name: "John",
age: 30,
}
这称为“尾”逗号(或“挂”逗号。译者注:有点皮)。这样我们在添/删/移动属性时,就不用考虑逗号了。
方括号
用点访问符不能访问多词属性:
// 这里会报语法错误
user.likes birds = true
这是因为点访问符要求后面跟的必须是有效的变量标识符,也就是说是不能出现空格或其他不在有效变量标识符范围内的字符。
有一个可选方案,就是使用“方括号”访问,而且没有字符串限制:
let user = {};
// 设置
user["likes birds"] = true;
// 获取
alert(user["likes birds"]); // true
// 删除
delete user["likes birds"];
现在一切远转正常。需要注意的是,方括号里的字符串要加引号的(任何形式的引号都行)。
使用方括号访问属性还有一个强大的地方,就是可以使用表达式。方括号中的表达式会先被求值,结果作为属性名。注意下,要跟上面字符串字面量区分的,这里使用的是表达式(译者注:这里说得不严谨,其实上面单独的字符串字面量,也属于一个表达式,不过是最简单的那种——字面量表达式):
let key = "likes birds";
// 下面的写法跟 user["likes birds"] = true; 这种写法是一样的
user[key] = true;
使用方括号语法的话,就比较灵活了,因为变量值是在运行时解析的。因此我们可以让用户输入,选择要访问哪个属性。
例如:
let user = {
name: "John",
age: 30
};
let key = prompt("What do you want to know about the user?", "name");
// 使用变量获取属性值
alert( user[key] ); // John (如果输入的是 "name")
计算属性
可以在对象字面量中使用方括号设置属性,这种属性称为计算属性。
例如:
let fruit = prompt("Which fruit to buy?", "apple");
let bag = {
[fruit]: 5, // 属性名使用的是变量 fruit 的值
};
alert( bag.apple ); // 如果 fruit 的值是 "apple",那么这里得到结果是 5
计算属性很简单:[fruit]
表示这个属性名使用的是变量 fruit
的值。
因此,如果用户输入了 "apple"
,bag
就是 {apple: 5}
。
上面的代码本质上与下面代码一样:
let fruit = prompt("Which fruit to buy?", "apple");
let bag = {};
// 使用变量 fruit 的值作为属性名
bag[fruit] = 5;
这种书写更好看一些。
我们在方括号中还能用更复杂的表达式:
let fruit = 'apple';
let bag = {
[fruit + 'Computers']: 5 // bag.appleComputers = 5
};
方括号语法比点访问符更加强大,它允许设置任何名称的属性名,就是写起来有点麻烦。
多数时候,如果属性名已知、并且相对简单点的话,就使用点访问符。如果属性名是更复杂一些的,就改换使用方括号。
保留字可以作属性名使用
JavaScript 语言中是有保留字的,比如“for”、“let”、“return”等等,而变量名不能使用这些保留字的。
但对象属性名就没有这个限制,几乎任何名称都可以:
```javascript let obj = { for: 1, let: 2, return: 3 }
alert( obj.for + obj.let + obj.return ); // 6
> <br />上面用的是“几乎”这个词,不是全部。是因为还是有个别属性因为历史原因,被使用或被特殊对待的。比如 “__proto__”,我们就不能将它设置成非对象值:
>
> ```javascript
let obj = {};
obj.__proto__ = 5;
alert(obj.__proto__); // [object Object], 并不是预想的 5
我们能看到,给
__proto__
属性赋的值 5 被忽略了。如果我们开始创建了一个对象,设想在里面能存储任意键值对,上面说的问题就会成为程序 bug/漏洞的来源。 这种情况下,用户可能使用
__proto__
作为属性名存储数据,最后一赋值就发现问题了。当然还是有方法存储像
__proto__
这类在普通对象中被特殊对象的属性,比如数据结构 Map,它是支持任意属性名设置的,我们后面会讲到,现在先学熟对象再说。
属性简写形式
写代码的时候,我们会碰到使用一个变量作为对象属性使用。
例如:
function makeUser(name, age) {
return {
name: name,
age: age
// ...其他属性
};
}
let user = makeUser("John", 30);
alert(user.name); // John
上面代码里,属性名跟变量名是一样的。这类场景遇到的还是挺多的,为了书写简便,引入了简写属性。
我们不写成 name: name
,而是写成 name
:
function makeUser(name, age) {
return {
name, // 等同于 name: name
age // 等同于 age: age
// ...
};
}
可以在对象中同时使用普通属性和简写属性:
let user = {
name, // 等同于 name:name
age: 30
};
存在性检查
还有一个值得注意的点是,我们在对象上可以访问任意属性,不管这个属性在对象上有没有。如果访问的属性不存,也不会报错,而是返回 undefined
。这就为判断属性是否存在提供了一种通用方法——获取属性后与 undefined
比较:
let user = {};
alert( user.noSuchProperty === undefined ); // true 表示 "没有这个属性"
还有一个特殊的 "in"
操作符用来检查属性是否存在。
语法是:
"key" in object
例如:
let user = { name: "John", age: 30 };
alert( "age" in user ); // true, user.age 存在
alert( "blabla" in user ); // false, user.blabla 不存在
需要注意的是,in
的左边必须是一个属性名,通常是个字符串。
如果忽略引号,那么检查就是变量值。例如:
let user = { age: 30 };
let key = "age";
alert( key in user ); // true, 使用 key 值作为属性名,检查是否在对象里包含
属性值为
**undefined**
的属性使用**in**
检查一般来说,使用
"=== undefined"
检查属性存在性是够的。但有一种情况就不行,而要用in
运算符。这种情况是指:对象属性值是 undefined 的时候。
```javascript let obj = { test: undefined };
alert( obj.test ); // 得 undefined, 但真没有这个属性吗?
alert( “test” in obj ); // true, 这个属性确实存在!
> > 上面代码里,属性 `test` 从技术角度上讲是存在的,使用 `=== undefined` 就区分不出来,而要用 `in` 才行。
> <br />这种情况比较少见,因为 `undefined` 一般用来表示是没有赋值。通常情况下,我们使用 `null` 表示“空”或“未知”值,`in` 运算符在代码不太常见。<br />
<a name="dk3vpa"></a>
## for..in 循环
`for..in` 循环用来遍历对象属性名。与之前学习的 `for(;;)` 结构完全不同。
语法为:
```javascript
for(key in object) {
// 这里是对对象的每个属性名进行操作的地方
}
例如,下面代码输出了对象 user
的所有属性:
let user = {
name: "John",
age: 30,
isAdmin: true
};
for(let key in user) {
// 属性名
alert( key ); // name, age, isAdmin
// 与属性名对应的属性值
alert( user[key] ); // John, 30, true
}
注意,所有的“for”结构都允许我们在循环内部声明循环变量,像这里的 let key
。
当然,我们也可以使用其他不叫 key
的变量名,比如 "for(let prop in obj)"
这种形式也常被使用。
对象属性的输出顺序
对象是有序的吗?换句话说,如果我们遍历一个对象,遍历出来的属性顺序是声明时的顺序吗?是否可以依赖输出的属性顺序?
简短的回答是:“排列顺序有点特殊”——整数属性是按照排序后的顺序输出的,其他属性则是按照声明先后输出的。细节如下:
假设有一个国际电话区号对象:
let codes = {
"49": "Germany",
"41": "Switzerland",
"44": "Great Britain",
// ..,
"1": "USA"
};
for(let code in codes) {
alert(code); // 1, 41, 44, 49
}
这个对象可以用作电话区号的提示列表。如果我们做了一个德国网页,那么希望 49
是提示列表里的第一项。
但在执行代码后发现:
USA
(1) 是第一个。接着是
Switzerland
(41) 等等。
这里的区号都整数,结果按照升序排列,最后得到的遍历顺序是 1
、41
、44
、49
。
整型属性是什么意思?
这里的“整型属性”是指属性名从字符串转成整数的时候,形式上没有修改。
比如,
"49"
是一个整数属性名,因为转成整数是49
与字符串形式仍是一样的,但+49
和1.2
就不是。
// Math.trunc 是一个内置操作数字的方法,用于截取数字的整数部分,丢弃小数部分
alert( String(Math.trunc(Number("49"))) ); // "49", same, integer property
alert( String(Math.trunc(Number("+49"))) ); // "49", not same "+49" ⇒ not integer property
alert( String(Math.trunc(Number("1.2"))) ); // "1", not same "1.2" ⇒ not integer property
译者注:经测试发现,负整数不适用于此规则——即负整数是按照添加顺序遍历输出的。
另一方面,如果属性名是非整数,那么遍历顺序就是添加顺序,例如:
let user = {
name: "John",
surname: "Smith"
};
user.age = 25; // 再添加一个
// 非整数属性是按照添加顺序遍历出来的
for (let prop in user) {
alert( prop ); // name, surname, age
}
因此,为了解决上面电话区号的问题,可以稍作修改用非整数来“欺骗”一下——在每个区号之前加一个加号“+”就够了。
let codes = {
"+49": "Germany",
"+41": "Switzerland",
"+44": "Great Britain",
// ..,
"+1": "USA"
};
for (let code in codes) {
alert( +code ); // 49, 41, 44, 1
}
现在遍历顺序就是添加顺序啦。
引用复制
对象与原始值有一个最基本的不同在于,对象是“以引用的形式”存储和复制的。
原始值包括:字符串、数字和布尔值——赋值(或说复制)的是“整个值”。
例如:
let message = "Hello!";
let phrase = message;
结果得到的是两个独立的变量,每个变量中存储了一个值 "Hello!"
。
对象就不是这样的。
变量存储的不是对象本身,而是它的“内存地址”,也就是“引用”。
这有一张图:
let user = {
name: "John"
};
在这张图里,对象存在了内存中的某个位置。user
“引用”了它。
复制对象变量,复制的不是对象本身,而是对象在内存中的地址,会多出一个地址,但对象还是一个。
我们把对象想成是一个文件柜,对象变量就是打开这个柜子的钥匙,将一个对象变量赋值给另一个变量,相当于配了一把钥匙,而不是多出来一个文件柜。
例如:
let user = { name: "John" };
let admin = user; // 赋值引用
现在我们有两个变量,都引用同一个对象:
我们可以使用两者中的一个来访问文件柜并修改内容:
let user = { name: 'John' };
let admin = user;
admin.name = 'Pete'; // 通过 "admin" 这个引用来修改
alert(user.name); // 'Pete', 修改在 "user" 中也能看见
上面的代码就说明了我们只有一个对象,而有两把打开这个“柜子”的钥匙,使用其中一把钥匙(admin
)打开柜子然后修改内容,用另一把钥匙(user
)再一次打开,就能看见之前做的修改。
比较引用
相等运算符 == 和严格相等运算符 === 在比较对象时,行为一致。
对象相等比较比的是是否指向同一对象。
如果两个变量引用同一个对象,那么它们就是相等的:
let a = {};
let b = a; // 复制引用
alert( a == b ); // true, 两个变量引用一个相同的对象
alert( a === b ); // true
如果两个变量引用的对象是不同的,彼此独立的,即便都是空对象,也不相等:
let a = {};
let b = {}; // 两个独立的空对象
alert( a == b ); // false
当以 obj1 > obj2
的形式比较对象,或者把对象与一个原始值比较(比如 obj === 5
),此时就不是简单的比较引用是相同了,而是先会把对象转成原始值,再进行比较。后面我们将研究对象转换的工作原理,但说实话,这样的比较是比较少见的,如果出现的话基本上就是编码问题了。
常量对象
用 const
声明的对象是可以修改的。
例如:
const user = {
name: "John"
};
user.age = 25; // (*)
alert(user.age); // 25
我们可能认为 (*)
这个地方会报错,但不会,这是正确的。const
只表示变量 user
本身是不可修改的。 此处的 user
存储的是对象在内存中的地址。在 (*)
这个地方我们进入到了对象内部,并不是对 user
重新赋值。
例如:
const user = {
name: "John"
};
// Error (不能重新赋值 user)
user = {
name: "Pete"
};
但是如果我们想要实现对象属性不可修改,怎么做呢?就是说,如果设置 user.age = 25
的话会报错,这也能做到,我们会在后面《属性标志和描述符》一章里介绍。
Object.assign:克隆 & 合并
复制一个对象变量,得到的是对同一个对象的引用。但如果真的需要当前对象的一个副本,或者说一个克隆,该如何做呢?
也能做到,就是稍微稍微难一点,JavaScript 没有为此提供内置方法,实际上,也很少需要。在大多数情况下,复制引用就够用了。
如果我们真的需要复制一个副本,就需要创建一个新对象,通过遍历源对象属性,将属性结构赋给新对象。类似这样:
let user = {
name: "John",
age: 30
};
let clone = {}; // 新的空对象
// 将所有 user 属性赋给 clone
for (let key in user) {
clone[key] = user[key];
}
// 新的 clone 指向一个完全独立的对象
clone.name = "Pete"; // 修改对象属性
alert( user.name ); // John 仍然存在于原始对象里
我们还可以使用 Object.assign 方法实现此功能。
语法是:
Object.assign(dest[, src1, src2, src3...])
参数
dest
,和src1, ..., srcN
(任意多个)都是对象。此方法将复制
src1, ..., srcN
中的属性到dest
中,然后返回dest
。
我们利用这个方法,将多个对象的属性合并到一个对象里:
let user = { name: "John" };
let permissions1 = { canView: true };
let permissions2 = { canEdit: true };
// 复制 permissions1 和 permissions2 里的属性到 user 中
Object.assign(user, permissions1, permissions2);
// 现在 user 变为 { name: "John", canView: true, canEdit: true } 了
如果目标对象中已存在同名属性,就会被重写:
let user = { name: "John" };
// 重写 name, 添加 isAdmin
Object.assign(user, { name: "Pete", isAdmin: true });
// 现在 user = { name: "Pete", isAdmin: true }
也可以使用 Object.assign
替换循环,做简单克隆。
let user = {
name: "John",
age: 30
};
let clone = Object.assign({}, user);
上面代码与循环遍历属性赋给新对象结果一样,但更加简短。
到现在为止,我们都是假设 user
中的属性都是原始类型的,但如果属性是对象类型的该如何处理呢?
比如下面这样:
let user = {
name: "John",
sizes: {
height: 182,
width: 50
}
};
alert( user.sizes.height ); // 182
现在使用 clone.sizes = user.sizes
复制属性就不行了,因为 user.sizes
是个对象,是通过引用复制的。就是说 clone
和 user
共享了同一个尺寸对象 { height: 182, width: 50 }
。
let user = {
name: "John",
sizes: {
height: 182,
width: 50
}
};
let clone = Object.assign({}, user);
alert( user.sizes === clone.sizes ); // true, 同一个对象
// lone 和 user 共享了同个尺寸对象
user.sizes.width++; // 在一个地方修改
alert(clone.sizes.width); // 51, 在另一个地方也能看见
为了解决这个问题,我们在赋值属性的时候,需要检查 user[key]
是否是个对象,如果是,就再对这个对象克隆,以此类推……这被称为“深度克隆”。
有一个用于深度克隆的标准算法可以处理上述甚至更复杂的情况,叫结构化克隆算法。为了避免重复造轮子,我们可以使用 JavaScript Lodash 库的实现方法 _.cloneDeep(obj)。
总结
对象是具有多个特殊功能的关联数组。
存储属性(键值对):
属性名必须是字符串或 Symbol 值(通常是字符串)。
值可以是任意类性的。
访问属性使用:
点访问符:
obj.property
。方括号运算符:
obj["property"]
。方括号允许我们使用变量访问属性,比如obj[varWithKey]
。
其他操作:
删除属性:
delete obj.prop
。检查某个属性是否存在:
"key" in ob
j。遍历对象:
for (let key in obj)
。
对象是通过引用传递的。换句话说,变量存储的不是“对象本身”,而是对象的“引用”(即对象在内存中的地址)。所以复制这个对象变量或以参数形式传给函数时,否是以引用的方式传递的,而非直接传递对象本身。所有通过引用进行的操作(比如:添加/删除属性)都是在操作同一个对象。
“真实复制”(或克隆)一个对象,我们可以使用 Object.assign
或者 _.cloneDeep(obj) 方法。
JavaScript 中还有许多其他类型的对象:
Array:存储有序数据集合。
Date:存储日期和时间的信息。
Error:存储错误信息。
等等……
它们各有各的特征,我们以后会学到。有时会听人说“数组类型、“日期类型”啥的,其实并不是的,这些都是“对象”类型,只是在以不同的方式扩展它而已。
JavaScript 对象非常强大,本篇介绍的只是冰山一角,接下来我们还会更加频繁的操作对象,并会学到关于对象方面的更多知识。
(完)