You dont know JS
  • 对象与类

    • 对象基础
    • How-Objects-Work

对象不仅仅是多个值的容器,尽管显然这是与对象交互的大多数情况下的上下文。

要充分理解 JS 中的对象机制,并在我们的程序中最大限度地利用对象,我们需要更仔细地查看对象(及其属性)的许多特征,这些特征可能会影响与它们交互时的行为。

这些定义对象底层行为的特征在正式术语中被统称为"元对象协议"(MOP)。MOP 不仅有助于理解对象的行为方式,还可以用于覆盖对象的默认行为,使语言更好地满足我们程序的需求。

属性描述符

对象上的每个属性在内部都由所谓的"属性描述符"描述。这本身就是一个对象(又称"元对象"),它有几个属性(又称"特性")在其上,决定了目标属性的行为方式。

我们可以使用Object.getOwnPropertyDescriptor(..)(ES5)检索任何现有属性的属性描述符:

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

Object.getOwnPropertyDescriptor(myObj, 'favoriteNumber');
// {
//     value: 42,
//     enumerable: true,
//     writable: true,
//     configurable: true
// }

我们甚至可以使用这样的描述符通过Object.defineProperty(..)(ES5)在对象上定义新属性:

anotherObj = {};

Object.defineProperty(anotherObj, 'fave', {
	value: 42,
	enumerable: true, // 如果省略则为默认值
	writable: true, // 如果省略则为默认值
	configurable: true, // 如果省略则为默认值
});

anotherObj.fave; // 42

如果现有属性尚未被标记为不可配置(在其描述符中设置configurable: false),则始终可以使用Object.defineProperty(..)重新定义/覆盖它。

警告:
本章前面的几个部分提到了"复制"或"复制"属性。人们可能会认为这种复制/复制会在属性描述符级别进行。然而,这些操作实际上都不是这样工作的;它们都执行简单的=样式访问和赋值,这会忽略属性的底层描述符定义中的任何细微差别。

虽然在实际应用中似乎不太常见,但我们甚至可以一次定义多个属性,每个属性都有自己的描述符:

anotherObj = {};

Object.defineProperties(anotherObj, {
	fave: {
		// 一个属性描述符
	},
	superFave: {
		// 另一个属性描述符
	},
});

很少看到这种用法,因为你很少需要专门控制多个属性的定义。但在某些情况下可能会有用。

访问器属性

属性描述符通常定义一个value属性,如上所示。然而,可以定义一种特殊类型的属性,称为"访问器属性"(又名 getter/setter)。对于这样的属性,其描述符不定义固定的value属性,而是看起来像这样:

{
    get() { .. },    // 检索值时调用的函数
    set(v) { .. },   // 赋值时调用的函数
    // .. enumerable等
}

getter 看起来像属性访问(obj.prop),但在底层它调用定义的get()方法;这有点像你调用了obj.prop()。setter 看起来像属性赋值(obj.prop = value),但它调用定义的set(..)方法;这有点像你调用了obj.prop(value)。

让我们举例说明一个 getter/setter 访问器属性:

anotherObj = {};

Object.defineProperty(anotherObj, 'fave', {
	get() {
		console.log("获取'fave'值!");
		return 123;
	},
	set(v) {
		console.log(`忽略${v}赋值。`);
	},
});

anotherObj.fave;
// 获取'fave'值!
// 123

anotherObj.fave = 42;
// 忽略42赋值。

anotherObj.fave;
// 获取'fave'值!
// 123

可枚举、可写、可配置

除了value或get() / set(..)之外,属性描述符的其他 3 个属性是(如上所示):

  • enumerable
  • writable
  • configurable

enumerable属性控制该属性是否会出现在对象属性的各种枚举中,例如Object.keys(..)、Object.entries(..)、for..in循环,以及使用...对象展开和Object.assign(..)时发生的复制。大多数属性应该保持可枚举,但如果某些特殊属性不应被迭代/复制,你可以将它们标记为不可枚举。

writable属性控制是否允许value赋值(通过=)。要使属性"只读",请将其定义为writable: false。但是,只要该属性仍然可配置,Object.defineProperty(..)仍然可以通过设置不同的value来更改值。

configurable属性控制属性的描述符是否可以重新定义/覆盖。configurable: false的属性被锁定到其定义,任何进一步使用Object.defineProperty(..)更改它的尝试都将失败。只要属性描述符上仍然设置了writable: true,非可配置属性仍然可以被赋予新值(通过=)。

对象子类型

JS 中有各种专门的对象子类型。但到目前为止,你最常与之交互的两种是数组和函数。

注意:
通过"子类型",我们指的是从父类型继承行为但随后专门化或扩展这些行为的派生类型的概念。换句话说,这些子类型的值完全是对象,但也不仅仅是对象。

