Skip to main content

Object(对象):基础知识

对象用来存储键值对和更复杂的实体。

对象

JavaScript 中有八种数据类型。有七种原始类型,因为它们的值只包含一种东西(字符串,数字或者其他)。相反,对象则用来存储键值对和更复杂的实体。

基础操作

创建

// 创建空对象
let user = new Object(); // “构造函数” 的语法
let user = {}; // “字面量” 的语法(常用)

初始化

列表中的最后一个属性应以逗号结尾,这叫做尾随(trailing)或悬挂(hanging)逗号。这样便于我们添加、删除和移动属性,因为所有的行都是相似的。

let user = {     // 一个对象
name: "John", // 键 "name",值 "John"
age: 30, // 键 "age",值 30
"likes birds": true, // 多词属性名必须加引号
};

添加属性

// 添加属性,属性的值可以是任意类型
// 使用点操作添加属性、属性值
user.isAdmin = true;

// 使用方括号操作添加属性、属性值
user["likes birds"] = true;

读取

// 读取文件的属性

// 点操作读取
alert( user.name ); // John

// 方括号读取
alert(user["likes birds"]); // 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")

移除属性

delete user.age;
delete user["likes birds"];

计算属性

当创建一个对象时,我们可以在对象字面量中使用方括号。这叫做 计算属性

let fruit = prompt("Which fruit to buy?", "apple");

let bag = {
[fruit]: 5, // 属性名是从 fruit 变量中得到的
};

alert( bag.apple ); // 5 如果 fruit="apple"

所以,如果一个用户输入 "apple",bag 将变为 {apple: 5}

方括号比点符号更强大。它允许任何属性名和变量,但写起来也更加麻烦。

所以,大部分时间里,当属性名是已知且简单的时候,就使用点符号。如果我们需要一些更复杂的内容,那么就用方括号。

属性值简写

在实际开发中,我们通常用已存在的变量当做属性名、将其值作为属性值。 即:通过变量生成属性。

function makeUser(name, age) {
return {
name: name,
age: age,
// ……其他的属性
};
}
// 进一步简写
let user = {
name, // 与 name:name 相同
age: 30
};

属性名称限制

变量名不能是编程语言的某个保留字,如 “for”、“let”、“return” 等…… 但对象的属性名并不受此限制。

// 这些属性都没问题
let obj = {
for: 1,
let: 2,
return: 3
};

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

简而言之,属性命名没有限制。属性名可以是任何字符串或者 symbol(一种特殊的标志符类型)。

其他类型会被自动地转换为字符串。

例如,当数字 0 被用作对象的属性的键时,会被转换为字符串 "0":

let obj = {
0: "test" // 等同于 "0": "test"
};

// 都会输出相同的属性(数字 0 被转为字符串 "0")
alert( obj["0"] ); // test
alert( obj[0] ); // test (相同的属性)

这里有个小陷阱:一个名为 proto 的属性。我们不能将它设置为一个非对象的值:

let obj = {};
obj.__proto__ = 5; // 分配一个数字
alert(obj.__proto__); // [object Object]

—— 值为对象,与预期结果不同 我们从代码中可以看出来,把它赋值为 5 的操作被忽略了。 我们将在 后续章节 中学习 proto 的特殊性质,并给出了解决此问题的方法。

属性存在性测试

相比于其他语言,JavaScript 的对象有一个需要注意的特性:能够被访问任何属性。即使属性不存在也不会报错,读取不存在的属性只会得到 undefined。

判断一个属性是否存在:

let user = {};
alert( user.noSuchProperty === undefined ); // true 意思是没有这个属性

这里还有一个特别的,检查属性是否存在的操作符 "in"。

let user = { age: 30 };

let key = "age";
alert( key in user ); // true,属性 "age" 存在
alert( "name" in user ); // false,属性 "name" 不存在

大部分情况下与 undefined 进行比较来判断就可以了。 但"属性存在,但存储的值是 undefined "的时候:

let obj = {
test: undefined
};

alert( obj.test ); // 显示 undefined,所以属性不存在?

alert( "test" in obj ); // true,属性存在!

"for..in" 循环

为了遍历一个对象的所有键(key),可以使用一个特殊形式的循环:for..in。

