You dont know JS
  • 对象与类

    • 对象基础
    • How-Objects-Work

第 1 章:对象基础

注意:
正在进行中

JS 中的一切都是对象。

这是关于 JS 最普遍但最不正确的"事实"之一。让我们开始揭穿这个误解。

JS 确实有对象,但这并不意味着所有的值都是对象。尽管如此,对象可以说是这门语言中最重要(也最多样!)的值类型,所以掌握它们对你的 JS 之旅至关重要。

对象机制无疑是最灵活和强大的容器类型 - 你可以将其他值放入其中;你编写的每个 JS 程序都会以某种方式使用它们。但这并不是对象在本书中占据重要地位的原因。对象是 JS 三大支柱之一的基础:原型。

为什么原型(以及本书后面将介绍的this关键字)如此核心,以至于成为 JS 的三大支柱之一?除此之外,原型是 JS 对象系统表达类设计模式的方式,而类设计模式是所有编程中最广泛依赖的设计模式之一。

因此,我们的旅程将从对象开始,建立对原型的完整理解,揭开this关键字的神秘面纱,并探索class系统。

对象作为容器

将多个值收集在一个容器中的一种常见方法是使用对象。对象是键/值对的集合。JS 中还有具有特殊行为的对象子类型,比如数组(数字索引)甚至函数(可调用);稍后会详细介绍这些子类型。

注意:
键通常被称为"属性名",而属性名和值的配对常被称为"属性"。本书将明确使用这些术语。

常规 JS 对象通常使用字面量语法声明,如下所示:

myObj = {
	// ..
};

**注意:**还有另一种创建对象的方法(使用 myObj = new Object() ),但这不常见也不推荐,几乎从来都不是合适的方法。坚持使用对象字面量语法。

很容易混淆{ .. }对的含义,因为 JS 根据使用的上下文,重载了花括号来表示以下任何一种:

  • 分隔值,如对象字面量
  • 定义对象解构模式(稍后会详细介绍)
  • 分隔插值字符串表达式,如 `some ${ getNumber() } thing`
  • 定义块,如在if和for循环上
  • 定义函数体

虽然在阅读代码时有时可能具有挑战性,请注意{ .. }花括号对是否用在程序中值/表达式有效出现的地方;如果是,则它是一个对象字面量,否则就是其他重载用法之一。

定义属性

在对象字面量花括号内,你可以使用propertyName: propertyValue对来定义属性(名称和值),如下所示:

myObj = {
	favoriteNumber: 42,
	isDeveloper: true,
	firstName: 'Kyle',
};

你分配给属性的值可以是字面量,如上所示,也可以通过表达式计算:

function twenty() {
	return 20;
}

myObj = {
	favoriteNumber: (twenty() + 1) * 2,
};

表达式(twenty() + 1) * 2会立即计算,结果(42)被分配为属性值。

开发人员有时想知道是否有一种方法可以为属性值定义一个"惰性"表达式,意味着它不是在赋值时计算,而是稍后定义。JS 没有惰性表达式,所以唯一的方法是将表达式包装在一个函数中:

function twenty() {
	return 20;
}
function myNumber() {
	return (twenty() + 1) * 2;
}

myObj = {
	favoriteNumber: myNumber, // 注意,不是 `myNumber()` 作为函数调用
};

在这种情况下,favoriteNumber 不是持有一个数值,而是持有一个函数引用。要计算结果,必须显式执行该函数引用。

看起来像 JSON?

你可能注意到我们到目前为止看到的这种对象字面量语法类似于一种相关语法,"JSON"(JavaScript 对象表示法):

{
	"favoriteNumber": 42,
	"isDeveloper": true,
	"firstName": "Kyle"
}

JS 的对象字面量和 JSON 之间的最大区别在于,对于定义为 JSON 的对象:

  1. 属性名必须用 " 双引号字符引起来

  2. 属性值必须是字面量(可以是基本类型、对象或数组),而不是任意的 JS 表达式