数组

数组是专门用于数字索引的对象,而不是使用字符串命名的属性位置。它们仍然是对象,所以像favoriteNumber这样的命名属性是合法的。但是强烈不建议将命名属性混入数字索引的数组中。

数组最好用字面量语法定义(类似于对象),但使用[ .. ]方括号而不是{ .. }大括号:

myList = [23, 42, 109];

JS 允许数组中包含任何混合的值类型,包括对象、其他数组、函数等。你可能已经知道,数组是"从零开始索引的",这意味着数组中的第一个元素位于索引0,而不是1:

myList = [23, 42, 109];

myList[0]; // 23
myList[1]; // 42

回想一下,对象上任何"看起来像"整数的字符串属性名 - 能够有效地强制转换为数字整数 - 实际上会被视为整数属性(又名整数索引)。数组也是如此。你应该始终使用42作为整数索引(又名属性名),但如果你使用字符串"42",JS 会假设你是指整数,并为你处理。

// "2"在这里作为整数索引工作,但不建议这样做
myList['2']; // 109

"数组上不应有命名属性"规则的一个例外是,所有数组自动公开一个length属性,它会自动更新以保持与数组的"长度"一致。

myList = [23, 42, 109];

myList.length; // 3

// "push"另一个值到列表末尾
myList.push('Hello');

myList.length; // 4
警告:
许多 JS 开发人员错误地认为数组length基本上是一个getter(参见本章前面的"访问器属性"),但事实并非如此。结果是,这些开发人员觉得访问这个属性"代价很高" - 好像 JS 必须即时重新计算长度 - 因此会做一些事情,比如在对数组进行非变异循环之前捕获/存储数组的长度。从性能角度来看,这曾经是"最佳实践"。但至少在过去 10 年里,这实际上已经成为一种反模式,因为 JS 引擎管理length属性比我们的 JS 代码试图"智胜"引擎以避免调用我们认为是getter的东西更有效率。让 JS 引擎完成它的工作,并在需要时随时随地访问该属性是更有效的。

空槽位

JS 数组在设计上还有一个非常不幸的"缺陷",称为"空槽位"。如果你为数组分配一个超出数组当前末尾一个以上位置的索引,JS 会将中间的槽位留"空",而不是自动将它们分配为undefined,如你可能期望的那样:

myList = [23, 42, 109];
myList.length; // 3

myList[14] = 'Hello';
myList.length; // 15

myList; // [ 23, 42, 109, empty x 11, "Hello" ]

// 看起来像一个真实的槽位,
// 里面有一个真实的`undefined`值,
// 但要小心,这是个陷阱!
myList[9]; // undefined

你可能想知道为什么空槽位如此糟糕?一个原因是:在 JS 中,有些 API,比如数组的map(..)方法,会令人惊讶地跳过空槽位!永远不要故意在数组中创建空槽位。这无疑是 JS 的"糟糕之处"之一。

函数

我在这里没有太多关于函数的具体内容要说,只是要指出它们也是子对象类型。这意味着除了可执行之外,它们还可以添加命名属性或从中访问命名属性。

函数有两个预定义的属性,你可能会发现自己在与之交互,特别是用于元编程目的:

function help(opt1, opt2, ...remainingOpts) {
	// ..
}

help.name; // "help"
help.length; // 2

函数的length是其显式定义的参数数量,直到但不包括具有默认值定义的参数(例如,param = 42)或"剩余参数"(例如,...remainingOpts)。

避免设置函数对象属性

你应该避免在函数对象上分配属性。如果你想存储与函数关联的额外信息,请使用单独的Map(..)(或WeakMap(..))将函数对象作为键,将额外信息作为值。

extraInfo = new Map();

extraInfo.set(help, '这是一些重要信息');

// 稍后:
extraInfo.get(help); // "这是一些重要信息"

对象特征

除了为特定属性定义行为外,某些行为可以在整个对象范围内进行配置:

  • 可扩展性
  • 密封
  • 冻结

可扩展性

可扩展性指的是对象是否可以定义/添加新属性。默认情况下,所有对象都是可扩展的,但你可以关闭对象的可扩展性:

myObj = {
	favoriteNumber: 42,
};

myObj.firstName = 'Kyle'; // 正常工作

Object.preventExtensions(myObj);

myObj.nicknames = ['getify', 'ydkjs']; // 失败
myObj.favoriteNumber = 123; // 正常工作

在非严格模式下,创建新属性的赋值将静默失败,而在严格模式下将抛出异常。

扩展 MOP

如本章开头所述,JS 中的对象行为遵循一组称为元对象协议(MOP)的规则。现在我们更充分地理解了对象默认如何工作,我们想把注意力转向如何挂钩一些这些默认行为并覆盖/自定义它们。