let user = {
name: "John",
age: 30,
isAdmin: true
};

for (let key in user) {
// keys
alert( key ); // name, age, isAdmin
// 属性键的值
alert( user[key] ); // John, 30, true
}

像对象一样排序

对象有顺序吗?如果我们遍历一个对象,我们获取属性的顺序是和属性添加时的顺序相同吗?

简短的回答是:“有特别的顺序”:整数属性会被进行排序,其他属性则按照创建的顺序显示。

对象引用和复制

对象的变量存储的不是对象本身,而是该对象“在内存中的地址” —— 换句话说就是对该对象的“引用”。 当一个对象变量被复制 —— 引用被复制,而该对象自身并没有被复制。

let user = { name: 'John' };

let admin = user;

admin.name = 'Pete'; // 通过 "admin" 引用来修改

alert(user.name); // 'Pete',修改能通过 "user" 引用看到

通过引用来比较

仅当两个对象为同一对象时,两者才相等。

例如,这里 a 和 b 两个变量都引用同一个对象,所以它们相等:

let a = {};
let b = a; // 复制引用

alert( a == b ); // true,都引用同一对象
alert( a === b ); // true

而这里两个独立的对象则并不相等,即使它们看起来很像(都为空):

let a = {};
let b = {}; // 两个独立的对象

alert( a == b ); // false

克隆与合并,Object.assign

那么,拷贝一个对象变量会又创建一个对相同对象的引用。

但是,如果我们想要复制一个对象,那该怎么做呢?

我们可以创建一个新对象,通过遍历已有对象的属性,并在原始类型值的层面复制它们,以实现对已有对象结构的复制。

就像这样:

let user = {
name: "John",
age: 30
};

let clone = {}; // 新的空对象

// 将 user 中所有的属性拷贝到其中
for (let key in user) {
clone[key] = user[key];
}

// 现在 clone 是带有相同内容的完全独立的对象
clone.name = "Pete"; // 改变了其中的数据

alert( user.name ); // 原来的对象中的 name 属性依然是 John

我们也可以使用 Object.assign 方法来达成同样的效果。

语法是: Object.assign(dest, [src1, src2, src3...]) 第一个参数 dest 是指目标对象。 更后面的参数 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 }
// 如果被拷贝的属性的属性名已经存在,那么它会被覆盖

克隆生成新对象:

// 将 user 中的所有属性拷贝到了一个空对象中,并返回这个新的对象
let clone = Object.assign({}, user);

还有其他克隆对象的方法,例如使用 spread 语法 clone = {...user}

深层克隆

到现在为止,我们都假设 user 的所有属性均为原始类型。但属性可以是对其他对象的引用,对象的某个属性也是Object类型。 此时使用上述的克隆方法是无效的,Object类型的属性并没有真正被复制。

为了解决这个问题,我们应该使用一个拷贝循环来检查 user[key] 的每个值,如果它是一个对象,那么也复制它的结构。这就是所谓的“深拷贝”。

我们可以使用递归来实现它。或者为了不重复造轮子,采用现有的实现,例如 lodash 库的 _.cloneDeep(obj)

垃圾回收

对于开发者来说,JavaScript 的内存管理是自动的、无形的。我们创建的原始值、对象、函数……这一切都会占用内存。

当我们不再需要某个东西时会发生什么?JavaScript 引擎如何发现它并清理它?

可达性(Reachability)

JavaScript 中主要的内存管理概念是 可达性。

简而言之,“可达”值是那些以某种方式可访问或可用的值。它们被存储在内存中。

这里列出固有的可达值的基本集合,这些值明显不能被释放。

比方说:

  • 当前执行的函数,它的局部变量和参数。
  • 当前嵌套调用链上的其他函数、它们的局部变量和参数。
  • 全局变量。 (还有一些其他的,内部实现) 这些值被称作 根(roots)

如果一个值可以从根通过引用或者引用链进行访问,则认为该值是可达的。

比方说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则 该 对象被认为是可达的。而且它引用的内容也是可达的。

在 JavaScript 引擎中有一个被称作 垃圾回收器 的东西在后台执行。它监控着所有对象的状态,并删除掉那些已经不可达的。

