Object(对象)
我们在 数据类型 一章学到的,JavaScript 中有八种数据类型。有七种原始类型,因为它们的值只包含一种东西(字符串,数字或者其他),它们不能再被原子化。
对象则用来存储键值对和更复杂的实体。在 JavaScript 中,对象几乎渗透到了这门编程语言的方方面面。所以,在我们深入理解这门语言之前,必须先理解对象。
基础
我们可以用下面两种语法中的任一种来创建一个空的对象
let user = new Object(); // “构造函数” 的语法
let user = {}; // “字面量” 的语法
通常用字面量的语法来创建对象,可以在创建对象的时候,立即将一些属性以键值对的形式放到 {...} 中。
let user = {
// 一个对象,包含两个属性
name: "John", // 键 "name",值 "John"
age: 30, // 键 "age",值 30
};
- 访问属性:可以使用点符号访问属性值 belike:
user.name
- 声明属性:可以直接在字面量里声明,也可以像
user.isAdmin = true;
这样来声明或者修改 user 对象里的键 - 移除属性:可以用 delete 操作符移除属性
delete user.age;
- 使用多字词语作为属性名:可以用多字词语来作为属性名,但必须给它们加上引号belike:
"likes birds": true
一个代码规范
列表中的最后一个属性应以逗号结尾:
let user = {
name: "John",
age: 30,
};
这叫做尾随(trailing)或悬挂(hanging)逗号。这样便于我们添加、删除和移动属性,因为所有的行都是相似的。 :::
方括号的使用
对于多词属性,点操作就不能用了,点符号要求 key 是有效的变量标识符。这意味着:不包含空格,不以数字开头,也不包含特殊字符(允许使用 $ 和 _)。需要使用方括号 belike:
user["likes birds"] = true;
方括号同样提供了一种可以通过任意表达式来获取属性名的方式 belike:
let user = {
name: "John",
age: 30,
};
let key = prompt("What do you want to know about the user?", "name");
// 访问变量
alert(user[key]); // John(如果输入 "name")
当创建一个对象时,我们可以在对象字面量中使用方括号。这叫做 计算属性
。belike:
let fruit = prompt("Which fruit to buy?", "apple");
let bag = {
[fruit]: 5, // 属性名是从 fruit 变量中得到的
};
alert(bag.apple); // 5 如果 fruit="apple"
// 本质上,这跟下面的语法效果相同:
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
};
大部分时间里,当属性名是已知且简单的时候,就使用点符号。如果我们需要一些更复杂的内容,那么就用方括号。
属性值的简写
在实际开发中,我们通常用已存在的变量当做属性名。belike:
function makeUser(name, age) {
return {
name: name,
age: age,
// ……其他的属性
};
}
let user = makeUser("John", 30);
alert(user.name); // John
在上面的例子中,属性名跟变量名一样。这种通过变量生成属性的应用场景很常见,在这有一种特殊的 属性值缩写
方法,使属性名变得更短。
function makeUser(name, age) {
return {
name, // 与 name: name 相同
age, // 与 age: age 相同
// ...
};
}
属性名称限制
变量名不能是编程语言的某个保留字,如 “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 的属性
名为 proto 的属性。我们不能将它设置为一个非对象的值,后面我们会在原型继承里面讲一下它的特殊性质,然后给出如何解决这个问题的方法:
let obj = {};
obj.__proto__ = 5; // 分配一个数字
alert(obj.__proto__); // [object Object] —— 值为对象,与预期结果不同
属性存在性测试
JavaScript 的对象有一个需要注意的特性:能够被访问任何属性。即使属性不存在也不会报错!
读取不存在的属性只会得到 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 不存在。
WARNING
in 的左边必须是 属性名。通常是一个带引号的字符串。如果我们省略引号,就意味着左边是一个变量,它应该包含要判断的实际属性名。
啊那有人就要问了:为何会有 in 运算符呢?与 undefined 进行比较来判断还不够吗?属性存在,但存储的值是 undefined 的时候,那么比较运算就失效了,但是这种情况很少发生,因为通常情况下不应该给对象赋值 undefined。我们通常会用 null 来表示未知的或者空的值。
"for..in" 循环
为了遍历一个对象的所有键(key),可以使用一个特殊形式的循环:for..in。语法为:
for (key in object) {
// 对此对象属性中的每个键执行的代码
}
// 举个详细点的例子:
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
}
对象的顺序
对象有顺序吗?换句话说,如果我们遍历一个对象,我们获取属性的顺序是和属性添加时的顺序相同吗?这靠谱吗? 简短的回答是:“有特别的顺序”:整数属性会被进行排序以升序排列。所以我们看到的是 1, 41, 44, 49,其他属性则按照创建的顺序显示。详情如下:
let codes = {
49: "Germany",
41: "Switzerland",
44: "Great Britain",
// ..,
1: "USA",
};
for (let code in codes) {
alert(code); // 1, 41, 44, 49
}
所以,为了解决电话号码的问题,我们可以使用非整数属性名来 欺骗 程序。只需要给每个键名加一个加号 "+" 前缀就行了。
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" };
let admin = user; // 复制引用
我们可以通过其中任意一个变量来访问该对象并修改它的内容:
let user = { name: "John" };
let admin = user;
admin.name = "Pete"; // 通过 "admin" 引用来修改
alert(user.name); // 'Pete',修改能通过 "user" 引用看到
对象间的比较
- 仅当两个对象为同一对象时,两者才相等。例如, a 和 b 两个变量都引用同一个对象,它们相等
- 而两个独立的对象则并不相等,即使它们看起来很像(都为空):
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",
age: 30,
};
// 将 user 中的所有属性拷贝到了一个空对象中,并返回这个新的对象。
let clone = Object.assign({}, user);
- Spread 语法也能实现克隆对象的功能,我们将会在后面的章节:Rest 和 Spread 中讲到
深层克隆
到现在为止,我们都假设 user 的所有属性均为原始类型。但属性可以是对其他对象的引用。比如:
let user = {
name: "John",
sizes: {
height: 182,
width: 50,
},
};
alert(user.sizes.height); // 182
如果我们用上面的 Object.assign
的方法来克隆它,那么克隆出来的新对象和原来的 user 对象会共用一个 sizes,因为 user.sizes 是个对象,它会以引用形式被拷贝。
为了解决这个问题,并让 user 和 clone 成为两个真正独立的对象,我们应该使用一个拷贝循环来检查 user[key] 的每个值,如果它是一个对象,那么也复制它的结构。这就是所谓的深拷贝
。我们可以使用递归来实现它,可以采用现有的实现,例如 lodash
库的 _.cloneDeep(obj)
。
使用 const 声明的对象也是可以被修改的
通过引用对对象进行存储的一个重要的副作用是声明为 const
的对象 可以 被修改。
const user = {
name: "John",
};
user.name = "Pete"; // (*)
alert(user.name); // Pete
也就是说:user 的值是一个常量,它必须始终引用同一个对象,但该对象的属性可以被自由修改。换句话说,只有当我们尝试将 user=...
作为一个整体进行赋值时,const user
才会报错。
如果我们真的需要创建常量对象属性,也可以,但使用的是完全不同的方法。我们将在 属性标志和属性描述符
中学习它。
简单理解垃圾回收机制
原始值、对象、函数……这一切都会占用内存。当我们不再需要某个东西时会发生什么?JavaScript 引擎如何发现它并清理它?这就要提到 JavaScript 的垃圾回收机制。
可达性(Reachability)
JavaScript 中主要的内存管理概念是 可达性。简而言之,“可达”值是那些以某种方式可访问或可用的值。它们被存储在内存中。
- 什么值不能被释放?
- 当前执行的函数,它的局部变量和参数
- 当前嵌套调用链上的其他函数、它们的局部变量和参数
- 全局变量
- (还有一些其他的,内部实现)
这些值被称作 根(roots)
。
- 如果一个值可以从根通过引用或者引用链进行访问,则认为该值是可达的。比方说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则
该
对象被认为是可达的。而且它引用的内容也是可达的。
举个例子:
// user 具有对这个对象的引用
let user = {
name: "John",
};
全局变量 "user" 引用了对象 {name:"John"}
(为简洁起见,我们称它为 John)。John 的 "name" 属性存储一个原始值,所以它被写在对象内部。如果 user 的值被重写了,这个引用就没了:垃圾回收器会认为 John 是垃圾数据并进行回收,然后释放内存。
两个引用
如果我们把 user 的引用复制给 admin:
// user 具有对这个对象的引用
let user = {
name: "John",
};
let admin = user;
如果我们又去尝试重写 user 的值,对象仍然可以被通过 admin 这个全局变量访问到,因此它必须被保留在内存中。如果我们又重写了 admin,对象就会被删除。
对于相互关联的对象···
我们来看一个更为复杂的例子:
function marry(man, woman) {
woman.husband = man;
man.wife = woman;
return {
father: man,
mother: woman,
};
}
let family = marry(
{
name: "John",
},
{
name: "Ann",
}
);
// 其实,得到的对象是:
family = {
father: {
name: "John",
wife: "Ann",
},
mother: {
name: "Ann",
husband: "John",
},
};
marry 函数通过让两个对象相互引用使它们“结婚”了,并返回了一个包含这两个对象的新对象。所有对象都是可达的。
如果我们去删除两个引用:
delete family.father;
delete family.mother.husband;
// 那么对象会变成:
family = {
mother: {
name: "Ann",
},
};
那么我们可以看到再也没有对 John 的引用了:对外引用不重要,只有传入引用才可以使对象可达。所以,John 现在是不可达的,并且将被从内存中删除,同时 John 的所有数据也将变得不可达。
几个对象相互引用但是没有外部引用
几个对象相互引用,但外部没有对其任意对象的引用,这些对象也可能是不可达的,并被从内存中删除。
// 比如我们还是有这个对象
family = {
father: {
name: "John",
wife: "Ann",
},
mother: {
name: "Ann",
husband: "John",
},
};
// 现在我们修改 family 的值
family = null;
John 和 Ann 仍然连着,都有传入的引用。但是,这样还不够。
前面说的 "family" 对象已经不再与根相连,没有了外部对其的引用,所以它变成了一座“孤岛”,并且将被从内存中删除。
垃圾回收的基本算法
垃圾回收的基本算法被称为 “mark-and-sweep”。
定期执行以下“垃圾回收”步骤:
- 垃圾收集器找到所有的根,并“标记”(记住)它们。
- 然后它遍历并“标记”来自它们的所有引用。
- 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记- 住,以免将来再次遍历到同一个对象。
- ……如此操作,直到所有可达的(从根部)引用都被访问到。
- 没有被标记的对象都会被删除。
基础算法之外···
JavaScript 引擎做了许多优化,使垃圾回收运行速度更快,并且不会对代码执行引入任何延迟。
分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
当需要底层的优化时,对引擎有深入了解将很有帮助。在熟悉了这门编程语言之后,把熟悉引擎作为下一步计划是明智之选,届时,我们可能才会去深入理解垃圾回收机制。
对象方法 与 this 关键字
对象往往是现实世界的实体抽象,这其实就是所谓的 面向对象编程
,简称为 “OOP”。我们对实体进行操作,那么对象中的操作,我们则用属性中的函数来表示。
创建方法
我们给出一个简单的例子:
let user = {
name: "John",
age: 30,
};
user.sayHi = function () {
alert("Hello!");
};
user.sayHi(); // Hello!
这里我们使用函数表达式创建了一个函数,并将其指定给对象的 user.sayHi 属性。作为对象属性的函数被称为 方法
。
我们还可以怎么去声明一个对象方法:
- 使用预先声明的函数:
let user = {
// ...
};
// 首先,声明函数
function sayHi() {
alert("Hello!");
}
// 然后将其作为一个方法添加
user.sayHi = sayHi;
user.sayHi(); // Hello!
- 简写
user = {
sayHi: function () {
alert("Hello");
},
};
// 方法简写看起来更好,对吧?
let user = {
sayHi() {
// 与 "sayHi: function(){...}" 一样
alert("Hello");
},
};
TIP
这种表示法还是有些不同。在对象继承方面有一些细微的差别(稍后将会介绍),但目前它们并不重要。在几乎所有的情况下,更短的语法是首选的。
关于 this
通常,对象方法需要访问对象中存储的信息才能完成其工作。例如,user.sayHi() 中的代码可能需要用到 user 的 name 属性。为了访问该对象,方法中可以使用 this 关键字。
let user = {
name: "John",
age: 30,
sayHi() {
// "this" 指的是“当前的对象”
alert(this.name);
},
};
user.sayHi(); // John
WARNING
也可以在不使用 this 的情况下,通过外部变量名来引用 sayHi() 方法:
let user = {
name: "John",
age: 30,
sayHi() {
alert(user.name); // "user" 替代 "this"
},
};
但这样的代码是不可靠的。如果我们决定将 user 复制给另一个变量,例如 admin = user,并赋另外的值给 user,那么它将访问到错误的对象。比如:
let user = {
name: "John",
age: 30,
sayHi() {
alert(user.name); // 导致错误
},
};
let admin = user;
user = null; // 重写让其更明显
admin.sayHi(); // TypeError: Cannot read property 'name' of null
如果我们在 alert 中以 this.name 替换 user.name,那么代码就会正常运行。
this 不受限制
在 JavaScript 中,this 关键字与其他大多数编程语言中的不同。JavaScript 中的 this 可以用于任何函数,即使它不是对象的方法。比如
// 这也并不会有语法错误
function sayHi() {
alert(this.name);
}
**this 的值是在代码运行时计算出来的,它取决于代码上下文。**例如:
let user = { name: "John" };
let admin = { name: "Admin" };
function sayHi() {
alert(this.name);
}
// 在两个对象中使用相同的函数
user.f = sayHi;
admin.f = sayHi;
// 这两个调用有不同的 this 值
// 函数内部的 "this" 是“点符号前面”的那个对象
user.f(); // John(this == user)
admin.f(); // Admin(this == admin)
admin["f"](); // Admin(使用点符号或方括号语法来访问这个方法,都没有关系。)
也就是:如果 obj.f() 被调用了,则 this 在 f 函数调用期间是 obj。所以在上面的例子中 this 先是 user,之后是 admin。
在没有对象的情况下调用:this == undefined
我们甚至可以在没有对象的情况下调用函数:
function sayHi() {
alert(this);
}
sayHi(); // undefined
在这种情况下,严格模式下的 this 值为 undefined。如果我们尝试访问 this.name,将会报错。
在非严格模式的情况下,this 将会是 全局对象(浏览器中的 window)。这是一个历史行为,"use strict" 已经将其修复了。
通常这种调用是程序出错了。如果在一个函数内部有 this,那么通常意味着它是在对象上下文环境中被调用的。
解除 this 绑定的后果
如果你经常使用其他的编程语言,那么你可能已经习惯了“绑定 this”的概念,即在对象中定义的方法总是有指向该对象的 this。
在 JavaScript 中,this 是“自由”的,它的值是在调用时计算出来的,它的值并不取决于方法声明的位置,而是取决于在“点符号前”的是什么对象。
在运行时对 this 求值的这个概念既有优点也有缺点。一方面,函数可以被重用于不同的对象。另一方面,更大的灵活性造成了更大的出错的可能。
这里我们的立场并不是要评判编程语言的这个设计是好是坏。而是要了解怎样使用它,如何趋利避害。
箭头函数没有自己的 “this”
箭头函数有些特别:它们没有自己的 this。如果我们在这样的函数中引用 this,this 值取决于外部“正常的”函数。
举个例子,这里的 arrow() 使用的 this 来自于外部的 user.sayHi() 方法:
let user = {
firstName: "Ilya",
sayHi() {
let arrow = () => alert(this.firstName);
arrow();
},
};
user.sayHi(); // Ilya
这是箭头函数的一个特性,当我们并不想要一个独立的 this,反而想从外部上下文中获取时,它很有用。后面再讲到箭头函数的时候我们会再讨论一下。
另外,JavaScript INFO 后面的三个小 Task 都挺有意思的:https://zh.javascript.info/object-methods 可以加深对 this 的理解。
构造器和操作符 new
我们经常需要创建很多类似的对象,例如多个用户或菜单项等。这可以使用构造函数和 "new" 操作符来实现。
构造函数
构造函数在技术上是常规函数。不过有两个约定:
- 它们的命名以大写字母开头。
- 它们只能由 "new" 操作符来执行。
比如:
function User(name) {
this.name = name;
this.isAdmin = false;
}
let user = new User("Jack");
alert(user.name); // Jack
alert(user.isAdmin); // false
// 其实就是得到了对象:
user = {
name: "Jack",
isAdmin: false,
};
当一个函数被使用 new 操作符执行时,它按照以下步骤:
- 一个新的空对象被创建并分配给 this。
- 函数体执行。通常它会修改 this,为其添加新的属性。
- 返回 this 的值。
也就是:
function User(name) {
// this = {};(隐式创建)
// 添加属性到 this
this.name = name;
this.isAdmin = false;
// return this;(隐式返回)
}
现在,如果我们想创建其他用户,我们可以调用 new User("Ann"),new User("Alice") 等。比每次都使用字面量创建要短得多,而且更易于阅读。这是构造器的主要目的 —— 实现可重用的对象创建代码。
让我们再强调一遍 —— 从技术上讲,任何函数(除了箭头函数,它没有自己的 this)都可以用作构造器。即可以通过 new 来运行,它会执行上面的算法。“首字母大写”是一个共同的约定,以明确表示一个函数将被使用 new 来运行。
new function() { … }
如果我们有许多行用于创建单个复杂对象的代码,我们可以将它们封装在一个立即调用的构造函数中,像这样:
// 创建一个函数并立即使用 new 调用它
let user = new (function () {
this.name = "John";
this.isAdmin = false;
// ……用于用户创建的其他代码
// 也许是复杂的逻辑和语句
// 局部变量等
})();
这个构造函数不能被再次调用,因为它不保存在任何地方,只是被创建和调用。因此,这个技巧旨在封装构建单个对象的代码,而无需将来重用。
还要注意,这个和 new Function
不是一个东西!我们会在后面的章节讲到 new Function
关于 new.target
它其实很少被使用。不过还是提一嘴。
在一个函数内部,我们可以使用 new.target 属性来检查它是否被使用 new 进行调用了。对于常规调用,它为 undefined,对于使用 new 的调用,则等于该函数:
function User() {
alert(new.target);
}
// 不带 "new":
User(); // undefined
// 带 "new":
new User(); // function User { ... }
它可以被用在函数内部,来判断该函数是被通过 new 调用的“构造器模式”,还是没被通过 new 调用的“常规模式”。
我们也可以让 new 调用和常规调用做相同的工作,像这样:
function User(name) {
if (!new.target) {
// 如果你没有通过 new 运行我
return new User(name); // ……我会给你添加 new
}
this.name = name;
}
let john = User("John"); // 将调用重定向到新用户
alert(john.name); // John
这种方法有时被用在库中以使语法更加灵活。这样人们在调用函数时,无论是否使用了 new,程序都能工作。
不过,到处都使用它并不是一件好事,因为省略了 new 使得很难观察到代码中正在发生什么。而通过 new 我们都可以知道这创建了一个新对象。
构造器的 return
通常,构造器没有 return 语句。但是有 return 的时候:
- 如果 return 返回的是一个对象,则返回这个对象,而不是 this。
- 如果 return 返回的是一个原始类型,则忽略。
比如:
function BigUser() {
this.name = "John";
return { name: "Godzilla" }; // <-- 返回这个对象
}
alert( new BigUser().name ); // Godzilla,得到了那个对象
// return 为空(或者我们可以在它之后放置一个原始类型,没有什么影响)
function SmallUser() {
this.name = "John";
return; // <-- 返回 this
}
alert( new SmallUser().name ); // John
通常构造器没有 return 语句。这里我们主要为了完整性而提及返回对象的特殊行为。
构造器中的方法
使用构造函数来创建对象会带来很大的灵活性。构造函数可能有一些参数,这些参数定义了如何构造对象以及要放入什么。
当然,我们不仅可以将属性添加到 this 中,还可以添加方法。比如:
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() { ... }
}
*/
关于对象,我们在 原型,继承
和 类
章节中还会继续深入介绍它们
可选链 ?.
TIP
可选链是在 ECMAScript 2020 中引入的一项功能,它简化了在中间属性可能为 null 或 undefined 时访问嵌套对象或数组的属性和方法的过程。
可选链 ?. 是一种访问嵌套对象属性的安全的方式。即使中间的属性不存在,也不会出现错误。
先来讨论一下:“不存在的属性”
举个例子,假设我们有很多个 user 对象,其中存储了我们的用户数据。
我们大多数用户的地址都存储在 user.address 中,街道地址存储在 user.address.street 中,但有些用户没有提供这些信息。
在这种情况下,当我们尝试获取 user.address.street,而该用户恰好没提供地址信息,我们则会收到一个错误:
let user = {}; // 一个没有 "address" 属性的 user 对象
alert(user.address.street); // Error!
但是在很多实际场景中,我们更希望得到的是 undefined(表示没有 street 属性)而不是一个错误。
又比如:
我们使用 dom 操作时(例如 document.querySelector('.elem'))以对象的形式获取一个网页元素,如果没有这种对象,则返回 null。
// 如果 document.querySelector('.elem') 的结果为 null,则这里不存在这个元素
let html = document.querySelector(".elem").innerHTML;
// 如果 document.querySelector('.elem') 的结果为 null,这句代码则会出现错误
如果该元素不存在,则访问 null 的 .innerHTML 属性时会报错。在某些情况下,当元素的缺失是没问题的时候,我们希望避免出现这种错误,而是接受 html = null 作为结果。
OK,现在我们想在前者能得到 undefined ,后者能得到 null,那么可以分别写出下面的代码:
// 1
let user = {};
alert(user.address ? user.address.street : undefined);
// 2
let html = document.querySelector(".elem")
? document.querySelector(".elem").innerHTML
: null;
// 对于嵌套层次更深的属性,代码会变得更丑,因为需要更多的重复。
// 例如,让我们以相同的方式尝试获取 user.address.street.name
alert(
user.address ? (user.address.street ? user.address.street.name : null) : null
);
// 当然也可以用 && 运算符
alert(user.address && user.address.street && user.address.street.name);
// 不过还是不够优雅
可选链 ?.
被加入到了 JavaScript 这门编程语言中,就是为了简单地进行对整条路径上的属性使用与运算进行判断。
我该如何使用?
如果可选链 ?.
前面的值为 undefined 或者 null,它会停止运算并返回 undefined。
声明
为了简明起见,在接下来的内容中,我们会说如果一个属性既不是 null 也不是 undefined,那么它就“存在”。
例如 value?.prop
:
- 如果 value 存在,则结果与 value.prop 相同,
- 否则(当 value 为 undefined/null 时)则返回 undefined。
又比如我们想去安全地访问 user.address.street:
let user = {}; // user 没有 address 属性
alert( user?.address?.street ); // undefined(不报错)
对于 document.querySelector
:
let html = document.querySelector('.elem')?.innerHTML; // 如果没有符合的元素,则为 undefined
TIP
?. 语法使其前面的值成为可选值,但不会对其后面的起作用。
例如,在 user?.address.street.name 中,?. 允许 user 为 null/undefined(在这种情况下会返回 undefined)也不会报错,但这仅对于 user。更深层次的属性是通过常规方式访问的。如果我们希望它们中的一些也是可选的,那么我们需要使用更多的 ?. 来替换 .。
不要过度使用可选链
我们应该只将 ?. 使用在一些东西可以不存在的地方。
例如,如果根据我们的代码逻辑,user 对象必须存在,但 address 是可选的,那么我们应该这样写 user.address?.street,而不是这样 user?.address?.street。
那么,如果 user 恰巧为 undefined,我们会看到一个编程错误并修复它。否则,如果我们滥用 ?.,会导致代码中的错误在不应该被消除的地方消除了,这会导致调试更加困难。
?.
前的变量必须已声明
如果未声明变量 user,那么 user?.anything 会触发一个错误:
// ReferenceError: user is not defined
user?.address;
?. 前的变量必须已声明(例如 let/const/var user 或作为一个函数参数)。可选链仅适用于已声明的变量。
短路效应
如果 ?. 左边部分不存在,就会立即停止运算(“短路效应”)。因此,如果在 ?. 的右侧有任何进一步的函数调用或操作,它们均不会执行。
?.() 和 ?.[]
可选链 ?. 不是一个运算符,而是一个特殊的语法结构。它还可以与函数和方括号一起使用。
例如,将 ?.() 用于调用一个可能不存在的函数。
在下面这段代码中,userAdmin 具有 admin 方法,而 userGuest 没有:
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
我们还可以将 ?.
跟 delete 一起使用,所以我们可以使用 ?.
来安全地读取或删除,但不能写入:
delete user?.name; // 如果 user 存在,则删除 user.name
总而言之:
obj?.prop
—— 如果obj
存在则返回obj.prop
,否则返回undefined。
obj?.[prop]
—— 如果obj
存在则返回obj[prop]
,否则返回undefined。
obj.method?.()
—— 如果obj
.method 存在则调用obj.method()
,否则返回undefined。
symbol 类型
我们前面说了:作为对象属性键只能用两种原始类型:字符串和symbol,我们这里来讲讲symbol,它能给我们带来什么。
“symbol” 值表示唯一的标识符。
可以使用 Symbol() 来创建这种类型的值:
let id1 = Symbol();
// 我们可以给 symbol 一个描述(也称为 symbol 名)
// 这在代码调试时非常有用:
// id 是描述为 "id" 的 symbol
let id2 = Symbol("id2");
symbol 保证是唯一的。即使我们创建了许多具有相同描述的 symbol,它们的值也是不同。描述只是一个标签,不影响任何东西。
总而言之,symbol 是带有可选描述的“原始唯一值”
。让我们看看我们可以在哪里使用它们。
symbol 不会被自动转换为字符串
JavaScript 中的大多数值都支持字符串的隐式转换。例如,我们可以 alert 任何值,都可以生效。symbol 比较特殊,它不会被自动转换。
这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。
如果我们真的想显示一个 symbol,我们需要在它上面调用 .toString(),比如:
let id = Symbol("id");
alert(id.toString()); // 输出:Symbol(id)
获取 symbol.description 属性,只显示描述(description):
let id = Symbol("id");
alert(id.description); // id
“隐藏”属性
symbol 允许我们创建对象的“隐藏”属性,代码的任何其他部分都不能意外访问或重写这些属性。
比如:
let user = { // 假设user对象属于另一个代码库
name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 symbol 作为键来访问数据
使用 Symbol("id") 作为键,比起用字符串 "id" 来有什么好处呢?
由于 user 对象属于另一个代码库,所以向它们添加字段是不安全的,因为我们可能会影响代码库中的其他预定义行为。但 symbol 属性不会被意外访问到。第三方代码不会知道新定义的 symbol,因此将 symbol 添加到 user 对象是安全的。
如果我们要在对象字面量 {...} 中使用 symbol,则需要使用方括号把它括起来。
就像这样:
let id = Symbol("id");
let user = {
name: "John",
[id]: 123 // 而不是 "id":123
};
// 这是因为我们需要变量 id 的值作为键,而不是字符串 “id”。
symbol 在 for…in 中会被跳过
symbol 属性不参与 for..in 循环。
比如:
let id = Symbol("id");
let user = {
name: "John",
age: 30,
[id]: 123
};
for (let key in user) alert(key); // name, age(没有 symbol)
// 使用 symbol 任务直接访问
alert("Direct: " + user[id]); // Direct: 123
// Object.keys(user) 也会忽略它们。
// 这是一般“隐藏符号属性”原则的一部分。
// 如果另一个脚本或库遍历我们的对象,它不会意外地访问到符号属性。
但是,Object.assign 会同时复制字符串和 symbol 属性:
let id = Symbol("id");
let user = {
[id]: 123
};
let clone = Object.assign({}, user);
alert( clone[id] ); // 123
这里并不矛盾,就是这样设计的。这里的想法是当我们克隆或者合并一个 object 时,通常希望 所有
属性被复制(包括像 id 这样的 symbol)。
全局 symbol
正如我们所看到的,通常所有的 symbol 都是不同的,即使它们有相同的名字。但有时我们想要名字相同的 symbol 具有相同的实体。例如,应用程序的不同部分想要访问的 symbol "id" 指的是完全相同的属性。
我们可以通过 全局 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 返回一个名字,我们可以使用 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
但是它仅仅适用于全局symbol,如果 symbol 不是全局的,它将无法找到它并返回 undefined。
系统 symbol
JavaScript 内部有很多“系统” symbol,我们可以使用它们来微调对象的各个方面。
symbol
有很多方法,比如 Symbol.toPrimitive
允许我们将对象描述为原始值转换。
TIP
从技术上说,symbol 不是 100% 隐藏的。有一个内建方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 symbol。但大多数库、内建方法和语法结构都没有使用这些方法。
对象 —— 原始值转换
当对象相加 obj1 + obj2,相减 obj1 - obj2,或者使用 alert(obj) 打印时会发生什么?
JavaScript 不允许自定义运算符对对象的处理方式。与其他一些编程语言(Ruby,C++)不同,我们无法实现特殊的对象处理方法来处理加法(或其他运算)。
在此类运算的情况下,对象会被自动转换为原始值,然后对这些原始值进行运算,并得到运算结果(也是一个原始值)。
因此,由于我们从技术上无法实现此类运算,所以在实际项目中不存在对对象的数学运算。如果你发现有,除了极少数例外,通常是写错了。
我们有两个目的:
- 让我们在遇到类似的对对象进行数学运算的编程错误时,能够更加理解到底发生了什么。
- 也有例外,这些操作也可以是可行的。例如日期相减或比较(Date 对象)。我们稍后会遇到它们。
转换规则
现在我们已经掌握了方法(method)和 symbol 的相关知识,可以开始学习对象原始值转换了。
会发生什么?
- 没有转换为布尔值。所有的对象在布尔上下文(context)中均为 true,就这么简单。只有字符串和数字转换。
- 数字转换发生在对象相减或应用数学函数时。例如,Date 对象(将在 日期和时间 一章中介绍)可以相减,date1 - date2 的结果是两个日期之间的差值。
- 至于字符串转换 —— 通常发生在我们像 alert(obj) 这样输出一个对象和类似的上下文中。
我们可以使用特殊的对象方法,自己实现字符串和数字的转换。
现在让我们一起探究技术细节,因为这是深入讨论该主题的唯一方式。
hint
JavaScript 是如何决定应用哪种转换的?
类型转换在各种情况下有三种变体。它们被称为 “hint”:
"string"
:对象到字符串的转换,当我们对期望一个字符串的对象执行操作时,如 “alert”"number"
:对象到数字的转换,例如当我们进行数学运算时;大多数内建的数学函数也包括这种转换。"default"
:在少数情况下发生,当运算符“不确定”期望值的类型时。例如,二元加法 + 可用于字符串(连接),也可以用于数字(相加)。因此,当二元加法得到对象类型的参数时,它将依据 "default" hint 来对其进行转换。此外,如果对象被用于与字符串、数字或 symbol 进行 == 比较,这时到底应该进行哪种转换也不是很明确,因此使用 "default" hint。
除了一种情况(Date 对象,我们稍后会讲到)之外,所有内建对象都以和 "number" 相同的方式实现 "default" 转换。
TIP
为了进行转换,JavaScript 尝试查找并调用三个对象方法:
- 调用 objSymbol.toPrimitive —— 带有 symbol 键 Symbol.toPrimitive(系统 symbol)的方法,如果这个方法存在的话,
- 否则,如果 hint 是 "string" —— 尝试调用 obj.toString() 或 obj.valueOf(),无论哪个存在。
- 否则,如果 hint 是 "number" 或 "default" —— 尝试调用 obj.valueOf() 或 obj.toString(),无论哪个存在。
Symbol.toPrimitive
我们从第一个方法开始。有一个名为 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); // 二元运算,为 default,hint: default -> 1500
toString/valueOf
如果没有 Symbol.toPrimitive,那么 JavaScript 将尝试寻找 toString 和 valueOf 方法:
- 对于 "string" hint:调用 toString 方法,如果它不存在,则调用 valueOf 方法(因此,对于字符串转换,优先调用 toString)。
- 对于其他 hint:调用 valueOf 方法,如果它不存在,则调用 toString 方法(因此,对于数学运算,优先调用 valueOf 方法)。
toString
和 valueOf
方法很早己有了。它们不是 symbol(那时候还没有 symbol 这个概念),而是“常规的”字符串命名的方法。它们提供了一种可选的“老派”的实现转换的方法。
这些方法必须返回一个原始值。如果 toString 或 valueOf 返回了一个对象,那么返回值会被忽略(和这里没有方法的时候相同)。
默认情况下,普通对象具有 toString 和 valueOf 方法:
toString
方法返回一个字符串 "[object Object]"。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
转换可以返回任何原始类型
关于所有原始转换方法,有一个重要的点需要知道,就是它们不一定会返回 “hint” 的原始值。
没有限制 toString() 是否返回字符串,或 Symbol.toPrimitive 方法是否为 "number" hint 返回数字。
唯一强制性的事情是:这些方法必须返回一个原始值,而不是对象。
由于历史原因,如果 toString 或 valueOf 返回一个对象,则不会出现 error,但是这种值会被忽略(就像这种方法根本不存在)。这是因为在 JavaScript 语言发展初期,没有很好的 “error” 的概念。
相反,Symbol.toPrimitive 更严格,它 必须 返回一个原始值,否则就会出现 error。
进一步的转换
我们已经知道,许多运算符和函数执行类型转换,例如乘法 * 将操作数转换为数字。
如果我们将对象作为参数传递,则会出现两个运算阶段:
- 对象被转换为原始值(通过前面我们描述的规则)。
- 如果还需要进一步计算,则生成的原始值会被进一步转换。
例如:
let obj = {
// toString 在没有其他方法的情况下处理所有转换
toString() {
return "2";
}
};
alert(obj * 2); // 4,对象被转换为原始值字符串 "2",之后它被乘法转换为数字 2。
- 乘法 obj * 2 首先将对象转换为原始值(字符串 “2”)。
- 之后 "2" * 2 变为 2 * 2(字符串被转换为数字)
二元加法在同样的情况下会将其连接成字符串,因为它更愿意接受字符串:
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // 22("2" + 2)被转换为原始值字符串 => 级联
总而言之:
对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。
这里有三种类型(hint):
"string"
(对于 alert 和其他需要字符串的操作)"number"
(对于数学运算)"default"
(少数运算符,通常对象以和 "number" 相同的方式实现 "default" 转换) 规范明确描述了哪个运算符使用哪个 hint。
转换算法是:
- 调用
obj[Symbol.toPrimitive](hint)
如果这个方法存在, - 否则,如果 hint 是 "string"
- 尝试调用 obj.toString() 或 obj.valueOf(),无论哪个存在。
- 否则,如果 hint 是 "number" 或者 "default"
- 尝试调用 obj.valueOf() 或 obj.toString(),无论哪个存在。 所有这些方法都必须返回一个原始值才能工作(如果已定义)。
在实际使用中,通常只实现 obj.toString() 作为字符串转换的“全能”方法就足够了,该方法应该返回对象的“人类可读”表示,用于日志记录或调试。