[[Prototype]]链

对象的一个最重要但最不明显的特征(MOP 的一部分)被称为其"原型链";官方 JS 规范表示法是[[Prototype]]。确保不要将这个[[Prototype]]与名为prototype的公共属性混淆。尽管命名相似,但这些是不同的概念。

[[Prototype]]是对象在创建时默认获得的内部链接,指向另一个对象。这种链接是对象的一个隐藏的、通常很微妙的特征,但它对对象的交互方式产生深远的影响。之所以称之为"链",是因为一个对象链接到另一个对象,而后者又链接到另一个对象,以此类推。

这个链条有一个结束或顶端,在那里链接停止,无法继续。稍后我们会详细讨论这一点。

我们在第 1 章中已经看到了[[Prototype]]链接的几个含义。例如,默认情况下,所有对象都通过[[Prototype]]链接到名为Object.prototype的内置对象。

警告:
Object.prototype这个名称本身可能会令人困惑,因为它使用了一个名为prototype的属性。[[Prototype]]和prototype是如何关联的!?暂时把这些问题/困惑搁置一边,因为我们稍后会在本章中回来解释[[Prototype]]和prototype之间的区别。目前,只需假设存在这个重要但命名奇怪的内置对象Object.prototype。

让我们考虑一些代码:

myObj = {
	favoriteNumber: 42,
};

这应该看起来很熟悉,来自第 1 章。但你在这段代码中看不到的是,那里的对象通过其内部[[Prototype]]自动链接到那个自动内置的、但命名奇怪的Object.prototype对象。

当我们做这样的事情时:

myObj.toString(); // "[object Object]"

myObj.hasOwnProperty('favoriteNumber'); // true

我们正在利用这个内部[[Prototype]]链接,而没有真正意识到它。由于myObj没有定义toString或hasOwnProperty属性,这些属性访问实际上最终委托访问,继续沿着[[Prototype]]链进行查找。

由于myObj通过[[Prototype]]链接到名为Object.prototype的对象,对toString和hasOwnProperty属性的查找继续在该对象上进行;事实上,这些方法在那里被找到了!

myObj.toString能够访问toString属性,即使它实际上并没有这个属性,这通常被称为"继承",或更具体地说,"原型继承"。toString和hasOwnProperty属性,以及许多其他属性,被称为myObj上的"继承属性"。

注意:
我对这里使用"继承"一词有很多不满 -- 它应该被称为"委托"! -- 但这是大多数人称呼它的方式,所以我们会勉强遵从并暂时使用相同的术语(尽管是在抗议下,带有引号)。我会在本书的附录中保留我的异议。

Object.prototype有几个内置的属性和方法,所有这些都被任何通过[[Prototype]]链接到Object.prototype的对象"继承",无论是直接链接还是通过另一个对象的链接间接链接。

从Object.prototype"继承"的一些常见属性包括:

  • constructor
  • __proto__
  • toString()
  • valueOf()
  • hasOwnProperty(..)
  • isPrototypeOf(..)

回想一下hasOwnProperty(..),我们之前看到它给我们一个布尔检查,看某个属性(通过字符串名称)是否由对象拥有:

myObj = {
	favoriteNumber: 42,
};

myObj.hasOwnProperty('favoriteNumber'); // true

一直以来,人们认为将hasOwnProperty(..)这样一个重要的工具作为 Object [[Prototype]]链上的实例方法而不是定义为静态工具是有些不幸的(语义组织、命名冲突等)。

从 ES2022 开始,JS 终于添加了这个工具的静态版本:Object.hasOwn(..)。

myObj = {
	favoriteNumber: 42,
};

Object.hasOwn(myObj, 'favoriteNumber'); // true

这种形式现在被认为是更可取和更强大的选择,实例方法(hasOwnProperty(..))形式现在通常应该避免使用。

有些不幸和不一致的是,目前(在写作时)还没有相应的静态工具,比如Object.isPrototype(..)(而不是实例方法isPrototypeOf(..))。但至少Object.hasOwn(..)存在了,这是一个进步。

创建一个具有不同[[Prototype]]的对象

默认情况下,你在程序中创建的任何对象都会通过[[Prototype]]链接到那个Object.prototype对象。但是,你可以这样创建一个具有不同链接的对象:

myObj = Object.create(differentObj);

Object.create(..)方法将其第一个参数作为为新创建对象的[[Prototype]]设置的值。

这种方法的一个缺点是你没有使用{ .. }字面量语法,所以你最初没有为myObj定义任何内容。你通常然后必须使用=一个一个地定义属性。