内部算法

垃圾回收的基本算法被称为 “mark-and-sweep”。

定期执行以下“垃圾回收”步骤:

  • 垃圾收集器找到所有的根,并“标记”(记住)它们。
  • 然后它遍历并“标记”来自它们的所有引用。
  • 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  • ……如此操作,直到所有可达的(从根部)引用都被访问到。
  • 没有被标记的对象都会被删除。

这是垃圾收集工作的概念。JavaScript 引擎做了许多优化,使垃圾回收运行速度更快,并且不会对代码执行引入任何延迟。

一些优化建议:

  • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
  • 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
  • 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

对象方法"this"

  • 存储在对象属性中的函数被称为“方法”。
  • 方法允许对象进行像 object.doSomething() 这样的“操作”。
  • 方法可以将对象引用为 this
  • this 的值是在程序运行时得到的。
  • 一个函数在声明时,可能就使用了 this,但是这个 this 只有在函数被调用时才会有值。
  • 可以在对象之间复制函数。
  • 以“方法”的语法调用函数时:object.method(),调用过程中的 this 值是 object
  • 箭头函数没有this,其声明的this是从外部获取的

构造器和操作符 "new"

常规的 {...} 语法允许创建一个对象。但是我们经常需要创建很多类似的对象,例如多个用户或菜单项等。

这可以使用构造函数和 "new" 操作符来实现。

function User(name) {
this.name = name;
this.isAdmin = false;
}

let user = new User("Jack");

alert(user.name); // Jack
alert(user.isAdmin); // false

// 上述代码等同于
let user = {
name: "Jack",
isAdmin: false
};

构造器中可以添加方法:

function User(name) {
this.name = name;

this.sayHi = function() {
alert( "My name is: " + this.name );
};
}

let john = new User("John");

john.sayHi(); // My name is: John

/*
john = {
name: "John",
sayHi: function() { ... }
}
*/

可选链 "?."

使用链式判断/获取某个属性值时会有很多判断:

let user = {};

alert(user.address ? user.address.street : undefined);

可选链

如果可选链 ?. 前面的值为 undefined 或者 null,它会停止运算并返回 undefined

let user = {}; // user 没有 address 属性

alert( user?.address?.street ); // undefined(不报错)

注意: 1.?. 语法使其前面的值成为可选值,但不会对其后面的起作用。 2.不要过度使用可选链 应该只将 ?. 使用在一些东西可以不存在的地方。 例如,如果根据我们的代码逻辑,user 对象必须存在,但 address 是可选的,那么我们应该这样写 user.address?.street,而不是这样 user?.address?.street

短路效应

如果 ?. 左边部分不存在,就会立即停止运算(“短路效应”)。 因此,如果在 ?. 的右侧有任何进一步的函数调用或操作,它们均不会执行。

let user = null;
let x = 0;

user?.sayHi(x++); // 没有 "user",因此代码执行没有到达 sayHi 调用和 x++

alert(x); // 0,值没有增加

其它变体:?.(),?.[]

可选链 ?. 不是一个运算符,而是一个特殊的语法结构。它还可以与函数和方括号一起使用。

  • 将 ?.() 用于调用一个可能不存在的函数。
  • 将 ?.[] 用于调用一个可能不存在的属性
let userAdmin = {
admin() {
alert("I am admin");
}
};
let userGuest = {};
userAdmin.admin?.(); // I am admin
userGuest.admin?.(); // 啥都没发生(没有这样的方法)

let key = "firstName";
let user1 = {
firstName: "John"
};
let user2 = null;
alert( user1?.[key] ); // John
alert( user2?.[key] ); // undefined

symbol 类型

根据规范,只有两种原始类型可以用作对象属性键:

  • 字符串类型
  • symbol 类型

否则,如果使用另一种类型,例如数字,它会被自动转换为字符串。所以 obj[1] 与 obj["1"] 相同,而 obj[true] 与 obj["true"] 相同。

symbol

“symbol” 值表示唯一的标识符。

创建:

  • 使用 Symbol()
  • 使用 Symbol("描述")
let id1 = Symbol();

// id 是描述为 "id" 的 symbol
let id = Symbol("id");

