对象
Object(对象):基础知识
JavaScript 中有八种数据类型:其中原始数据类型和对象。
可以使用带有可选属性列表的花括号 {}
来创建对象。一个对象就是一个键值对,其中键 key
是一个字符串,值 value
可以是任意类型值。
创建对象
通过字面量的方式:
let user = {
name: 'John',
age: 30,
};
通过构造函数的方式:
let user = new Object({
name: 'John',
age: 30,
});
文本和属性
对象中的一对键值对就是对象的一个属性,我们常说属性名和属性值。
点操作符
访问属性的值:
console.log(user.age); // 30
添加属性:
user.isAdmin = true;
console.log(user.isAdmin); // true
用 delete
操作符来删除属性:
delete user.isAdmin;
console.log(user.isAdmin); // undefined
列表中的最后一个属性应以逗号结尾,这叫做尾随(trailing)或悬挂(hanging)逗号。这样便于我们添加、删除和移动属性,因为所有的行都是相似的。
方括号
访问属性的值:
console.log(user['name']); // John
注意,这里的键需要用引号包裹起来。
方括号提供了一种属性名的灵活表达方式,而不需要必须是静态量,如:
let preKey = 'name';
console.log(user[preKey]); // John
添加属性与删除属性同理。
计算属性
当创建一个对象的时候,我们可以在对象字面量中使用方括号。这就叫做计算属性,例如:
let fruit = prompt('Which fruit to buy?', 'apple');
let bag = {
[fruit]: 5, 属性名是从 fruit 变量中获得的
};
console.log(bag.apple); // 5
计算属性的含义很简单:[fruit]
含义是属性名应该从 fruit
变量中获取。
本质上,这与先建立一个对象,再用方括号语法添加属性的效果相同,但看起来更清晰。
不仅如此,方括号中还可以使用更加复杂的表达式:
let fruit = 'apple';
let bag = {
[fruit + 'Computers']: 5 // bag.appleComputers = 5
};
方括号比点符号更强大。它允许任何属性名和变量,但写起来也更加麻烦。
所以,大部分时间里,当属性名是已知且简单的时候,就使用点符号。如果我们需要一些更复杂的内容,那么就用方括号。
属性存在性测试
相比于其他语言,JavaScript 有一个需要注意的特性:只要对象存在,任何属性都可以被访问,哪怕属性并不存在也不会报错,只会返回 undefined
。
我们可以很容易地判断一个属性是否存在:
console.log(user.school === undefined); // True
这里还有一个特别的,判断属性是否存在的操作符 in
,例如:
console.log('school' in user); // flase
in
的左边必须是属性名,通常是一个带引号的字符串;如果省略引号,就代表左边是一个变量,这个变量应该存储实际的属性名。
为什么需要这种方法呢?因为存在一种情况:属性值本身就为 undefined
。此时用 in
操作符检查能够得到正确的结果。
for...in
循环
为了遍历一个对象所有的键,可以使用 for...in
循环来实现:
for (let key in user) {
console.log(key);
console.log(user[key]);
}
对象的引用和复制
与原始数据类型的根本区别之一是:对象是通过引用存储和复制的。
TIP
将对象赋值的变量存储的不是对象本身,而是对象在内存中的地址,换句话说就是该对象的“引用”。
例如:
let user = {
name: "John"
};
在内存中的存储方式为:
该对象被存储在内存中的某个位置(在图片的右侧),而变量 user
(在左侧)保存的是对其的“引用”。
我们可以将一个对象变量(例如 user
)想象成一张写有对象的地址的纸。
当我们对对象执行操作时,例如获取一个属性 user.name
,JavaScript 引擎会查看该地址中的内容,并在实际对象上执行操作。
现在,这就是为什么它很重要。
TIP
当一个对象被复制时——引用被复制了,而该对象本身并没有被复制。
如上面的图片中显示的,两个变量指向了同一个对象所在的内存区域。
对象的比较
TIP
只有当两个变量指向同一个对象的时候,两个对象才相等。
如下面这种情况:
let user = new Object({
name: 'John',
age: 30,
});
let person = new Object({
name: 'John',
age: 30,
});
console.log(user == person); // false
即使内容完全相同,但指向的不是同一个对象,就不相同。
拷贝与合并
就像上面所说的,直接在变量之间赋值只会创造一个新的引用。
那么,如何真正实现拷贝?
浅拷贝
第一种方法是通过遍历已有对象的属性,并在原始类型值的层面来复制它们,以实现对原始对象的复制,如:
let user = new Object({
name: 'John',
age: 30,
});
let person = {}; // 创建一个新对象
for (let key in user) { // 遍历
person[key] = user[key]; // 赋值
}
第二种方法是使用 Object.assign()
方法,该方法的语法是:
Object.assign(dest, [src2, src2, src3...]);
dest
: 目标对象;src2, src2, src3
...: 源对象;- 将所有源对象的属性拷贝到目标对象
dest
中; - 调用结果返回
dest
。
let person = Object.assign({}, user);
深拷贝
在浅拷贝中,我们一直假设 user
的属性都是原始类型,但属性可以是其他对象的引用,例如:
let user = {
name: 'John',
sizes: {
width: 180,
height: 80,
},
}
对于这样的对象使用上面的两种方式就无法实现完全的拷贝了,为了实现有对象嵌套的深拷贝,可以考虑使用以下四个方法:
JSON
let user = {
name: 'John',
sizes: {
width: 180,
height: 80,
},
school: undefined,
};
let person = JSON.parse(JSON.stringify(user));
这种方法简单易用,但也有两个缺点:
- 不支持拷贝函数、undefined 和 Symbol 等特殊值。
- 对象中存在循环引用时会报错。
递归实现
function deepClone(srcObj) {
if (srcObj === null || typeof srcObj !== 'object') {
return srcObj;
}
const copiedObj = Array.isArray(srcObj) ? [] : {};
for (let key in srcObj) {
copiedObj[key] = deepClone(srcObj[key]); // 递归拷贝
}
return copiedObj;
}
这种方式可以拷贝函数、undefiend 和 symbol,但依然无法处理循环引用,如果内部有循环引用,递归调用会陷入死循环,最终导致栈溢出。
可以通过 WeakMap 来跟踪已经拷贝过的对象,从而避免循环引用导致的无限递归。
function deepClone(srcObj, seen = new WeakMap()) {
if (srcObj === null || typeof srcObj !== 'object') {
return srcObj;
}
// 检查 weakmap 中是否存在
if (seen.has(srcObj)) {
return seen.get(srcObj); // 如果存在,直接返回已拷贝的对象
}
const copiedObj = Array.isArray(srcObj) ? [] : {};
// 将当前对象存储到 weakmap 中
seen.set(srcObj, copiedObj);
for (let key in srcObj) {
copiedObj[key] = deepClone(srcObj[key], seen); // 递归拷贝
}
return copiedObj;
}
垃圾回收
当一个变量被系统认为是不可达的时候,就会被引擎垃圾回收。
这一般是指没有任何引用指向它,但也有一种例外。通常,当数组、对象之类的数据结构在内存中时,它们的子元素,如对象的属性、数组的元素都会被认为是可达的,哪怕该变量本身被 null
赋值,例如:
let john = { name: "John" };
let array = [ john ];
john = null; // 覆盖引用
此时,john
不会被回收,因为存到了数组中。