注意:
Object.create(..)的第二个可选参数是 -- 像前面讨论的Object.defineProperties(..)的第二个参数一样 -- 一个对象,其属性持有描述符,用于初始定义新对象。在实践中,这种形式很少使用,可能是因为指定完整的描述符比只指定名称/值对更笨拙。但在某些有限的情况下它可能会派上用场。

另外,但不太可取的是,你可以使用{ .. }字面量语法以及一个特殊(而且看起来很奇怪!)的属性:

myObj = {
	__proto__: differentObj,

	// .. 对象定义的其余部分
};
警告:
看起来很奇怪的__proto__属性在一些 JS 引擎中存在了 20 多年,但直到 ES6(2015 年)才在 JS 中标准化。即便如此,它也是在规范的附录 B 中添加的[^specApB],其中列出了 TC39 勉强包含的特性,因为它们在各种基于浏览器的 JS 引擎中广泛存在,因此即使它们不是源于 TC39,也是事实上的现实。因此,规范"保证"这个特性存在于所有符合标准的基于浏览器的 JS 引擎中,但不一定保证在其他独立的 JS 引擎中工作。Node.js 使用 Chrome 浏览器的 JS 引擎(v8),所以 Node.js 默认/偶然地获得了__proto__。在使用__proto__时要小心,要意识到你的代码将在哪些 JS 引擎环境中运行。

无论你使用Object.create(..)还是__proto__,相关的创建对象通常会通过[[Prototype]]链接到一个不同于默认Object.prototype的对象。

空[[Prototype]]链接

我们上面提到[[Prototype]]链必须在某处停止,以便查找不会永远继续。Object.prototype通常是每个[[Prototype]]链的顶端/结束,因为它自己的[[Prototype]]是null,因此没有其他地方可以继续查找。

然而,你也可以定义自己的[[Prototype]]值为null的对象,例如:

emptyObj = Object.create(null);
// 或:emptyObj = { __proto__: null }

emptyObj.toString; // undefined

创建一个没有[[Prototype]]链接到Object.prototype的对象可能非常有用。例如,如第 1 章所述,in和for..in构造会查询[[Prototype]]链以获取继承的属性。但这可能是不希望的,因为你可能不希望像"toString" in myObj这样的东西成功解析。

此外,一个空[[Prototype]]的对象可以避免任何意外的"继承"冲突,即它自己的属性名称和它从其他地方"继承"的属性名称之间的冲突。这些类型的(有用的!)对象在流行的说法中有时被称为"字典对象"。

[[Prototype]]与prototype

注意到这个特殊对象Object.prototype的名称/位置中的公共属性名prototype了吗?这是怎么回事?

Object是Object(..)函数;默认情况下,所有函数(它们本身就是对象!)都有这样一个prototype属性,指向一个对象。

这就是[[Prototype]]和prototype之间的名称冲突真正咬到我们的地方。函数上的prototype属性并不定义函数本身经历的任何链接。事实上,函数(作为对象)有它们自己的内部[[Prototype]]链接到其他地方 -- 稍后会详细讨论。

相反,函数上的prototype属性指的是一个对象,该对象应该被任何其他对象链接到,这些对象是在使用new关键字调用该函数时创建的:

myObj = {};

// 基本上相同:
myObj = new Object();

由于{ .. }对象字面量语法本质上与new Object()调用相同,内置对象位于/命名为Object.prototype被用作我们创建并命名为myObj的新对象的内部[[Prototype]]值。

呼!仅仅因为[[Prototype]]和prototype之间的名称重叠,就使这个主题变得更加令人困惑!


但是函数本身(作为对象!)在[[Prototype]]方面链接到哪里?它们链接到Function.prototype,这是另一个内置对象,位于Function(..)函数的prototype属性上。

换句话说,你可以将函数本身想象为由new Function(..)调用"创建",然后通过[[Prototype]]链接到Function.prototype对象。这个对象包含所有函数默认"继承"的属性/方法,如toString()(将函数的源代码字符串序列化)和call(..) / apply(..) / bind(..)(我们将在本书后面解释这些)。

对象行为

对象上的属性在内部由"描述符"元对象定义和控制,该元对象包括诸如value(属性的当前值)和enumerable(控制属性是否包含在仅可枚举的属性/属性名列表中的布尔值)等属性。

JS 中对象及其属性的工作方式被称为"元对象协议"(MOP)。我们可以通过Object.defineProperty(..)精确控制属性的行为,以及通过Object.freeze(..)控制对象范围的行为。但更强大的是,我们可以使用特殊的预定义符号来挂钩并覆盖对象上的某些默认行为。

原型是对象之间的内部链接,允许对一个对象进行属性或方法访问 -- 如果请求的属性/方法不存在 -- 通过"委托"该访问查找到另一个对象来处理。当委托涉及方法时,方法运行的上下文通过this关键字从初始对象共享到目标对象。

Prev
对象基础