描述相同的symbol,不相同:

let id1 = Symbol("id");
let id2 = Symbol("id");

alert(id1 == id2); // false

"隐藏"属性

symbol 允许我们创建对象的“隐藏”属性,代码的任何其他部分都不能意外访问或重写这些属性。

例如,如果我们使用的是属于第三方代码的 user 对象,我们想要给它们添加一些标识符。

我们可以给它们使用 symbol 键:

let user = { // 属于另一个代码
name: "John"
};

let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 symbol 作为键来访问数据
alert( id.description ); // id 获取到symbol的描述

不用字符串作为主键的原因:

  • 向第三方库增加字段是不安全的,其他使用者可能会意外访问到
  • 增加的字段可能会冲突,其他使用者可能添加同名属性

另外:

  • symbol 属性不参与 for..in 循环
  • Object.keys(oneObject) 中不存在 symbol 属性
  • Object.assign 会同时复制字符串和 symbol 属性
let id = Symbol("id");
let user = {
[id]: 123
};
let clone = Object.assign({}, user);
alert( clone[id] ); // 123

对象字面量中的symbol

如果我们要在对象字面量 {...} 中使用 symbol,则需要使用方括号把它括起来。

let id = Symbol("id");

let user = {
name: "John",
[id]: 123 // 而不是 "id":123
};

全局 symbol

如果想注册一个全局的symbol,以便在任何地方都可以访问,可以使用 全局 symbol注册表

要从注册表中读取(不存在则创建)symbol,使用 Symbol.for(key)。 该调用会检查全局注册表,如果有一个描述为 key 的 symbol,则返回该 symbol,否则将创建一个新 symbol(Symbol(key)),并通过给定的 key 将其存储在注册表中。

// 从全局注册表中读取
let id = Symbol.for("id"); // 如果该 symbol 不存在,则创建它

// 再次读取(可能是在代码中的另一个位置)
let idAgain = Symbol.for("id");

// 相同的 symbol
alert( id === idAgain ); // true

通过全局 symbol 返回一个名字,我们可以使用 Symbol.keyFor(sym)

// 通过 name 获取 symbol
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");

// 通过 symbol 获取 name
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id

对象 —— 原始值转换

当对象相加 obj1 + obj2,相减 obj1 - obj2,或者使用 alert(obj) 打印时会发生什么? 在此类运算的情况下,对象会被自动转换为原始值,然后对这些原始值进行运算,并得到运算结果(也是一个原始值)。

Symbol.toPrimitive

对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。 有三种类型(hint):

  • "string"(对于 alert 和其他需要字符串的操作)
  • "number"(对于数学运算)
  • "default"(少数运算符,通常对象以和 "number" 相同的方式实现 "default" 转换)

有一个名为 Symbol.toPrimitive 的内建 symbol,它被用来给转换方法命名,像这样:

obj[Symbol.toPrimitive] = function(hint) {
// 这里是将此对象转换为原始值的代码
// 它必须返回一个原始值
// hint = "string"、"number" 或 "default" 中的一个
}

如果 Symbol.toPrimitive 方法存在,则它会被用于所有 hint,无需更多其他方法。

例如,这里 user 对象实现了它:

let user = {
name: "John",
money: 1000,

[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};

// 转换演示:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500

从代码中我们可以看到,根据转换的不同,user 变成一个自描述字符串或者一个金额。user[Symbol.toPrimitive] 方法处理了所有的转换情况。

toString/valueOf

如果没有 Symbol.toPrimitive,那么 JavaScript 将尝试寻找 toString 和 valueOf 方法:

  • 对于 "string" hint:调用 toString 方法,如果它不存在,则调用 valueOf 方法(因此,对于字符串转换,优先调用 toString)。
  • 对于其他 hint:调用 valueOf 方法,如果它不存在,则调用 toString 方法(因此,对于数学运算,优先调用 valueOf 方法)。
let user = {
name: "John",
money: 1000,

// 对于 hint="string"
toString() {
return `{name: "${this.name}"}`;
},

// 对于 hint="number" 或 "default"
valueOf() {
return this.money;
}

};

alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500

如果没有 Symbol.toPrimitive 和 valueOftoString 将处理所有原始转换。