在 JS 程序中,对象字面量不需要引用属性名 - 你可以引用它们('或"都允许),但通常是可选的。然而,有些字符在属性名中是有效的,但如果不用引号括起来就无法包含;例如,前导数字或空格:

myObj = {
	favoriteNumber: 42,
	isDeveloper: true,
	firstName: 'Kyle',
	'2 nicknames': ['getify', 'ydkjs'],
};

另一个小区别是,JSON 语法 - 也就是将被解析为 JSON 的文本,例如来自.json文件 - 比一般的 JS 更严格。例如,JS 允许注释(// ..和/* .. */),以及对象和数组表达式中的尾随,逗号;JSON 不允许这些。幸运的是,JSON 仍然允许任意空白。

属性名

对象字面量中的属性名几乎总是被视为/强制转换为字符串值。一个例外是整数(或"看起来像整数")的属性"名称":

anotherObj = {
	42: '<-- 这个属性名将被视为整数',
	41: '<-- ...这个也是',

	true: '<-- 这个属性名将被视为字符串',
	[myObj]: '<-- ...这个也是',
};

42属性名将被视为整数属性名(又名索引);"41"字符串值也将被视为整数,因为它看起来像一个整数。相比之下,true值将成为字符串属性名"true",而myObj标识符引用通过周围的[ .. ]计算,将对象的值强制转换为字符串(通常默认为"[object Object]")。

警告:
如果你真的需要使用对象作为键/属性名,千万不要依赖这种计算的字符串强制转换;它的行为令人惊讶,几乎肯定不是预期的,所以很可能会出现程序错误。相反,使用更专门的数据结构,称为Map(在 ES6 中添加),其中用作属性"名称"的对象保持原样,而不是被强制转换为字符串值。

与上面的[myObj]一样,你可以在对象字面量定义时计算任何属性名(与计算属性值不同):

anotherObj = {
	['x' + 21 * 2]: true,
};

表达式"x" + (21 * 2)必须出现在[ .. ]括号内,它会立即计算,结果("x42")用作属性名。

符号作为属性名

ES6 添加了一个新的原始值类型Symbol,它通常用作存储和检索属性值的特殊属性名。它们是通过Symbol(..)函数调用创建的(不使用new关键字),该函数接受一个可选的描述字符串,仅用于更友好的调试目的;如果指定,描述对 JS 程序是不可访问的,因此除了调试输出外不用于任何其他目的。

myPropSymbol = Symbol('可选的、对开发者友好的描述');
注意:
符号有点像数字或字符串,除了它们的值对 JS 程序是不透明的,并且在 JS 程序中是全局唯一的。换句话说,你可以创建和使用符号,但 JS 不让你知道任何关于底层值的信息,也不让你对底层值做任何事情;这被 JS 引擎保留为隐藏的实现细节。

如前所述,计算的属性名是在对象字面量上定义符号属性名的方式:

myPropSymbol = Symbol('可选的、对开发者友好的描述');

anotherObj = {
	[myPropSymbol]: 'Hello, symbol!',
};

用于在anotherObj上定义属性的计算属性名将是实际的原始符号值(不管它是什么),而不是可选的描述字符串("可选的、对开发者友好的描述")。

因为符号在你的程序中是全局唯一的,所以不会发生意外冲突,即程序的一部分可能意外地定义了一个与程序的另一部分试图定义/分配的属性名相同的属性名。

符号还可用于挂钩对象的特殊默认行为,我们将在下一章的"扩展 MOP"中更详细地介绍这一点。

简洁属性

在定义对象字面量时,常见的做法是使用与现有作用域内标识符相同的属性名,该标识符保存你想要分配的值。

coolFact = '第一个因超速被定罪的人时速为8英里';

anotherObj = {
	coolFact: coolFact,
};
注意:
这与引用属性名定义 "coolFact": coolFact 是一样的,但 JS 开发人员很少引用属性名,除非绝对必要。事实上,习惯上是避免不必要的引号,所以不鼓励在不需要时包含它们。

在这种情况下,属性名和值表达式标识符相同,你可以省略属性名部分的属性定义,作为所谓的"简洁属性"定义:

coolFact = '第一个因超速被定罪的人时速为8英里';

anotherObj = {
	coolFact, // <-- 简洁属性简写
};

属性名是 "coolFact"(字符串),分配给属性的值是 coolFact 变量在那一刻的内容:"第一个因超速被定罪的人时速为8英里"。

起初,这种简写方便可能看起来令人困惑。但随着你越来越熟悉看到这个非常常见和流行的特性被使用,你可能会更喜欢它,因为它可以减少输入(和阅读!)。

简洁方法

另一个类似的简写是使用更简洁的形式在对象字面量中定义函数/方法:

anotherObj = {
	// 标准函数属性
	greet: function () {
		console.log('Hello!');
	},

	// 简洁函数/方法属性
	greet2() {
		console.log('Hello, friend!');
	},
};

当我们讨论简洁方法属性时,我们还可以定义生成器函数(另一个 ES6 特性):

anotherObj = {
	// 不用这样:
	//   greet3: function*() { yield "Hello, everyone!"; }

	// 简洁生成器方法
	*greet3() {
		yield 'Hello, everyone!';
	},
};

虽然并不常见,但简洁方法/生成器甚至可以有引号或计算的名称:

anotherObj = {
	'greet-4'() {
		console.log('Hello, audience!');
	},

	// 简洁计算名称
	['gr' + 'eet 5']() {
		console.log('Hello, audience!');
	},

	// 简洁计算生成器名称
	*['ok, greet 6'.toUpperCase()]() {
		yield 'Hello, audience!';
	},
};

对象展开

在对象字面量创建时定义属性的另一种方式是使用 ... 语法形式 -- 它实际上不是一个运算符,但看起来很像一个 -- 通常被称为"对象展开"。

在对象字面量内使用的 ... 会将另一个对象值的内容(属性,即键/值对)"展开"到正在定义的对象中:

anotherObj = {
	favoriteNumber: 12,

	...myObj, // 对象展开,浅拷贝 `myObj`

	greeting: 'Hello!',
};

myObj 属性的展开是浅层的,因为它只复制 myObj 的顶层属性;那些属性持有的任何值都只是简单地分配过来。如果这些值中的任何一个是对其他对象的引用,则引用本身被分配(通过复制),但底层对象值不会被复制 -- 所以你最终会得到多个对同一对象的共享引用。

你可以将对象展开想象成一个 for 循环,它一次一个地运行属性,并从源对象(myObj)到目标对象(anotherObj)进行 = 样式的赋值。

另外,考虑这些属性定义操作是"按顺序"进行的,从对象字面量的顶部到底部。在上面的代码片段中,由于 myObj 有一个 favoriteNumber 属性,对象展开最终会覆盖前一行的 favoriteNumber: 12 属性赋值。此外,如果 myObj 包含了一个被复制过来的 greeting 属性,下一行(greeting: "Hello!")会覆盖该属性定义。

注意:
对象展开也只复制自有属性(直接在对象上的属性)且这些属性是可枚举的(允许被枚举/列出)。它不会复制属性 -- 就是说,实际上不会模仿属性的确切特征 -- 而是进行简单的赋值式复制。我们将在下一章的"属性描述符"部分详细介绍这些细节。

使用 ... 对象展开的一种常见方式是执行浅层对象复制:

myObjShallowCopy = { ...myObj };

请记住,你不能将 ... 展开到现有的对象值中;... 对象展开语法只能出现在创建新对象值的 { .. } 对象字面量内。要使用 API 而不是语法执行类似的浅层对象复制,请参见本章后面的"对象条目"部分(介绍 Object.entries(..) 和 Object.fromEntries(..))。

但如果你想要将对象属性(浅层地)复制到现有的对象中,请参见本章后面的"分配属性"部分(介绍 Object.assign(..))。

深层对象复制

另外,由于 ... 不执行完全的深层对象复制,对象展开通常只适合复制只包含简单、原始值的对象,而不适合包含对其他对象的引用。

深层对象复制是一个非常复杂和微妙的操作。复制 42 这样的值是显而易见且简单的,但复制一个函数(它是一种特殊的对象,也是通过引用持有的)或复制一个外部(不完全在 JS 中的)对象引用(如 DOM 元素)意味着什么?如果一个对象有循环引用(比如一个嵌套的后代对象持有对外部祖先对象的引用)会发生什么?对于如何处理所有这些边缘情况,存在各种观点,因此不存在单一的深层对象复制标准。

对于深层对象复制,标准方法有:

  1. 使用声明了特定观点的库工具,说明应如何处理复制行为/细节。

  2. 使用 JSON.parse(JSON.stringify(..)) 往返技巧 -- 这只有在没有循环引用,并且对象中没有无法通过 JSON 正确序列化的值(如函数)时才能"正常工作"。

最近,第三个选项已经出现。这不是 JS 特性,而是由诸如 Web 平台等环境提供给 JS 的配套 API。现在可以使用 structuredClone(..) [^structuredClone] 来深层复制对象。

myObjCopy = structuredClone(myObj);

这个内置工具背后的底层算法支持复制循环引用,以及比 JSON 往返技巧更多类型的值。然而,这个算法仍然有其限制,包括不支持克隆函数或 DOM 元素。

访问属性

最好使用 . 运算符来访问现有对象的属性:

myObj.favoriteNumber; // 42
myObj.isDeveloper; // true

如果可以这样访问属性,强烈建议这样做。

如果属性名包含不能出现在标识符中的字符,例如前导数字或空格,则可以使用 [ .. ] 括号代替 .:

myObj['2 nicknames']; // [ "getify", "ydkjs" ]
anotherObj[42]; // "<-- 这个属性名将..."
anotherObj['41']; // "<-- 这个属性名将..."

即使数字属性"名称"保持为数字,通过 [ .. ] 括号的属性访问将把字符串表示强制转换为数字(例如,"42" 转换为数字等价物 42),然后相应地访问关联的数字属性。

与对象字面量类似,要访问的属性名可以通过 [ .. ] 括号计算。表达式可以是一个简单的标识符:

propName = '41';
anotherObj[propName];

实际上,你在 [ .. ] 括号之间放置的可以是任意的 JS 表达式,不仅仅是标识符或字面值,如 42 或 "isDeveloper"。JS 首先会评估表达式,然后将结果值用作要在对象上查找的属性名:

function howMany(x) {
	return x + 1;
}

myObj[`${howMany(1)} nicknames`]; // [ "getify", "ydkjs" ]

在这个代码片段中,表达式是一个反引号分隔的 `模板字符串字面量`,其中插入了函数调用 howMany(1) 的表达式。该表达式的整体结果是字符串值 "2 nicknames",然后用作要访问的属性名。

对象条目

你可以获取对象中属性的列表,作为一个包含属性名和值的元组(两元素子数组)的数组:

myObj = {
	favoriteNumber: 42,
	isDeveloper: true,
	firstName: 'Kyle',
};

Object.entries(myObj);
// [ ["favoriteNumber",42], ["isDeveloper",true], ["firstName","Kyle"] ]

在 ES6 中添加的 Object.entries(..) 从源对象中检索这个条目列表 -- 只包含自有和可枚举的属性;请参见下一章的"属性描述符"部分。

这样的列表可以被循环/迭代,可能将属性分配给另一个现有对象。然而,也可以使用 Object.fromEntries(..) (在 ES2019 中添加)从条目列表创建一个新对象:

myObjShallowCopy = Object.fromEntries(Object.entries(myObj));

// 这是前面讨论过的另一种方法:
// myObjShallowCopy = { ...myObj };

解构

访问属性的另一种方法是通过对象解构(在 ES6 中添加)。可以将解构视为定义一个"模式",描述一个对象值应该"看起来像什么"(结构上),然后要求 JS 遵循该"模式"系统地访问对象值的内容。

对象解构的最终结果不是另一个对象,而是将源对象的值分配给其他目标(变量等)的一个或多个赋值。

想象一下这种 ES6 之前的代码:

myObj = {
	favoriteNumber: 42,
	isDeveloper: true,
	firstName: 'Kyle',
};

const favoriteNumber =
	myObj.favoriteNumber !== undefined ? myObj.favoriteNumber : 42;
const isDev = myObj.isDeveloper;
const firstName = myObj.firstName;
const lname = myObj.lastName !== undefined ? myObj.lastName : '--missing--';

那些属性值的访问和对其他标识符的赋值通常被称为"手动解构"。要使用声明式对象解构语法,它可能看起来像这样:

myObj = {
	favoriteNumber: 42,
	isDeveloper: true,
	firstName: 'Kyle',
};

const { favoriteNumber = 12 } = myObj;
const {
	isDeveloper: isDev,
	firstName: firstName,
	lastName: lname = '--missing--',
} = myObj;

favoriteNumber; // 42
isDev; // true
firstName; // "Kyle"
lname; // "--missing--"

如所示,{ .. } 对象解构类似于对象字面量值定义,但它出现在 = 运算符的左侧,而不是右侧,通常对象值表达式会出现在右侧。这使得左侧的 { .. } 成为解构模式,而不是另一个对象定义。

{ favoriteNumber } = myObj 解构告诉 JS 在对象上找到一个名为 favoriteNumber 的属性,并将其值分配给同名的标识符。模式中 favoriteNumber 标识符的单个实例类似于前面讨论的"简洁属性":如果源(属性名)和目标(标识符)相同,你可以省略其中一个,只列出一次。

= 12 部分告诉 JS,如果源对象要么没有 favoriteNumber 属性,要么该属性持有 undefined 值,则为 favoriteNumber 的赋值提供 12 作为默认值。

在第二个解构模式中,isDeveloper: isDev 模式指示 JS 在源对象上找到一个名为 isDeveloper 的属性,并将其值分配给名为 isDev 的标识符。这有点像将源"重命名"为目标。相比之下,firstName: firstName 提供了赋值的源和目标,但由于它们是相同的,所以是多余的;这里单个 firstName 就足够了,通常更受欢迎。

lastName: lname = "--missing--" 结合了源-目标重命名和默认值(如果 lastName 源属性丢失或 undefined)。

上面的代码片段将对象解构与变量声明结合在一起 -- 在这个例子中使用了 const,但 var 和 let 也可以工作 -- 但它本质上不是一个声明机制。解构是关于访问和赋值(从源到目标)的,所以它可以针对现有目标而不是声明新目标:

let fave;

// 这里需要周围的 ( ),
// 当不使用声明符时
({ favoriteNumber: fave } = myObj);

fave; // 42

对象解构语法通常因其声明性和更可读的风格而受到青睐,相比于 ES6 之前的高度命令式等价物。但不要过度使用解构。有时只做 x = someObj.x 就完全可以了!

条件属性访问

最近(在 ES2020 中),一个称为"可选链接"的特性被添加到 JS 中,它增强了属性访问能力(特别是嵌套属性访问)。主要形式是两个字符的复合运算符 ?.,如 A?.B。

这个运算符将检查左侧引用(A)是否为空值(null 或 undefined)。如果是,则属性访问表达式的其余部分将被短路(跳过),并返回 undefined 作为结果(即使实际遇到的是 null!)。否则,?. 将像正常的 . 运算符一样访问属性。

例如:

myObj?.favoriteNumber;

这里,对 myObj 进行空值检查,这意味着只有当 myObj 中的值非空时才执行 favoriteNumber 属性访问。注意,它并不验证 myObj 是否真的持有一个真实的对象,只是验证它是非空的。然而,所有非空值都可以通过

. 运算符进行"安全"(不会抛出 JS 异常)的"访问",即使没有匹配的属性可检索。

很容易误以为空值检查是针对 favoriteNumber 属性的。但记住 ? 在安全检查执行的一侧,而 . 在只有在非空检查通过时才有条件评估的一侧,这有助于理解。

通常,?. 运算符用于可能深度为 3 层或更多的嵌套属性访问,例如:

myObj?.address?.city;

使用 ?. 运算符的等效操作如下所示:

myObj != null && myObj.address != null ? myObj.address.city : undefined;

再次提醒,这里没有对最右边的属性(city)进行检查。

此外,不应在程序中普遍使用 ?. 来替代每个单独的 . 运算符。你应该尽可能在进行 . 属性访问之前知道它是否会成功。只有当被访问的值的性质受制于无法预测/控制的条件时,才使用 ?.。

例如,在前面的代码片段中,myObj?. 的使用可能是误导的,因为在开始对可能甚至不持有顶级对象的变量进行一系列属性访问时,这种情况实际上不应该发生(除了在某些条件下可能缺少某些属性的内容)。

相反,我建议使用如下用法:

myObj.address?.city;

并且该表达式应该只在你确定 myObj 至少持有一个有效对象的程序部分使用(无论它是否有一个包含子对象的 address 属性)。

"可选链接"运算符的另一种形式是 ?.[,当你想要进行的属性访问需要 [ .. ] 括号时使用。

myObj['2 nicknames']?.[0]; // "getify"

关于 ?. 行为的所有断言同样适用于 ?.[。

警告:
这个特性还有第三种形式,称为"可选调用",使用 ?.( 作为运算符。它用于在执行属性中的函数值之前对属性进行非空检查。例如,不用 myObj.someFunc(42),你可以做 myObj.someFunc?.(42)。?.( 在调用之前(带有 (42) 部分)检查 myObj.someFunc 是否为非空。虽然这可能听起来是一个有用的特性,但我认为这足够危险,值得完全避免使用这种形式/结构。

我的担心是 ?.( 让人觉得我们在确保函数是"可调用的"才调用它,而事实上我们只是检查它是否非空。与 ?. 不同,?. 允许对非空但也不是对象的值进行"安全"的 . 访问,?.( 非空检查并不同样"安全"。如果相关属性有任何非空、非函数值,如 true 或 "Hello",(42) 调用部分将被调用并抛出 JS 异常。因此,换句话说,这种形式不幸地伪装成比实际更"安全",因此在本质上所有情况下都应避免使用。如果属性值可能不是函数,在尝试调用它之前,请更全面地检查其函数性。不要假装 ?.( 正在为你做这件事,否则你的代码的未来读者/维护者(包括你未来的自己!)可能会后悔。

访问非对象的属性

这可能听起来有悖常理,但你通常可以从本身不是对象的值访问属性/方法:

fave = 42;

fave; // 42
fave.toString(); // "42"

这里,fave 持有一个原始的 42 数值。那么我们怎么能对它做 .toString 来访问一个属性,然后 () 来调用该属性中持有的函数呢?

这是一个比我们在本书中讨论的更深入的话题;请参阅本系列的第 4 本书,"类型与语法",以获取更多信息。然而,简单地说:如果你对一个非对象、非空值执行属性访问(. 或 [ .. ]),JS 默认会(暂时!)将该值强制转换为一个对象包装的表示,允许对该隐式实例化的对象进行属性访问。

这个过程通常被称为"装箱",就像把一个值放进一个"盒子"(对象容器)里。

所以在上面的代码片段中,就在访问 42 值的 .toString 的那一刻,JS 会将这个值装箱到一个 Number 对象中,然后执行属性访问。

注意,null 和 undefined 可以通过调用 Object(null) / Object(undefined) 对象化。然而,JS 不会自动装箱这些空值,所以对它们的属性访问将失败(如前面"条件属性访问"部分所讨论的)。

注意:
装箱有一个对应物:拆箱。例如,当 JS 引擎遇到一个对象包装器 -- 比如用 Number(42) 或 Object(42) 创建的包裹着 42 的 Number 对象 -- 时,只要遇到数学运算(如 * 或 -),它就会拆箱以检索底层的原始 42。拆箱行为远远超出了我们讨论的范围,但在前面提到的"类型与语法"标题中有完整的介绍。

分配属性

无论属性是在对象字面量定义时定义的,还是后来添加的,属性值的分配都是用 = 运算符完成的,就像任何其他正常的赋值一样:

myObj.favoriteNumber = 123;

如果 favoriteNumber 属性还不存在,该语句将创建一个新的具有该名称的属性并分配其值。但如果它已经存在,该语句将重新分配其值。

警告:
对属性的 = 赋值可能会失败(静默或抛出异常),或者它可能不直接分配值,而是调用执行某些操作的setter函数。下一章将详细介绍这些行为。

也可以一次分配一个或多个属性 -- 假设源属性(名称和值对)在另一个对象中 -- 使用 Object.assign(..) (在 ES6 中添加)方法:

// 浅拷贝所有(自有和可枚举的)属性
// 从 `myObj` 到 `anotherObj`
Object.assign(anotherObj, myObj);

Object.assign(
	/*目标=*/ anotherObj,
	/*源1=*/ {
		someProp: 'some value',
		anotherProp: 1001,
	},
	/*源2=*/ {
		yetAnotherProp: false,
	}
);

Object.assign(..) 将第一个对象作为目标,第二个(和可选的后续)对象作为源。复制以前面"对象展开"部分描述的相同方式进行。

删除属性

一旦在对象上定义了属性,唯一删除它的方法是使用 delete 运算符:

anotherObj = {
	counter: 123,
};

anotherObj.counter; // 123

delete anotherObj.counter;

anotherObj.counter; // undefined

与普遍的误解相反,JS 的 delete 运算符不直接进行任何内存的释放或垃圾回收(GC)。它唯一做的就是从对象中移除一个属性。如果属性中的值是对另一个对象/等的引用,并且一旦属性被移除,该值没有其他存活的引用,那么该值很可能在未来的 GC 扫描中有资格被移除。

在非对象属性上调用 delete 是对 delete 运算符的误用,要么会静默失败(在非严格模式下),要么会抛出异常(在严格模式下)。

从对象中删除属性不同于将其赋值为 undefined 或 null。最初或后来被赋值为 undefined 的属性仍然存在于对象上,在枚举内容时可能仍会被揭示。

确定容器内容

你可以通过多种方式确定对象的内容。要询问对象是否有特定属性:

myObj = {
	favoriteNumber: 42,
	coolFact: '第一个因超速被定罪的人时速为8英里',
	beardLength: undefined,
	nicknames: ['getify', 'ydkjs'],
};

'favoriteNumber' in myObj; // true

myObj.hasOwnProperty('coolFact'); // true
myObj.hasOwnProperty('beardLength'); // true

myObj.nicknames = undefined;
myObj.hasOwnProperty('nicknames'); // true

delete myObj.nicknames;
myObj.hasOwnProperty('nicknames'); // false

in 运算符和 hasOwnProperty(..) 方法的行为有一个重要区别。in 运算符不仅会检查指定的目标对象,如果在那里没有找到,它还会查询对象的 [[Prototype]] 链(在下一章中介绍)。相比之下,hasOwnProperty(..) 只查询目标对象。

如果你仔细观察,你可能已经注意到 myObj 似乎有一个叫做 hasOwnProperty(..) 的方法属性,尽管我们没有定义它。这是因为 hasOwnProperty(..) 被定义为 Object.prototype 上的内置方法,默认情况下所有普通对象都"继承"它。然而,访问这样一个"继承的"方法存在固有风险。再次强调,更多关于原型的内容将在下一章中介绍。

更好的存在性检查

ES2022(在写作时几乎是官方的)已经确定了一个新特性,Object.hasOwn(..)。它本质上做的事情与 hasOwnProperty(..) 相同,但它是作为一个静态助手在对象值之外调用的,而不是通过对象的 [[Prototype]],使其在使用上更安全和一致:

// 不用:
myObj.hasOwnProperty('favoriteNumber');

// 我们现在应该优先使用:
Object.hasOwn(myObj, 'favoriteNumber');

尽管(在写作时)这个特性刚刚在 JS 中出现,但有一些 polyfills 可以让这个 API 在你的程序中可用,即使在运行在尚未定义该特性的先前 JS 环境中。例如,一个快速的 polyfill 草图:

// `Object.hasOwn(..)` 的简单polyfill草图
if (!Object.hasOwn) {
	Object.hasOwn = function hasOwn(obj, propName) {
		return Object.prototype.hasOwnProperty.call(obj, propName);
	};
}

在你的程序中包含这样的 polyfill 补丁意味着你可以安全地开始使用 Object.hasOwn(..) 进行属性存在性检查,无论 JS 环境是否内置了 Object.hasOwn(..)。

列出所有容器内容

我们之前已经讨论过 Object.entries(..) API,它告诉我们一个对象有哪些属性(只要它们是可枚举的 -- 下一章会有更多介绍)。

还有其他可用的机制。Object.keys(..) 给我们一个对象中可枚举属性名(也称为键)的列表 -- 只有名称,没有值;Object.values(..) 则给我们一个在可枚举属性中持有的所有值的列表。

但如果我们想获取对象中所有的键(可枚举或不可枚举)?Object.getOwnPropertyNames(..) 似乎做了我们想要的,因为它像 Object.keys(..) 一样但也返回不可枚举的属性名。然而,这个列表不会包括任何 Symbol 属性名,因为它们被视为对象上的特殊位置。Object.getOwnPropertySymbols(..) 返回对象的所有 Symbol 属性。所以如果你将这两个列表连接在一起,你就会有对象的所有直接(自有)内容。

然而,正如我们已经暗示过几次,并将在下一章中全面介绍的,一个对象也可以从其 [[Prototype]] 链"继承"内容。这些不被视为自有内容,所以它们不会出现在任何这些列表中。

回想一下,in 运算符可能会遍历整个链来寻找属性的存在。类似地,for..in 循环将遍历链并列出任何可枚举的(自有或继承的)属性。但没有内置的 API 可以遍历整个链并返回自有和继承内容的组合集合的列表。

临时容器

使用容器来持有多个值有时只是一种临时传输机制,比如当你想通过单个参数向函数传递多个值时,或当你想让函数返回多个值时:

function formatValues({ one, two, three }) {
	// 作为参数传入的实际对象
	// 是不可访问的,因为
	// 我们将它解构为三个
	// 单独的变量

	one = one.toUpperCase();
	two = `--${two}--`;
	three = three.substring(0, 5);

	// 这个对象只是为了传输
	// 所有三个值在一个单一的
	// return语句中
	return { one, two, three };
}

// 解构函数的返回值,
// 因为那个返回的对象
// 只是一个临时容器
// 用来传输多个值给我们
const { one, two, three } =
	// 这个对象参数是一个临时
	// 传输多个输入值的容器
	formatValues({
		one: 'Kyle',
		two: 'Simpson',
		three: 'getify',
	});

one; // "KYLE"
two; // "--Simpson--"
three; // "getif"

传入formatValues(..)的对象字面量立即被参数解构,所以在函数内部我们只处理三个单独的变量(one, two, 和 three)。从函数return的对象字面量也立即被解构,所以我们再次只处理三个单独的变量(one, two, three)。

这段代码说明了这样一种习惯/模式:对象有时只是一个临时传输容器,而不是本身具有意义的值。

容器是属性的集合

对象最常见的用途是作为多个值的容器。我们通过以下方式创建和管理属性容器对象:

  • 定义属性(命名位置),可以在对象创建时或之后
  • 分配值,可以在对象创建时或之后
  • 稍后使用位置名称(属性名)访问值
  • 通过delete删除属性
  • 使用in, hasOwnProperty(..) / hasOwn(..), Object.entries(..) / Object.keys(..) 等确定容器内容

但对象不仅仅是静态的属性名和值的集合。在下一章中,我们将深入研究它们实际上是如何工作的。

Next
How-Objects-Work