二十八、对象
原文:
exploringjs.com/impatient-js/ch_objects.html
译者:飞龙
-
28.1 速查表:对象
-
28.1.1 单个对象
-
28.1.2 原型链
-
-
28.2 什么是对象?
- 28.2.1 使用对象的两种方式
-
28.3 固定布局对象
-
28.3.1 对象文字:属性
-
28.3.2 对象文字:属性值简写
-
28.3.3 获取属性
-
28.3.4 设置属性
-
28.3.5 对象文字:方法
-
28.3.6 对象文字:访问器
-
-
28.4 扩展到对象文字(
...
)[ES2018]-
28.4.1 扩展的用例:复制对象
-
28.4.2 扩展的用例:缺失属性的默认值
-
28.4.3 扩展的用例:非破坏性地更改属性
-
28.4.4 “破坏性扩展”:
Object.assign()
[ES6]
-
-
28.5 方法和特殊变量
this
-
28.5.1 方法是其值为函数的属性
-
28.5.2 特殊变量
this
-
28.5.3 方法和
.call()
-
28.5.4 方法和
.bind()
-
28.5.5
this
陷阱:提取方法 -
28.5.6
this
陷阱:意外遮蔽this
-
28.5.7
this
在各种上下文中的值(高级)
-
-
28.6 可选链接用于属性获取和方法调用[ES2020](高级)
-
28.6.1 示例:可选的固定属性获取
-
28.6.2 更详细的操作符(高级)
-
28.6.3 可选属性获取的短路
-
28.6.4 可选链接:缺点和替代方案
-
28.6.5 常见问题
-
-
28.7 字典对象(高级)
-
28.7.1 对象文字中的引用键
-
28.7.2 对象文字中的计算键
-
28.7.3
in
运算符:是否存在具有给定键的属性? -
28.7.4 删除属性
-
28.7.5 可枚举性
-
28.7.6 通过
Object.keys()
等列出属性键 -
28.7.7 通过
Object.values()
列出属性值 -
28.7.9 属性被确定性地列出
-
28.7.11 使用对象作为字典的陷阱
-
-
28.8 属性特性和冻结对象(高级)
-
28.8.1 属性特性和属性描述符[ES5]
-
28.8.2 冻结对象[ES5]
-
-
28.9 原型链
-
28.9.1 JavaScript 的操作:所有属性 vs. 自有属性
-
28.9.2 陷阱:原型链中只有第一个成员被改变
-
28.9.3 使用原型的技巧(高级)
-
28.9.5 通过原型共享数据
-
-
28.10 FAQ:对象
- 28.10.1 为什么对象保留属性的插入顺序?
在本书中,JavaScript 的面向对象编程(OOP)风格分四步介绍。本章涵盖了第 1 步和第 2 步;下一章涵盖了第 3 步和第 4 步。这些步骤是(图 8):
-
单个对象(本章): 对象,JavaScript 的基本 OOP 构建块,在孤立状态下如何工作?
-
原型链(本章): 每个对象都有零个或多个原型对象链。原型是 JavaScript 的核心继承机制。
-
类(下一章): JavaScript 的类是对象的工厂。类与其实例之间的关系基于原型继承(第 2 步)。
-
子类(下一章): 子类 与 超类 之间的关系也是基于原型继承的。
图 8:本书分四步介绍 JavaScript 中的面向对象编程。
28.1 对象速查表
28.1.1 单个对象
通过对象字面量创建对象(以大括号开始和结束):
const myObject = { // object literal
myProperty: 1,
myMethod() {
return 2;
}, // comma!
get myAccessor() {
return this.myProperty;
}, // comma!
set myAccessor(value) {
this.myProperty = value;
}, // last comma is optional
};
assert.equal(
myObject.myProperty, 1
);
assert.equal(
myObject.myMethod(), 2
);
assert.equal(
myObject.myAccessor, 1
);
myObject.myAccessor = 3;
assert.equal(
myObject.myProperty, 3
);
能够直接创建对象(无需类)是 JavaScript 的一个亮点。
扩展为对象:
const original = {
a: 1,
b: {
c: 3,
},
};
// Spreading (...) copies one object “into” another one:
const modifiedCopy = {
...original, // spreading
d: 4,
};
assert.deepEqual(
modifiedCopy,
{
a: 1,
b: {
c: 3,
},
d: 4,
}
);
// Caveat: spreading copies shallowly (property values are shared)
modifiedCopy.a = 5; // does not affect `original`
modifiedCopy.b.c = 6; // affects `original`
assert.deepEqual(
original,
{
a: 1, // unchanged
b: {
c: 6, // changed
},
},
);
我们还可以使用扩展来制作对象的未修改(浅层)副本:
const exactCopy = {...obj};
28.1.2 原型链
原型是 JavaScript 的基本继承机制。甚至类也是基于它构建的。每个对象的原型都是null
或一个对象。后者的对象也可以有原型,依此类推。通常,我们得到原型的链。
原型的管理方式如下:
// `obj1` has no prototype (its prototype is `null`)
const obj1 = Object.create(null); // (A)
assert.equal(
Object.getPrototypeOf(obj1), null // (B)
);
// `obj2` has the prototype `proto`
const proto = {
protoProp: 'protoProp',
};
const obj2 = {
__proto__: proto, // (C)
objProp: 'objProp',
}
assert.equal(
Object.getPrototypeOf(obj2), proto
);
注:
-
在创建对象时设置对象的原型:A 行,C 行
-
检索对象的原型:B 行
每个对象都继承其原型的所有属性:
// `obj2` inherits .protoProp from `proto`
assert.equal(
obj2.protoProp, 'protoProp'
);
assert.deepEqual(
Reflect.ownKeys(obj2),
['objProp'] // own properties of `obj2`
);
对象的非继承属性称为其自有属性。
原型最重要的用例是多个对象可以通过从共同原型继承方法来共享它们。
28.2 什么是对象?
JavaScript 中的对象:
-
对象是一组槽(键值条目)。
-
公共槽称为属性:
- 属性键只能是字符串或符号。
-
私有槽只能通过类创建,并在§29.2.4“公共槽(属性)vs. 私有槽”中进行了解。
28.2.1 使用对象的两种方式
在 JavaScript 中有两种使用对象的方式:
-
固定布局对象:以这种方式使用,对象就像数据库中的记录一样工作。它们具有固定数量的属性,其键在开发时已知。它们的值通常具有不同的类型。
const fixedLayoutObject = { product: 'carrot', quantity: 4, };
-
字典对象:以这种方式使用,对象就像查找表或映射一样。它们具有可变数量的属性,其键在开发时未知。它们的所有值都具有相同的类型。
const dictionaryObject = { ['one']: 1, ['two']: 2, };
请注意,这两种方式也可以混合使用:有些对象既是固定布局对象,又是字典对象。
使用对象的方式会影响它们在本章中的解释:
-
首先,我们将探索固定布局对象。即使属性键在底层是字符串或符号,它们对我们来说将显示为固定标识符。
-
稍后,我们将探索字典对象。请注意,Maps 通常比对象更好地充当字典。但是,我们将遇到的一些操作对于固定布局对象也很有用。
28.3 固定布局对象
让我们首先探索固定布局对象。
28.3.1 对象文字:属性
对象文字是创建固定布局对象的一种方式。它们是 JavaScript 的一个突出特点:我们可以直接创建对象-无需类!这是一个例子:
const jane = {
first: 'Jane',
last: 'Doe', // optional trailing comma
};
在示例中,我们通过对象文字创建了一个对象,它以大括号{}
开头和结尾。在其中,我们定义了两个属性(键值条目):
-
第一个属性的键是
first
,值为'Jane'
。 -
第二个属性的键是
last
,值为'Doe'
。
自 ES5 以来,对象文字中允许使用尾随逗号。
我们将在以后看到指定属性键的其他方法,但是使用这种指定方式,它们必须遵循 JavaScript 变量名称的规则。例如,我们可以使用first_name
作为属性键,但不能使用first-name
)。但是,保留字是允许的。
const obj = {
if: true,
const: true,
};
为了检查各种操作对对象的影响,我们将在本章的这一部分偶尔使用Object.keys()
。它列出属性键:
> Object.keys({a:1, b:2})
[ 'a', 'b' ]
28.3.2 对象文字:属性值简写
每当属性的值是通过与键同名的变量定义的时候,我们可以省略键。
function createPoint(x, y) {
return {x, y}; // Same as: {x: x, y: y}
}
assert.deepEqual(
createPoint(9, 2),
{ x: 9, y: 2 }
);
28.3.3 获取属性
这是我们获取(读取)属性的方式(A 行):
const jane = {
first: 'Jane',
last: 'Doe',
};
// Get property .first
assert.equal(jane.first, 'Jane'); // (A)
获取未知属性会产生undefined
:
assert.equal(jane.unknownProperty, undefined);
28.3.4 设置属性
这是我们设置(写入)属性的方式(A 行):
const obj = {
prop: 1,
};
assert.equal(obj.prop, 1);
obj.prop = 2; // (A)
assert.equal(obj.prop, 2);
我们刚刚通过设置更改了现有属性。如果我们设置一个未知的属性,我们将创建一个新条目:
const obj = {}; // empty object
assert.deepEqual(
Object.keys(obj), []);
obj.unknownProperty = 'abc';
assert.deepEqual(
Object.keys(obj), ['unknownProperty']);
28.3.5 对象文字:方法
以下代码显示了如何通过对象文字创建方法.says()
:
const jane = {
first: 'Jane', // value property
says(text) { // method
return `${this.first} says “${text}”`; // (A)
}, // comma as separator (optional at end)
};
assert.equal(jane.says('hello'), 'Jane says “hello”');
在方法调用jane.says('hello')
期间,jane
被称为方法调用的接收者,并分配给特殊变量this
(有关this
的更多信息,请参见§28.5“方法和特殊变量this
”)。这使得方法.says()
能够访问 A 行中的兄弟属性.first
。
28.3.6 对象文字:访问器
通过对象文字内部的语法定义访问器,看起来像方法:getter和/或setter(即,每个访问器都有一个或两个)。
调用访问器看起来像访问值属性:
-
读取属性会调用 getter。
-
写入属性会调用 setter。
28.3.6.1 获取器
通过在方法定义前加上修饰符get
来创建 getter:
const jane = {
first: 'Jane',
last: 'Doe',
get full() {
return `${this.first} ${this.last}`;
},
};
assert.equal(jane.full, 'Jane Doe');
jane.first = 'John';
assert.equal(jane.full, 'John Doe');
28.3.6.2 设置器
通过在方法定义前加上修饰符set
来创建 setter:
const jane = {
first: 'Jane',
last: 'Doe',
set full(fullName) {
const parts = fullName.split(' ');
this.first = parts[0];
this.last = parts[1];
},
};
jane.full = 'Richard Roe';
assert.equal(jane.first, 'Richard');
assert.equal(jane.last, 'Roe');
练习:通过对象文字创建对象
exercises/objects/color_point_object_test.mjs
28.4 扩展到对象文字(...
)[ES2018]
在对象文字中,扩展属性将另一个对象的属性添加到当前对象中:
> const obj = {one: 1, two: 2};
> {...obj, three: 3}
{ one: 1, two: 2, three: 3 }
const obj1 = {one: 1, two: 2};
const obj2 = {three: 3};
assert.deepEqual(
{...obj1, ...obj2, four: 4},
{one: 1, two: 2, three: 3, four: 4}
);
如果属性键冲突,最后提到的属性将“获胜”:
> const obj = {one: 1, two: 2, three: 3};
> {...obj, one: true}
{ one: true, two: 2, three: 3 }
> {one: true, ...obj}
{ one: 1, two: 2, three: 3 }
所有值都是可扩展的,甚至undefined
和null
:
> {...undefined}
{}
> {...null}
{}
> {...123}
{}
> {...'abc'}
{ '0': 'a', '1': 'b', '2': 'c' }
> {...['a', 'b']}
{ '0': 'a', '1': 'b' }
字符串和数组的属性.length
被隐藏在这种操作中(它不是可枚举的;有关更多信息,请参见§28.8.1“属性属性和属性描述符[ES5]”)。
扩展包括其键为符号的属性(这些符号被Object.keys()
,Object.values()
和Object.entries()
忽略):
const symbolKey = Symbol('symbolKey');
const obj = {
stringKey: 1,
[symbolKey]: 2,
};
assert.deepEqual(
{...obj, anotherStringKey: 3},
{
stringKey: 1,
[symbolKey]: 2,
anotherStringKey: 3,
}
);
28.4.1 扩展的用例:复制对象
我们可以使用扩展来创建对象original
的副本:
const copy = {...original};
注意 - 复制是浅层的:copy
是一个全新的对象,其中包含original
的所有属性(键值条目)的副本。但是,如果属性值是对象,则这些对象本身不会被复制;它们在original
和copy
之间共享。让我们看一个例子:
const original = { a: 1, b: {prop: true} };
const copy = {...original};
copy
的第一级确实是一个副本:如果我们更改该级别的任何属性,它不会影响原始对象:
copy.a = 2;
assert.deepEqual(
original, { a: 1, b: {prop: true} }); // no change
但是,深层次的内容不会被复制。例如,.b
的值在原始对象和副本之间共享。在副本中更改.b
也会在原始对象中更改它。
copy.b.prop = false;
assert.deepEqual(
original, { a: 1, b: {prop: false} });
JavaScript 没有内置支持深复制
对象的深复制(其中所有级别都被复制)通常很难以通用方式实现。因此,JavaScript 目前没有内置操作。如果我们需要这样的操作,我们必须自己实现它。
28.4.2 扩展的用例:缺少属性的默认值
如果我们的代码的输入之一是具有数据的对象,则可以通过指定默认值使属性变为可选,如果缺少这些属性,则使用这些默认值。其中一种方法是通过包含默认值的对象。在下面的示例中,该对象是DEFAULTS
:
const DEFAULTS = {alpha: 'a', beta: 'b'};
const providedData = {alpha: 1};
const allData = {...DEFAULTS, ...providedData};
assert.deepEqual(allData, {alpha: 1, beta: 'b'});
结果对象allData
是通过复制DEFAULTS
并用providedData
的属性覆盖其属性而创建的。
但是我们不需要对象来指定默认值;我们也可以在对象文字中单独指定它们:
const providedData = {alpha: 1};
const allData = {alpha: 'a', beta: 'b', ...providedData};
assert.deepEqual(allData, {alpha: 1, beta: 'b'});
28.4.3 扩展的用例:非破坏性地更改属性
到目前为止,我们已经遇到了一种更改对象的属性.alpha
的方法:我们设置它(A 行)并改变对象。也就是说,这种更改属性的方式是破坏性的。
const obj = {alpha: 'a', beta: 'b'};
obj.alpha = 1; // (A)
assert.deepEqual(obj, {alpha: 1, beta: 'b'});
通过扩展,我们可以非破坏性地更改.alpha
- 我们复制了obj
的副本,其中.alpha
具有不同的值:
const obj = {alpha: 'a', beta: 'b'};
const updatedObj = {...obj, alpha: 1};
assert.deepEqual(updatedObj, {alpha: 1, beta: 'b'});
练习:通过扩展(固定键)非破坏性地更新属性
exercises/objects/update_name_test.mjs
28.4.4 “破坏性扩展”:Object.assign()
[ES6]
Object.assign()
是一个工具方法:
Object.assign(target, source_1, source_2, ···)
此表达式将source_1
的所有属性分配给target
,然后将source_2
的所有属性等。最后,它返回target
- 例如:
const target = { a: 1 };
const result = Object.assign(
target,
{b: 2},
{c: 3, b: true});
assert.deepEqual(
result, { a: 1, b: true, c: 3 });
// target was modified and returned:
assert.equal(result, target);
Object.assign()
的用例与扩展属性的用例类似。在某种程度上,它是破坏性地扩展。
28.5 方法和特殊变量this
28.5.1 方法是其值为函数的属性
让我们重新访问用于介绍方法的示例:
const jane = {
first: 'Jane',
says(text) {
return `${this.first} says “${text}”`;
},
};
有些令人惊讶的是,方法就是函数:
assert.equal(typeof jane.says, 'function');
为什么?我们在可调用值章节中学到,普通函数扮演了几种角色。方法是其中之一。因此,在内部,jane
大致如下。
const jane = {
first: 'Jane',
says: function (text) {
return `${this.first} says “${text}”`;
},
};
28.5.2 特殊变量this
考虑以下代码:
const obj = {
someMethod(x, y) {
assert.equal(this, obj); // (A)
assert.equal(x, 'a');
assert.equal(y, 'b');
}
};
obj.someMethod('a', 'b'); // (B)
在 B 行,obj
是方法调用的接收者。它通过一个隐式(隐藏)参数传递给存储在obj.someMethod
中的函数,其名称为this
(A 行)。
如何理解
this
理解this
的最佳方法是将其视为普通函数(因此也是方法)的隐式参数。
28.5.3 方法和.call()
方法是函数,函数本身也有方法。其中之一是.call()
。让我们看一个例子来了解这个方法是如何工作的。
在上一节中,有这种方法调用:
obj.someMethod('a', 'b')
这种调用等同于:
obj.someMethod.call(obj, 'a', 'b');
这也等同于:
const func = obj.someMethod;
func.call(obj, 'a', 'b');
.call()
使通常隐含的参数this
变得显式:当通过.call()
调用函数时,第一个参数是this
,然后是常规(显式)函数参数。
顺便说一句,这意味着实际上有两个不同的点运算符:
-
一个用于访问属性:
obj.prop
-
另一个用于调用方法:
obj.prop()
它们的不同之处在于(2)不仅仅是(1)后面跟着函数调用运算符()
。相反,(2)还提供了this
的值。
28.5.4 方法和.bind()
.bind()
是函数对象的另一个方法。在下面的代码中,我们使用.bind()
将方法.says()
转换为独立的函数func()
:
const jane = {
first: 'Jane',
says(text) {
return `${this.first} says “${text}”`; // (A)
},
};
const func = jane.says.bind(jane, 'hello');
assert.equal(func(), 'Jane says “hello”');
通过.bind()
将this
设置为jane
在这里至关重要。否则,func()
将无法正常工作,因为this
在 A 行中使用。在下一节中,我们将探讨为什么会这样。
28.5.5 this
陷阱:提取方法
我们现在对函数和方法有了相当多的了解,并且准备好看看涉及方法和this
的最大陷阱:如果我们不小心,从对象中提取的方法进行函数调用可能会失败。
在以下示例中,当我们提取方法jane.says()
,将其存储在变量func
中,并调用func
时,我们失败了。
const jane = {
first: 'Jane',
says(text) {
return `${this.first} says “${text}”`;
},
};
const func = jane.says; // extract the method
assert.throws(
() => func('hello'), // (A)
{
name: 'TypeError',
message: "Cannot read properties of undefined (reading 'first')",
});
在 A 行,我们正在进行普通函数调用。在普通函数调用中,this
是undefined
(如果严格模式激活,几乎总是激活的)。因此,A 行等价于:
assert.throws(
() => jane.says.call(undefined, 'hello'), // `this` is undefined!
{
name: 'TypeError',
message: "Cannot read properties of undefined (reading 'first')",
}
);
我们如何解决这个问题?我们需要使用.bind()
来提取方法.says()
:
const func2 = jane.says.bind(jane);
assert.equal(func2('hello'), 'Jane says “hello”');
.bind()
确保我们调用func()
时this
始终是jane
。
我们还可以使用箭头函数来提取方法:
const func3 = text => jane.says(text);
assert.equal(func3('hello'), 'Jane says “hello”');
28.5.5.1 示例:提取一个方法
以下是我们在实际网页开发中可能看到的代码的简化版本:
class ClickHandler {
constructor(id, elem) {
this.id = id;
elem.addEventListener('click', this.handleClick); // (A)
}
handleClick(event) {
alert('Clicked ' + this.id);
}
}
在 A 行,我们没有正确提取方法.handleClick()
。相反,我们应该这样做:
const listener = this.handleClick.bind(this);
elem.addEventListener('click', listener);
// Later, possibly:
elem.removeEventListener('click', listener);
每次调用.bind()
都会创建一个新函数。这就是为什么如果我们想要稍后删除它,就需要将结果存储在某个地方。
28.5.5.2 如何避免提取方法的陷阱
遗憾的是,没有简单的方法可以避免提取方法的陷阱:每当我们提取一个方法时,都必须小心并正确地处理它 - 例如,通过绑定this
或使用箭头函数。
练习:提取一个方法
exercises/objects/method_extraction_exrc.mjs
28.5.6 this
陷阱:意外遮蔽this
意外遮蔽
this
只是普通函数的问题
箭头函数不会遮蔽this
。
考虑以下问题:当我们在普通函数内部时,我们无法访问周围范围的this
,因为普通函数有它自己的this
。换句话说,内部作用域中的变量隐藏了外部作用域中的变量。这就是所谓的遮蔽。以下代码是一个例子:
const prefixer = {
prefix: '==> ',
prefixStringArray(stringArray) {
return stringArray.map(
function (x) {
return this.prefix + x; // (A)
});
},
};
assert.throws(
() => prefixer.prefixStringArray(['a', 'b']),
{
name: 'TypeError',
message: "Cannot read properties of undefined (reading 'prefix')",
}
);
在 A 行,我们想要访问.prefixStringArray()
的this
。但我们不能,因为周围的普通函数有它自己的this
,遮蔽了(并阻止访问)方法的this
。前者的this
的值是undefined
,因为回调函数被函数调用。这解释了错误消息。
修复这个问题的最简单方法是使用箭头函数,它没有自己的this
,因此不会遮蔽任何东西:
const prefixer = {
prefix: '==> ',
prefixStringArray(stringArray) {
return stringArray.map(
(x) => {
return this.prefix + x;
});
},
};
assert.deepEqual(
prefixer.prefixStringArray(['a', 'b']),
['==> a', '==> b']);
我们也可以将this
存储在不同的变量中(A 行),这样它就不会被遮蔽:
prefixStringArray(stringArray) {
const that = this; // (A)
return stringArray.map(
function (x) {
return that.prefix + x;
});
},
另一个选择是通过.bind()
(第 A 行)为回调函数指定一个固定的this
:
prefixStringArray(stringArray) {
return stringArray.map(
function (x) {
return this.prefix + x;
}.bind(this)); // (A)
},
最后,.map()
让我们指定一个值作为this
(A 行),在调用回调函数时使用:
prefixStringArray(stringArray) {
return stringArray.map(
function (x) {
return this.prefix + x;
},
this); // (A)
},
28.5.6.1 避免意外遮蔽this
的陷阱
如果我们遵循§25.3.4“建议:优先使用专门的函数而不是普通函数”中的建议,我们可以避免意外遮蔽this
的陷阱。这是一个总结:
-
使用箭头函数作为匿名内联函数。它们没有
this
作为隐式参数,也不会遮蔽它。 -
对于命名的独立函数声明,我们可以使用箭头函数或函数声明。如果我们选择后者,就必须确保它们的主体中没有提到
this
。
28.5.7 各种上下文中this
的值(高级)
在各种上下文中this
的值是多少?
在可调用实体内,this
的值取决于可调用实体的调用方式和可调用实体的类型:
-
函数调用:
-
普通函数:
this === undefined
(在严格模式下) -
箭头函数:
this
与周围作用域相同(词法this
)
-
-
方法调用:
this
是调用的接收者 -
new
:this
指的是新创建的实例
我们还可以在所有常见的顶层作用域中访问this
:
-
<script>
元素:this === globalThis
-
ECMAScript 模块:
this === undefined
-
CommonJS 模块:
this === module.exports
提示:假装顶层作用域中不存在
this
我喜欢这样做是因为顶层的this
很令人困惑,而且对于它的(少数)用例有更好的替代方案。
28.6 可选链用于属性获取和方法调用[ES2020](高级)
存在以下种类的可选链操作:
obj?.prop // optional fixed property getting
obj?.[«expr»] // optional dynamic property getting
func?.(«arg0», «arg1») // optional function or method call
大致的想法是:
-
如果问号前的值既不是
undefined
也不是null
,那么执行问号后的操作。 -
否则,返回
undefined
。
这三种语法的每一种都会在后面更详细地介绍。以下是一些最初的例子:
> null?.prop
undefined
> {prop: 1}?.prop
1
> null?.(123)
undefined
> String?.(123)
'123'
28.6.1 示例:可选的固定属性获取
考虑以下数据:
const persons = [
{
surname: 'Zoe',
address: {
street: {
name: 'Sesame Street',
number: '123',
},
},
},
{
surname: 'Mariner',
},
{
surname: 'Carmen',
address: {
},
},
];
我们可以使用可选链来安全地提取街道名称:
const streetNames = persons.map(
p => p.address?.street?.name);
assert.deepEqual(
streetNames, ['Sesame Street', undefined, undefined]
);
28.6.1.1 通过 nullish coalescing 处理默认值
nullish coalescing operator 允许我们使用默认值'(no name)'
而不是undefined
:
const streetNames = persons.map(
p => p.address?.street?.name ?? '(no name)');
assert.deepEqual(
streetNames, ['Sesame Street', '(no name)', '(no name)']
);
28.6.2 更详细的操作符(高级)
28.6.2.1 可选的固定属性获取
以下两个表达式是等价的:
o?.prop
(o !== undefined && o !== null) ? o.prop : undefined
例子:
assert.equal(undefined?.prop, undefined);
assert.equal(null?.prop, undefined);
assert.equal({prop:1}?.prop, 1);
28.6.2.2 可选的动态属性获取
以下两个表达式是等价的:
o?.[«expr»]
(o !== undefined && o !== null) ? o[«expr»] : undefined
例子:
const key = 'prop';
assert.equal(undefined?.[key], undefined);
assert.equal(null?.[key], undefined);
assert.equal({prop:1}?.[key], 1);
28.6.2.3 可选的函数或方法调用
以下两个表达式是等价的:
f?.(arg0, arg1)
(f !== undefined && f !== null) ? f(arg0, arg1) : undefined
例子:
assert.equal(undefined?.(123), undefined);
assert.equal(null?.(123), undefined);
assert.equal(String?.(123), '123');
请注意,如果可选链的左侧不可调用,则此运算符会产生错误:
assert.throws(
() => true?.(123),
TypeError);
为什么?这个想法是,该运算符只容忍有意的遗漏。一个不可调用的值(除了undefined
和null
之外)可能是一个错误,应该报告而不是绕过。
28.6.3 可选属性获取的短路
在一系列属性获取和方法调用中,一旦第一个可选运算符在其左侧遇到undefined
或null
,评估就会停止:
function invokeM(value) {
return value?.a.b.m(); // (A)
}
const obj = {
a: {
b: {
m() { return 'result' }
}
}
};
assert.equal(
invokeM(obj), 'result'
);
assert.equal(
invokeM(undefined), undefined // (B)
);
在 B 行中考虑invokeM(undefined)
:undefined?.a
是undefined
。因此我们期望 A 行中的.b
失败。但实际上并不是:?.
运算符遇到值undefined
,整个表达式的评估立即返回undefined
。
这种行为不同于普通运算符,JavaScript 总是在评估运算符之前评估所有操作数。这被称为短路。其他短路运算符包括:
-
(a && b)
: 只有在a
为真时才评估b
。 -
(a || b)
: 只有在a
为假时才评估b
。 -
(c ? t : e)
: 如果c
为真,则评估t
。否则,评估e
。
28.6.4 可选链:缺点和替代方案
可选链也有缺点:
-
深度嵌套的结构更难管理。例如,如果有许多属性名称序列,重构会更加困难:每个序列都会强制多个对象的结构。
-
在访问数据时如此宽容会隐藏问题,这些问题将在后来显现,并且更难以调试。例如,早期出现的可选属性名称序列中的拼写错误会产生比正常拼写错误更多的负面影响。
可选链的另一种方法是在一个位置提取信息一次:
-
我们可以编写一个辅助函数来提取数据。
-
或者我们可以编写一个函数,其输入是深度嵌套的数据,输出是更简单、规范化的数据。
通过任一方法,都可以进行检查并在出现问题时提前失败。
进一步阅读:
28.6.5 经常问的问题
28.6.5.1 可选链操作符(?.
)的好记忆法是什么?
您是否偶尔不确定可选链操作符是以点号(.?
)还是问号(?.
)开始的?那么这个记忆法可能会帮助您:
-
IF(
?
)左侧不是 nullish -
THEN(
.
)访问属性。
28.6.5.2 为什么 o?.[x]
和 f?.()
中有点号?
以下两个可选操作符的语法并不理想:
obj?.[«expr»] // better: obj?[«expr»]
func?.(«arg0», «arg1») // better: func?(«arg0», «arg1»)
不幸的是,不够优雅的语法是必要的,因为区分理想的语法(第一个表达式)和条件运算符(第二个表达式)太复杂了:
obj?['a', 'b', 'c'].map(x => x+x)
obj ? ['a', 'b', 'c'].map(x => x+x) : []
28.6.5.3 为什么 null?.prop
的计算结果是 undefined
而不是 null
?
操作符 ?.
主要关注其右侧:属性 .prop
存在吗?如果不存在,就提前停止。因此,保留左侧的信息很少有用。然而,只有一个“提前终止”值确实简化了事情。
28.7 字典对象(高级)
对象最适合作为固定布局的对象。但在 ES6 之前,JavaScript 没有字典的数据结构(ES6 带来了 Maps)。因此,对象必须被用作字典,这带来了一个重要的限制:字典键必须是字符串(ES6 也引入了符号)。
首先,我们看一下与字典相关的对象的特性,但也适用于固定布局的对象。本节以实际使用对象作为字典的提示结束。(提示:如果可能的话,最好使用 Maps。)
28.7.1 对象字面量中的引用键
到目前为止,我们一直使用固定布局的对象。属性键是固定的标记,必须是有效的标识符,并在内部变为字符串:
const obj = {
mustBeAnIdentifier: 123,
};
// Get property
assert.equal(obj.mustBeAnIdentifier, 123);
// Set property
obj.mustBeAnIdentifier = 'abc';
assert.equal(obj.mustBeAnIdentifier, 'abc');
作为下一步,我们将超越属性键的这种限制:在本小节中,我们将使用任意固定字符串作为键。在下一小节中,我们将动态计算键。
两种语法使我们能够使用任意字符串作为属性键。
首先,在通过对象字面量创建属性键时,我们可以引用属性键(使用单引号或双引号):
const obj = {
'Can be any string!': 123,
};
其次,在获取或设置属性时,我们可以使用带有字符串的方括号:
// Get property
assert.equal(obj['Can be any string!'], 123);
// Set property
obj['Can be any string!'] = 'abc';
assert.equal(obj['Can be any string!'], 'abc');
我们也可以使用这些语法来定义方法:
const obj = {
'A nice method'() {
return 'Yes!';
},
};
assert.equal(obj['A nice method'](), 'Yes!');
28.7.2 对象字面量中的计算键
在上一小节中,属性键是通过对象字面量中的固定字符串指定的。在本节中,我们将学习如何动态计算属性键。这使我们能够使用任意字符串或符号。
对象字面量中动态计算的属性键的语法受到动态访问属性的启发。也就是说,我们可以使用方括号来包装表达式:
const obj = {
['Hello world!']: true,
['p'+'r'+'o'+'p']: 123,
[Symbol.toStringTag]: 'Goodbye', // (A)
};
assert.equal(obj['Hello world!'], true);
assert.equal(obj.prop, 123);
assert.equal(obj[Symbol.toStringTag], 'Goodbye');
计算键的主要用例是将符号作为属性键(A 行)。
请注意,用于获取和设置属性的方括号操作符可以使用任意表达式:
assert.equal(obj['p'+'r'+'o'+'p'], 123);
assert.equal(obj['==> prop'.slice(4)], 123);
方法也可以有计算属性键:
const methodKey = Symbol();
const obj = {
[methodKey]() {
return 'Yes!';
},
};
assert.equal(obj[methodKey](), 'Yes!');
在本章的其余部分,我们将主要再次使用固定属性键(因为它们在语法上更方便)。但所有特性也适用于任意字符串和符号。
练习:通过展开(计算键)非破坏性地更新属性
exercises/objects/update_property_test.mjs
28.7.3 in
运算符:是否存在具有给定键的属性?
in
运算符检查对象是否具有具有给定键的属性:
const obj = {
alpha: 'abc',
beta: false,
};
assert.equal('alpha' in obj, true);
assert.equal('beta' in obj, true);
assert.equal('unknownKey' in obj, false);
28.7.3.1 通过真值检查属性是否存在
我们也可以使用真值检查来确定属性是否存在:
assert.equal(
obj.alpha ? 'exists' : 'does not exist',
'exists');
assert.equal(
obj.unknownKey ? 'exists' : 'does not exist',
'does not exist');
前面的检查有效是因为obj.alpha
是真值,并且读取一个不存在的属性会返回undefined
(假值)。
然而,有一个重要的警告:如果属性存在但具有假值(undefined
、null
、false
、0
、""
等),真值检查会失败:
assert.equal(
obj.beta ? 'exists' : 'does not exist',
'does not exist'); // should be: 'exists'
28.7.4 删除属性
我们可以通过delete
运算符删除属性:
const obj = {
myProp: 123,
};
assert.deepEqual(Object.keys(obj), ['myProp']);
delete obj.myProp;
assert.deepEqual(Object.keys(obj), []);
28.7.5 可枚举性
可枚举性是属性的属性。一些操作会忽略不可枚举的属性,例如Object.keys()
和属性展开时。默认情况下,大多数属性是可枚举的。下一个例子展示了如何改变它以及它如何影响属性展开。
const enumerableSymbolKey = Symbol('enumerableSymbolKey');
const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');
// We create enumerable properties via an object literal
const obj = {
enumerableStringKey: 1,
[enumerableSymbolKey]: 2,
}
// For non-enumerable properties, we need a more powerful tool
Object.defineProperties(obj, {
nonEnumStringKey: {
value: 3,
enumerable: false,
},
[nonEnumSymbolKey]: {
value: 4,
enumerable: false,
},
});
// Non-enumerable properties are ignored by spreading:
assert.deepEqual(
{...obj},
{
enumerableStringKey: 1,
[enumerableSymbolKey]: 2,
}
);
Object.defineProperties()
在本章后面有解释。下一小节展示了这些操作如何受到可枚举性的影响:
28.7.6 通过Object.keys()
等列出属性键
表 19:列出自有(非继承的)属性键的标准库方法。它们都返回包含字符串和/或符号的数组。
可枚举 | 非可枚举 | 字符串 | 符号 | |
---|---|---|---|---|
Object.keys() | ✔ | ✔ | ||
Object.getOwnPropertyNames() | ✔ | ✔ | ✔ | |
Object.getOwnPropertySymbols() | ✔ | ✔ | ✔ | |
Reflect.ownKeys() | ✔ | ✔ | ✔ | ✔ |
tbl. 19 中的每个方法都返回一个参数的自有属性键的数组。在方法的名称中,我们可以看到以下区别:
-
属性键可以是字符串或符号。
-
属性名是其值为字符串的属性键。
-
属性符号是其值为符号的属性键。
为了演示这四个操作,我们重新访问上一小节的例子:
const enumerableSymbolKey = Symbol('enumerableSymbolKey');
const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');
const obj = {
enumerableStringKey: 1,
[enumerableSymbolKey]: 2,
}
Object.defineProperties(obj, {
nonEnumStringKey: {
value: 3,
enumerable: false,
},
[nonEnumSymbolKey]: {
value: 4,
enumerable: false,
},
});
assert.deepEqual(
Object.keys(obj),
['enumerableStringKey']
);
assert.deepEqual(
Object.getOwnPropertyNames(obj),
['enumerableStringKey', 'nonEnumStringKey']
);
assert.deepEqual(
Object.getOwnPropertySymbols(obj),
[enumerableSymbolKey, nonEnumSymbolKey]
);
assert.deepEqual(
Reflect.ownKeys(obj),
[
'enumerableStringKey', 'nonEnumStringKey',
enumerableSymbolKey, nonEnumSymbolKey,
]
);
28.7.7 通过Object.values()
列出属性值
Object.values()
列出对象的所有可枚举的字符串键属性的值:
const firstName = Symbol('firstName');
const obj = {
[firstName]: 'Jane',
lastName: 'Doe',
};
assert.deepEqual(
Object.values(obj),
['Doe']);
28.7.8 通过Object.entries()
列出属性条目[ES2017]
Object.entries()
列出所有可枚举的字符串键属性作为键值对。每对被编码为一个两元素数组:
const firstName = Symbol('firstName');
const obj = {
[firstName]: 'Jane',
lastName: 'Doe',
};
assert.deepEqual(
Object.entries(obj),
[
['lastName', 'Doe'],
]);
28.7.8.1 Object.entries()
的简单实现
下面的函数是Object.entries()
的简化版本:
function entries(obj) {
return Object.keys(obj)
.map(key => [key, obj[key]]);
}
练习:
Object.entries()
exercises/objects/find_key_test.mjs
28.7.9 属性被有确定性地列出
对象的自有(非继承的)属性总是按照以下顺序列出:
-
具有包含整数索引的字符串键的属性(包括数组索引):
按升序排列的数字顺序
-
剩余的具有字符串键的属性:
按照它们被添加的顺序
-
具有符号键的属性:
按照它们被添加的顺序
下面的例子演示了如何根据这些规则对属性键进行排序:
> Object.keys({b:0,a:0, 10:0,2:0})
[ '2', '10', 'b', 'a' ]
属性的顺序
ECMAScript 规范更详细地描述了属性的排序方式。
28.7.10 通过Object.fromEntries()
组装对象[ES2019]
给定一个[key, value]对的可迭代对象,Object.fromEntries()
创建一个对象:
const symbolKey = Symbol('symbolKey');
assert.deepEqual(
Object.fromEntries(
[
['stringKey', 1],
[symbolKey, 2],
]
),
{
stringKey: 1,
[symbolKey]: 2,
}
);
Object.fromEntries()
执行与Object.entries()
相反的操作。然而,Object.entries()
忽略了符号键属性,而Object.fromEntries()
则不会(见上一个例子)。
为了展示两者,我们将使用它们来实现库Underscore中的两个工具函数在接下来的子子部分中。
28.7.10.1 示例:pick()
Underscore 函数pick()
具有以下签名:
pick(object, ...keys)
它返回一个object
的副本,其中只有那些键在尾随参数中提到的属性:
const address = {
street: 'Evergreen Terrace',
number: '742',
city: 'Springfield',
state: 'NT',
zip: '49007',
};
assert.deepEqual(
pick(address, 'street', 'number'),
{
street: 'Evergreen Terrace',
number: '742',
}
);
我们可以实现pick()
如下:
function pick(object, ...keys) {
const filteredEntries = Object.entries(object)
.filter(([key, _value]) => keys.includes(key));
return Object.fromEntries(filteredEntries);
}
28.7.10.2 示例:invert()
Underscore 函数invert()
具有以下签名:
invert(object)
它返回一个object
的副本,其中所有属性的键和值被交换:
assert.deepEqual(
invert({a: 1, b: 2, c: 3}),
{1: 'a', 2: 'b', 3: 'c'}
);
我们可以这样实现invert()
:
function invert(object) {
const reversedEntries = Object.entries(object)
.map(([key, value]) => [value, key]);
return Object.fromEntries(reversedEntries);
}
28.7.10.3 Object.fromEntries()
的简单实现
以下函数是Object.fromEntries()
的简化版本:
function fromEntries(iterable) {
const result = {};
for (const [key, value] of iterable) {
let coercedKey;
if (typeof key === 'string' || typeof key === 'symbol') {
coercedKey = key;
} else {
coercedKey = String(key);
}
result[coercedKey] = value;
}
return result;
}
练习:使用
Object.entries()
和Object.fromEntries()
exercises/objects/omit_properties_test.mjs
28.7.11 使用对象作为字典的陷阱
如果我们使用普通对象(通过对象文字创建)作为字典,我们必须注意两个陷阱。
第一个陷阱是in
运算符也会找到继承的属性:
const dict = {};
assert.equal('toString' in dict, true);
我们希望dict
被视为空,但in
运算符检测到它从其原型Object.prototype
继承的属性。
第二个陷阱是我们不能使用属性键__proto__
,因为它具有特殊的功能(它设置对象的原型):
const dict = {};
dict['__proto__'] = 123;
// No property was added to dict:
assert.deepEqual(Object.keys(dict), []);
28.7.11.1 安全地使用对象作为字典
那么我们如何避免这两个陷阱呢?
-
如果可以的话,我们使用 Maps。它们是字典的最佳解决方案。
-
如果我们不能,我们使用一个对象作为字典的库,以防止我们犯错。
-
如果不可能或不希望这样做,我们使用一个没有原型的对象。
以下代码演示了如何使用没有原型的对象作为字典:
const dict = Object.create(null); // prototype is `null`
assert.equal('toString' in dict, false); // (A)
dict['__proto__'] = 123;
assert.deepEqual(Object.keys(dict), ['__proto__']);
我们避免了两个陷阱:
-
首先,没有原型的属性不继承任何属性(行 A)。
-
其次,在现代 JavaScript 中,
__proto__
是通过Object.prototype
实现的。这意味着如果原型链中没有Object.prototype
,它就会被关闭。
练习:使用对象作为字典
exercises/objects/simple_dict_test.mjs
28.8 属性属性和冻结对象(高级)
28.8.1 属性属性和属性描述符[ES5]
就像对象由属性组成一样,属性由属性组成。属性的值只是几个属性中的一个。其他包括:
-
writable
:是否可以更改属性的值? -
enumerable
:属性是否被Object.keys()
,扩展等考虑?
当我们使用一个操作来处理属性属性时,属性是通过属性描述符指定的:每个属性代表一个属性的对象。例如,这是我们如何读取属性obj.myProp
的属性:
const obj = { myProp: 123 };
assert.deepEqual(
Object.getOwnPropertyDescriptor(obj, 'myProp'),
{
value: 123,
writable: true,
enumerable: true,
configurable: true,
});
这就是我们如何更改obj.myProp
的属性:
assert.deepEqual(Object.keys(obj), ['myProp']);
// Hide property `myProp` from Object.keys()
// by making it non-enumerable
Object.defineProperty(obj, 'myProp', {
enumerable: false,
});
assert.deepEqual(Object.keys(obj), []);
进一步阅读:
-
可枚举性在本章的前面有更详细的介绍。
-
有关属性属性和属性描述符的更多信息,请参阅深入 JavaScript。
28.8.2 冻结对象[ES5]
Object.freeze(obj)
使obj
完全不可变:我们不能更改属性,添加属性,或更改其原型 - 例如:
const frozen = Object.freeze({ x: 2, y: 5 });
assert.throws(
() => { frozen.x = 7 },
{
name: 'TypeError',
message: /^Cannot assign to read only property 'x'/,
});
在幕后,Object.freeze()
改变属性(例如,使它们不可写)和对象(例如,使它们不可扩展,意味着不能再添加属性)的属性。
有一个警告:Object.freeze(obj)
只能浅冻结。也就是说,只有obj
的属性被冻结,而不是存储在属性中的对象。
更多信息
有关冻结和其他锁定对象的方法的更多信息,请参阅深入 JavaScript。
28.9 原型链
原型是 JavaScript 的唯一继承机制:每个对象都有一个原型,要么是null
,要么是一个对象。在后一种情况下,对象继承原型的所有属性。
在对象文字中,我们可以通过特殊属性__proto__
设置原型:
const proto = {
protoProp: 'a',
};
const obj = {
__proto__: proto,
objProp: 'b',
};
// obj inherits .protoProp:
assert.equal(obj.protoProp, 'a');
assert.equal('protoProp' in obj, true);
鉴于原型对象本身可以有一个原型,我们得到了一系列对象-所谓的原型链。继承给我们一种印象,即我们正在处理单个对象,但实际上我们正在处理对象链。
图 9 显示了obj
的原型链是什么样子。
图 9:obj
开始了一个包含proto
和其他对象的对象链。
非继承属性称为自有属性。obj
有一个自有属性.objProp
。
28.9.1 JavaScript 的操作:所有属性与自有属性
一些操作考虑所有属性(自有和继承的)-例如,获取属性:
> const obj = { one: 1 };
> typeof obj.one // own
'number'
> typeof obj.toString // inherited
'function'
其他操作只考虑自有属性-例如,Object.keys()
:
> Object.keys(obj)
[ 'one' ]
继续阅读另一个操作,该操作也只考虑自有属性:设置属性。
28.9.2 陷阱:只有原型链的第一个成员被改变
给定一个具有原型对象链的对象obj
,设置obj
的自有属性只改变obj
是有意义的。但是,通过obj
设置继承属性也只会改变obj
。它在obj
中创建一个新的自有属性,覆盖了继承属性。让我们看看如何在以下对象中工作:
const proto = {
protoProp: 'a',
};
const obj = {
__proto__: proto,
objProp: 'b',
};
在下一个代码片段中,我们设置了继承属性obj.protoProp
(A 行)。这通过创建自有属性“改变”了它:当读取obj.protoProp
时,首先找到自有属性,其值覆盖了继承属性的值。
// In the beginning, obj has one own property
assert.deepEqual(Object.keys(obj), ['objProp']);
obj.protoProp = 'x'; // (A)
// We created a new own property:
assert.deepEqual(Object.keys(obj), ['objProp', 'protoProp']);
// The inherited property itself is unchanged:
assert.equal(proto.protoProp, 'a');
// The own property overrides the inherited property:
assert.equal(obj.protoProp, 'x');
obj
的原型链如图所示。10。
图 10:obj
的自有属性.protoProp
覆盖了从proto
继承的属性。
28.9.3 处理原型的提示(高级)
28.9.3.1 获取和设置原型
__proto__
的建议:
-
不要将
__proto__
用作伪属性(Object
的所有实例的 setter):-
它不能与所有对象一起使用(例如不是
Object
的实例的对象)。 -
语言规范已经将其弃用。
有关此功能的更多信息,请参阅§29.8.7“
Object.prototype.__proto__
(访问器)”。 -
-
在对象文字中使用
__proto__
设置原型是不同的:这是对象文字的一个特性,没有陷阱。
获取和设置原型的推荐方法是:
-
获取对象的原型:
Object.getPrototypeOf(obj: Object) : Object
-
在创建对象时,设置对象的原型的最佳时间是。我们可以通过对象文字中的
__proto__
或通过以下方式来实现:Object.create(proto: Object) : Object
如果必须,我们可以使用
Object.setPrototypeOf()
来更改现有对象的原型。但这可能会对性能产生负面影响。
这就是这些特性的使用方式:
const proto1 = {};
const proto2a = {};
const proto2b = {};
const obj1 = {
__proto__: proto1,
a: 1,
b: 2,
};
assert.equal(Object.getPrototypeOf(obj1), proto1);
const obj2 = Object.create(
proto2a,
{
a: {
value: 1,
writable: true,
enumerable: true,
configurable: true,
},
b: {
value: 2,
writable: true,
enumerable: true,
configurable: true,
},
}
);
assert.equal(Object.getPrototypeOf(obj2), proto2a);
Object.setPrototypeOf(obj2, proto2b);
assert.equal(Object.getPrototypeOf(obj2), proto2b);
28.9.3.2 检查一个对象是否在另一个对象的原型链中
到目前为止,“proto
是obj
的原型”总是意味着“proto
是obj
的直接原型”。但它也可以更松散地使用,并意味着proto
在obj
的原型链中。可以通过.isPrototypeOf()
检查这种更松散的关系:
例如:
const a = {};
const b = {__proto__: a};
const c = {__proto__: b};
assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);
assert.equal(c.isPrototypeOf(a), false);
assert.equal(a.isPrototypeOf(a), false);
有关此方法的更多信息,请参见§29.8.5 “Object.prototype.isPrototypeOf()
”。
28.9.4 Object.hasOwn()
: 给定属性是否是自有的(非继承的)?[ES2022]
in
运算符(A 行)检查对象是否具有给定属性。相反,Object.hasOwn()
(B 行和 C 行)检查属性是否是自有的。
const proto = {
protoProp: 'protoProp',
};
const obj = {
__proto__: proto,
objProp: 'objProp',
}
assert.equal('protoProp' in obj, true); // (A)
assert.equal(Object.hasOwn(obj, 'protoProp'), false); // (B)
assert.equal(Object.hasOwn(proto, 'protoProp'), true); // (C)
ES2022 之前的替代方法:
.hasOwnProperty()
在 ES2022 之前,我们可以使用另一个特性:§29.8.8 “Object.prototype.hasOwnProperty()
”。这个特性有陷阱,但引用的部分解释了如何解决它们。
28.9.5 通过原型共享数据
考虑以下代码:
const jane = {
firstName: 'Jane',
describe() {
return 'Person named '+this.firstName;
},
};
const tarzan = {
firstName: 'Tarzan',
describe() {
return 'Person named '+this.firstName;
},
};
assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');
我们有两个非常相似的对象。两者都有两个名为.firstName
和.describe
的属性。另外,方法.describe()
是相同的。我们如何避免重复这个方法?
我们可以将它移到一个对象PersonProto
中,并使该对象成为jane
和tarzan
的原型:
const PersonProto = {
describe() {
return 'Person named ' + this.firstName;
},
};
const jane = {
__proto__: PersonProto,
firstName: 'Jane',
};
const tarzan = {
__proto__: PersonProto,
firstName: 'Tarzan',
};
原型的名称反映了jane
和tarzan
都是人。
图 11:对象jane
和tarzan
共享方法.describe()
,通过它们的共同原型PersonProto
。
图 11 说明了这三个对象是如何连接的:底部的对象现在包含了特定于jane
和tarzan
的属性。顶部的对象包含了它们之间共享的属性。
当我们调用方法jane.describe()
时,this
指向该方法调用的接收者jane
(在图表的左下角)。这就是为什么该方法仍然有效。tarzan.describe()
也是类似的。
assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');
展望下一章关于类的章节 - 这是类在内部组织的方式:
-
所有实例都共享一个带有方法的共同原型。
-
特定于实例的数据存储在每个实例的自有属性中。
§29.3 “类的内部”更详细地解释了这一点。
28.10 常见问题:对象
28.10.1 为什么对象保留属性的插入顺序?
原则上,对象是无序的。排序属性的主要原因是列出条目、键或值的操作是确定性的。这有助于例如测试。
测验
参见测验应用。
二十九、ES6 中的类
原文:
exploringjs.com/impatient-js/ch_classes.html
译者:飞龙
-
29.1 速查表:类
-
29.2 类的基本要点
-
29.2.1 一个人的类
-
29.2.2 类表达式
-
29.2.3 instanceof 运算符
-
29.2.4 公共槽(属性)vs. 私有槽
-
29.2.5 更详细的私有槽[ES2022](高级)
-
29.2.6 JavaScript 中类的优缺点
-
29.2.7 使用类的技巧
-
-
29.3 类的内部
-
29.3.1 类实际上是两个连接的对象
-
29.3.2 类设置其实例的原型链
-
29.3.3
.__proto__
vs..prototype
-
29.3.4
Person.prototype.constructor
(高级) -
29.3.5 分派 vs. 直接方法调用(高级)
-
29.3.6 类从普通函数演变而来(高级)
-
-
29.4 类的原型成员
-
29.4.1 公共原型方法和访问器
-
29.4.2 私有方法和访问器[ES2022]
-
-
29.5 类的实例成员[ES2022]
-
29.5.1 实例公共字段
-
29.5.2 实例私有字段
-
29.5.3 ES2022 之前的实例私有数据(高级)
-
29.5.4 通过 WeakMaps 模拟受保护的可见性和友元可见性(高级)
-
-
29.6 类的静态成员
-
29.6.1 静态公共方法和访问器
-
29.6.2 ES2022 中的静态公共字段
-
29.6.3 ES2022 中的静态私有方法、访问器和字段
-
29.6.4 类中的静态初始化块[ES2022]
-
29.6.5 陷阱:使用
this
访问静态私有字段 -
29.6.6 所有成员(静态的、原型的、实例的)都可以访问所有私有成员
-
29.6.7 ES2022 之前的静态私有方法和数据
-
29.6.8 静态工厂方法
-
-
29.7 子类化
-
29.7.1 子类化的内部(高级)
-
29.7.2
instanceof
和子类化(高级) -
29.7.3 并非所有对象都是
Object
的实例(高级) -
29.7.4 内置对象的原型链(高级)
-
29.7.5 混入类(高级)
-
-
29.8
Object.prototype
的方法和访问器(高级)-
29.8.1 安全使用
Object.prototype
方法 -
29.8.2
Object.prototype.toString()
-
29.8.3
Object.prototype.toLocaleString()
-
29.8.4
Object.prototype.valueOf()
-
29.8.5
Object.prototype.isPrototypeOf()
-
29.8.6
Object.prototype.propertyIsEnumerable()
-
29.8.7
Object.prototype.__proto__
(访问器) -
29.8.8
Object.prototype.hasOwnProperty()
-
-
29.9 常见问题:类
-
29.9.1 为什么本书中称其为“实例私有字段”,而不是“私有实例字段”?](ch_classes.html#why-are-they-called-instance-private-fields-in-this-book-and-not-private-instance-fields)
-
29.9.2 为什么标识符前缀是
#
?为什么不通过private
声明私有字段?](ch_classes.html#why-the-identifier-prefix-why-not-declare-private-fields-via-private)
-
在本书中,JavaScript 的面向对象编程(OOP)分为四个步骤介绍。本章涵盖了第 3 步和第 4 步,上一章涵盖了第 1 步和第 2 步。这些步骤如下(图 12):
-
单个对象(上一章): JavaScript 的基本 OOP 构建块 对象 在孤立状态下是如何工作的?
-
原型链(上一章): 每个对象都有零个或多个 原型对象 的链。原型是 JavaScript 的核心继承机制。
-
类(本章): JavaScript 的 类 是对象的工厂。类与其实例之间的关系基于原型继承(第 2 步)。
-
子类化(本章): 子类 与 超类 之间的关系也是基于原型继承的。
图 12:本书以四个步骤介绍 JavaScript 中的面向对象编程。
29.1 备忘单:类
超类:
class Person {
#firstName; // (A)
constructor(firstName) {
this.#firstName = firstName; // (B)
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
const tarzan = new Person('Tarzan');
assert.equal(
tarzan.describe(),
'Person named Tarzan'
);
assert.deepEqual(
Person.extractNames([tarzan, new Person('Cheeta')]),
['Tarzan', 'Cheeta']
);
子类:
class Employee extends Person {
constructor(firstName, title) {
super(firstName);
this.title = title; // (C)
}
describe() {
return super.describe() +
` (${this.title})`;
}
}
const jane = new Employee('Jane', 'CTO');
assert.equal(
jane.title,
'CTO'
);
assert.equal(
jane.describe(),
'Person named Jane (CTO)'
);
注意:
-
.#firstName
是一个 私有字段,必须在初始化之前(行 B)进行声明(行 A)。- 私有字段只能在其所在的类内部访问。甚至子类也无法访问它。
-
.title
是一个属性,可以在没有先前声明的情况下进行初始化(行 C)。JavaScript 相对经常地将实例数据公开(与例如 Java 相反,后者更喜欢隐藏它)。
29.2 课程的要点
类基本上是一种紧凑的语法,用于设置原型链(在上一章中有解释)。在底层,JavaScript 的类是非常规的。但是当我们使用它们时,我们很少看到这一点。它们通常应该对已经使用过其他面向对象编程语言的人来说是熟悉的。
请注意,我们不需要类来创建对象。我们也可以通过对象字面量来创建对象。这就是为什么 JavaScript 不需要单例模式,而类的使用比许多其他具有类的语言中少。
29.2.1 人的类
我们之前使用了 jane
和 tarzan
,它们是代表人的单个对象。让我们使用 类声明 来实现这样的对象的工厂:
class Person {
#firstName; // (A)
constructor(firstName) {
this.#firstName = firstName; // (B)
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
现在可以通过 new Person()
创建 jane
和 tarzan
:
const jane = new Person('Jane');
const tarzan = new Person('Tarzan');
让我们来看看Person
类的内部是什么。
-
.constructor()
是一个特殊的方法,在创建新实例之后调用。在其中,this
指的是该实例。 -
[ES2022]
.#firstName
是一个实例私有字段:这样的字段存储在实例中。它们的访问方式类似于属性,但它们的名称是分开的-它们总是以井号符号(#
)开头。并且它们对类外部是不可见的:assert.deepEqual( Reflect.ownKeys(jane), [] );
在我们可以在构造函数中初始化
.#firstName
(B 行)之前,我们需要在类主体中提到它来声明它(A 行)。 -
.describe()
是一个方法。如果我们通过obj.describe()
调用它,那么this
在.describe()
的主体内指的是obj
。assert.equal( jane.describe(), 'Person named Jane' ); assert.equal( tarzan.describe(), 'Person named Tarzan' );
-
.extractName()
是一个静态方法。“静态”意味着它属于类,而不属于实例:assert.deepEqual( Person.extractNames([jane, tarzan]), ['Jane', 'Tarzan'] );
我们还可以在构造函数中创建实例属性(公共字段):
class Container {
constructor(value) {
this.value = value;
}
}
const abcContainer = new Container('abc');
assert.equal(
abcContainer.value, 'abc'
);
与实例私有字段相反,实例属性不必在类主体中声明。
29.2.2 类表达式
有两种类定义(定义类的方式):
-
类声明,我们在上一节中看到的。
-
类表达式,我们将在下面看到。
类表达式可以是匿名的也可以是命名的:
// Anonymous class expression
const Person = class { ··· };
// Named class expression
const Person = class MyClass { ··· };
命名类表达式的名称类似于命名函数表达式的名称:它只能在类主体内部访问,并且保持不变,无论该类分配给什么。
29.2.3 instanceof
运算符
instanceof
运算符告诉我们一个值是否是给定类的实例:
> new Person('Jane') instanceof Person
true
> {} instanceof Person
false
> {} instanceof Object
true
> [] instanceof Array
true
我们将在之后更详细地探讨instanceof
运算符(在我们看完子类化之后)。
29.2.4 公共槽(属性)vs. 私有槽
在 JavaScript 语言中,对象可以有两种“槽”。
-
公共槽(也称为属性)。例如,方法是公共槽。
-
私有槽[ES2022]。例如,私有字段是私有槽。
这些是我们需要了解有关属性和私有槽的最重要规则:
-
在类中,我们可以使用字段、方法、getter 和 setter 的公共和私有版本。它们都是对象中的槽。它们放置在哪些对象取决于是否使用关键字
static
和其他因素。 -
具有相同键的 getter 和 setter 创建一个单一的访问器槽。访问器也可以只有 getter 或只有 setter。
-
属性和私有槽非常不同-例如:
-
它们被分开存储。
-
它们的键是不同的。私有槽的键甚至不能直接访问(参见本章后面的§29.2.5.2“每个私有槽都有一个唯一的键(私有名称)”)。
-
属性是从原型继承的,私有槽不是。
-
私有槽只能通过类创建。
-
本章不涵盖所有属性和私有槽的细节(只涵盖基本内容)。如果您想深入了解,可以在这里进行。
-
ECMAScript 语言规范中的“对象内部方法和内部槽”一节解释了私有槽的工作原理。搜索“
[[PrivateElements]]
”。
以下类演示了两种槽。它的每个实例都有一个私有字段和一个属性:
class MyClass {
#instancePrivateField = 1;
instanceProperty = 2;
getInstanceValues() {
return [
this.#instancePrivateField,
this.instanceProperty,
];
}
}
const inst = new MyClass();
assert.deepEqual(
inst.getInstanceValues(), [1, 2]
);
如预期的那样,在MyClass
之外,我们只能看到属性:
assert.deepEqual(
Reflect.ownKeys(inst),
['instanceProperty']
);
接下来,我们将看一些私有槽的细节。
29.2.5 更详细的私有槽[ES2022](高级)
29.2.5.1 私有槽不能在子类中访问
私有槽确实只能在其类的主体内部访问。我们甚至不能从子类访问它:
class SuperClass {
#superProp = 'superProp';
}
class SubClass extends SuperClass {
getSuperProp() {
return this.#superProp;
}
}
// SyntaxError: Private field '#superProp'
// must be declared in an enclosing class
通过extends
进行子类化在本章后面有解释。如何解决这个限制在§29.5.4 “通过 WeakMaps 模拟受保护的可见性和友元可见性”中有解释。
29.2.5.2 每个私有槽都有一个唯一的键(私有名称)
私有槽具有类似于 symbols 的唯一键。考虑之前的以下类:
class MyClass {
#instancePrivateField = 1;
instanceProperty = 2;
getInstanceValues() {
return [
this.#instancePrivateField,
this.instanceProperty,
];
}
}
在内部,MyClass
的私有字段大致处理如下:
let MyClass;
{ // Scope of the body of the class
const instancePrivateFieldKey = Symbol();
MyClass = class {
// Very loose approximation of how this
// works in the language specification
__PrivateElements__ = new Map([
[instancePrivateFieldKey, 1],
]);
instanceProperty = 2;
getInstanceValues() {
return [
this.__PrivateElements__.get(instancePrivateFieldKey),
this.instanceProperty,
];
}
}
}
instancePrivateFieldKey
的值称为私有名称。我们不能直接在 JavaScript 中使用私有名称,我们只能间接使用它们,通过私有字段、私有方法和私有访问器的固定标识符。公共槽的固定标识符(如getInstanceValues
)被解释为字符串键,私有槽的固定标识符(如#instancePrivateField
)引用私有名称(类似于变量名称引用值)。
29.2.5.3 相同的私有标识符在不同类中引用不同的私有名称
因为私有槽的标识符不被用作键,所以在不同类中使用相同的标识符会产生不同的槽(A 行和 C 行):
class Color {
#name; // (A)
constructor(name) {
this.#name = name; // (B)
}
static getName(obj) {
return obj.#name;
}
}
class Person {
#name; // (C)
constructor(name) {
this.#name = name;
}
}
assert.equal(
Color.getName(new Color('green')), 'green'
);
// We can’t access the private slot #name of a Person in line B:
assert.throws(
() => Color.getName(new Person('Jane')),
{
name: 'TypeError',
message: 'Cannot read private member #name from'
+ ' an object whose class did not declare it',
}
);
29.2.5.4 私有字段的名称永远不会冲突
即使子类使用相同的名称作为私有字段,这两个名称也永远不会冲突,因为它们引用私有名称(始终是唯一的)。在下面的示例中,SuperClass
中的.#privateField
与SubClass
中的.#privateField
不冲突,即使两个槽都直接存储在inst
中:
class SuperClass {
#privateField = 'super';
getSuperPrivateField() {
return this.#privateField;
}
}
class SubClass extends SuperClass {
#privateField = 'sub';
getSubPrivateField() {
return this.#privateField;
}
}
const inst = new SubClass();
assert.equal(
inst.getSuperPrivateField(), 'super'
);
assert.equal(
inst.getSubPrivateField(), 'sub'
);
通过extends
进行子类化在本章后面有解释。
29.2.5.5 使用in
来检查对象是否具有给定的私有槽
in
运算符可用于检查私有槽是否存在(A 行):
class Color {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj; // (A)
}
}
让我们看看in
应用于私有槽的更多示例。
**私有方法。**以下代码显示私有方法在实例中创建私有槽:
class C1 {
#priv() {}
static check(obj) {
return #priv in obj;
}
}
assert.equal(C1.check(new C1()), true);
**静态私有字段。**我们也可以使用in
来检查静态私有字段:
class C2 {
static #priv = 1;
static check(obj) {
return #priv in obj;
}
}
assert.equal(C2.check(C2), true);
assert.equal(C2.check(new C2()), false);
**静态私有方法。**我们也可以检查静态私有方法的槽:
class C3 {
static #priv() {}
static check(obj) {
return #priv in obj;
}
}
assert.equal(C3.check(C3), true);
**在不同类中使用相同的私有标识符。**在下一个示例中,两个类Color
和Person
都有一个标识符为#name
的槽。in
运算符可以正确区分它们:
class Color {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj;
}
}
class Person {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj;
}
}
// Detecting Color’s #name
assert.equal(
Color.check(new Color()), true
);
assert.equal(
Color.check(new Person()), false
);
// Detecting Person’s #name
assert.equal(
Person.check(new Person()), true
);
assert.equal(
Person.check(new Color()), false
);
29.2.6 类在 JavaScript 中的优缺点
我建议使用类的原因如下:
-
类是对象创建和继承的常见标准,现在在许多库和框架中得到了广泛支持。与以前几乎每个框架都有自己的继承库相比,这是一个改进。
-
它们有助于 IDE 和类型检查器的工作,并在那里启用新功能。
-
如果您来自另一种语言到 JavaScript,并且习惯于类,那么您可以更快地开始。
-
JavaScript 引擎对它们进行了优化。也就是说,使用类的代码几乎总是比使用自定义继承库的代码更快。
-
我们可以对内置构造函数进行子类化,如
Error
。
这并不意味着类是完美的:
-
存在过度继承的风险。
-
在类中放入过多功能存在风险(其中一些功能通常最好放在函数中)。
-
类看起来对于来自其他语言的程序员来说很熟悉,但它们的工作方式和使用方式不同(见下一小节)。因此,存在这样的风险,即这些程序员编写的代码感觉不像 JavaScript。
-
类在表面上的工作方式与实际工作方式非常不同。换句话说,语法和语义之间存在断裂。两个例子是:
-
类
C
内部的方法定义会在对象C.prototype
中创建一个方法。 -
类是函数。
断开连接的动机是向后兼容性。幸运的是,这种断开在实践中几乎没有问题;如果我们按照类所假装的那样做,通常都没问题。
-
这是对类的第一次看法。我们很快会探索更多功能。
练习:编写一个类
exercises/classes/point_class_test.mjs
29.2.7 使用类的提示
-
谨慎使用继承 - 它往往会使代码变得更加复杂,并将相关功能分散到多个位置。
-
通常最好使用外部函数和变量而不是静态成员。我们甚至可以通过不导出它们来将它们私有化到一个模块中。这个规则的两个重要例外是:
-
需要访问私有插槽的操作
-
静态工厂方法
-
-
只将核心功能放在原型方法中。其他功能最好通过函数实现 - 尤其是涉及多个类的实例的算法。
29.3 类的内部
29.3.1 类实际上是两个连接的对象
在底层,一个类变成了两个连接的对象。让我们重新审视类Person
,看看它是如何工作的:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
类创建的第一个对象存储在Person
中。它有四个属性:
assert.deepEqual(
Reflect.ownKeys(Person),
['length', 'name', 'prototype', 'extractNames']
);
// The number of parameters of the constructor
assert.equal(
Person.length, 1
);
// The name of the class
assert.equal(
Person.name, 'Person'
);
剩下的两个属性是:
-
Person.extractNames
是我们已经看到在操作中的静态方法。 -
Person.prototype
指向由类定义创建的第二个对象。
这是Person.prototype
的内容:
assert.deepEqual(
Reflect.ownKeys(Person.prototype),
['constructor', 'describe']
);
有两个属性:
-
Person.prototype.constructor
指向构造函数。 -
Person.prototype.describe
是我们已经使用过的方法。
29.3.2 类设置其实例的原型链
对象Person.prototype
是所有实例的原型:
const jane = new Person('Jane');
assert.equal(
Object.getPrototypeOf(jane), Person.prototype
);
const tarzan = new Person('Tarzan');
assert.equal(
Object.getPrototypeOf(tarzan), Person.prototype
);
这解释了实例如何获得它们的方法:它们从对象Person.prototype
继承。
图 13 可视化了一切是如何连接的。
图 13:类Person
具有属性.prototype
,指向所有Person
实例的原型对象。对象jane
和tarzan
是这样的实例。
29.3.3 .__proto__
vs. .prototype
很容易混淆.__proto__
和.prototype
。希望图 13 能清楚地说明它们的区别:
-
.__proto__
是类Object
的访问器,它让我们获取和设置其实例的原型。 -
.prototype
是一个像其他任何属性一样的普通属性。它之所以特殊,只是因为new
运算符使用它的值作为实例的原型。它的名称并不理想。一个不同的名称,比如.instancePrototype
,更合适。
29.3.4 Person.prototype.constructor
(高级)
图 13 中有一个细节我们还没有看过:Person.prototype.constructor
指回Person
:
> Person.prototype.constructor === Person
true
这个设置存在是因为向后兼容性。但它有两个额外的好处。
首先,每个类的实例都继承属性.constructor
。因此,给定一个实例,我们可以通过它创建“相似”的对象:
const jane = new Person('Jane');
const cheeta = new jane.constructor('Cheeta');
// cheeta is also an instance of Person
assert.equal(cheeta instanceof Person, true);
其次,我们可以获取创建给定实例的类的名称:
const tarzan = new Person('Tarzan');
assert.equal(tarzan.constructor.name, 'Person');
29.3.5 分派 vs. 直接方法调用(高级)
在本小节中,我们学习了两种不同的调用方法:
-
分派方法调用
-
直接方法调用
理解它们两个将为我们提供重要的方法工作见解。
我们还需要本章后面的第二种方法:它将允许我们从Object.prototype
中借用有用的方法。
29.3.5.1 分派方法调用
让我们来看看类的方法调用是如何工作的。我们重新访问之前的jane
:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return 'Person named '+this.#firstName;
}
}
const jane = new Person('Jane');
图 14 有一个带有jane
的原型链的图表。
图 14:jane
的原型链以jane
开始,然后继续到Person.prototype
。
普通方法调用是分发的-方法调用
jane.describe()
发生在两个步骤中:
-
分发:JavaScript 遍历原型链,从
jane
开始查找具有键'describe'
的自有属性的第一个对象:它首先查看jane
,并没有找到自有属性.describe
。它继续查看jane
的原型,Person.prototype
,并找到了一个自有属性describe
,返回其值。const func = jane.describe;
-
调用:方法调用一个值与函数调用一个值不同,它不仅调用括号前面的内容和括号内的参数,还将
this
设置为方法调用的接收者(在本例中为jane
):func.call(jane);
这种动态查找方法并调用它的方式称为动态分发。
29.3.5.2 直接方法调用
我们还可以直接进行方法调用,而无需分发:
Person.prototype.describe.call(jane)
这一次,我们直接通过Person.prototype.describe
指向方法,而不是在原型链中搜索它。我们还通过.call()
不同地指定了this
。
this
总是指向实例
无论实例的原型链中的方法位于何处,this
总是指向实例(原型链的开头)。这使得.describe()
能够在示例中访问.#firstName
。
直接方法调用何时有用?每当我们想要从其他地方借用一个给定对象没有的方法时,例如:
const obj = Object.create(null);
// `obj` is not an instance of Object and doesn’t inherit
// its prototype method .toString()
assert.throws(
() => obj.toString(),
/^TypeError: obj.toString is not a function$/
);
assert.equal(
Object.prototype.toString.call(obj),
'[object Object]'
);
29.3.6 类从普通函数演变而来(高级)
在 ECMAScript 6 之前,JavaScript 没有类。而是使用普通函数作为构造函数:
function StringBuilderConstr(initialString) {
this.string = initialString;
}
StringBuilderConstr.prototype.add = function (str) {
this.string += str;
return this;
};
const sb = new StringBuilderConstr('¡');
sb.add('Hola').add('!');
assert.equal(
sb.string, '¡Hola!'
);
类提供了更好的语法来实现这一点:
class StringBuilderClass {
constructor(initialString) {
this.string = initialString;
}
add(str) {
this.string += str;
return this;
}
}
const sb = new StringBuilderClass('¡');
sb.add('Hola').add('!');
assert.equal(
sb.string, '¡Hola!'
);
使用构造函数进行子类化特别棘手。类还提供了超出更方便的语法之外的好处:
-
内置构造函数(如
Error
)可以被子类化。 -
我们可以通过
super
访问被覆盖的属性。 -
类不能被函数调用。
-
方法不能被
new
调用,也没有.prototype
属性。 -
支持私有实例数据。
-
还有更多。
类与构造函数非常兼容,甚至可以扩展它们:
function SuperConstructor() {}
class SubClass extends SuperConstructor {}
assert.equal(
new SubClass() instanceof SuperConstructor, true
);
extends
和子类化在本章后面有解释。
29.3.6.1 类就是构造函数
这给我们带来了一个有趣的见解。一方面,StringBuilderClass
通过StringBuilderClass.prototype.constructor
引用其构造函数。
另一方面,类就是构造函数(一个函数):
> StringBuilderClass.prototype.constructor === StringBuilderClass
true
> typeof StringBuilderClass
'function'
构造函数(函数)与类
由于它们的相似性,我可以互换使用术语构造函数(函数)和类。
29.4 类的原型成员
29.4.1 公共原型方法和访问器
以下类声明体中的所有成员都创建了PublicProtoClass.prototype
的属性。
class PublicProtoClass {
constructor(args) {
// (Do something with `args` here.)
}
publicProtoMethod() {
return 'publicProtoMethod';
}
get publicProtoAccessor() {
return 'publicProtoGetter';
}
set publicProtoAccessor(value) {
assert.equal(value, 'publicProtoSetter');
}
}
assert.deepEqual(
Reflect.ownKeys(PublicProtoClass.prototype),
['constructor', 'publicProtoMethod', 'publicProtoAccessor']
);
const inst = new PublicProtoClass('arg1', 'arg2');
assert.equal(
inst.publicProtoMethod(), 'publicProtoMethod'
);
assert.equal(
inst.publicProtoAccessor, 'publicProtoGetter'
);
inst.publicProtoAccessor = 'publicProtoSetter';
29.4.1.1 各种公共原型方法和访问器(高级)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');
class PublicProtoClass2 {
// Identifier keys
get accessor() {}
set accessor(value) {}
syncMethod() {}
* syncGeneratorMethod() {}
async asyncMethod() {}
async * asyncGeneratorMethod() {}
// Quoted keys
get 'an accessor'() {}
set 'an accessor'(value) {}
'sync method'() {}
* 'sync generator method'() {}
async 'async method'() {}
async * 'async generator method'() {}
// Computed keys
get [accessorKey]() {}
set accessorKey {}
[syncMethodKey]() {}
* [syncGenMethodKey]() {}
async [asyncMethodKey]() {}
async * [asyncGenMethodKey]() {}
}
// Quoted and computed keys are accessed via square brackets:
const inst = new PublicProtoClass2();
inst['sync method']();
inst[syncMethodKey]();
引用和计算键也可以在对象字面量中使用:
-
§28.7.1 “对象字面量中的引用键”
-
§28.7.2 “对象字面量中的计算键”
有关访问器(通过 getter 和/或 setter 定义)、生成器、异步方法和异步生成器方法的更多信息:
-
§28.3.6 “对象字面量:访问器”
-
§38 “同步生成器”
-
§41 “异步函数”
-
§42.2 “异步生成器”
29.4.2 私有方法和访问器 [ES2022]
私有方法(和访问器)是原型成员和实例成员的有趣混合体。
一方面,私有方法存储在实例的槽中(A 行):
class MyClass {
#privateMethod() {}
static check() {
const inst = new MyClass();
assert.equal(
#privateMethod in inst, true // (A)
);
assert.equal(
#privateMethod in MyClass.prototype, false
);
assert.equal(
#privateMethod in MyClass, false
);
}
}
MyClass.check();
为什么它们不存储在.prototype
对象中?私有槽不会被继承,只有属性会被继承。
另一方面,私有方法在实例之间是共享的,就像原型公共方法一样:
class MyClass {
#privateMethod() {}
static check() {
const inst1 = new MyClass();
const inst2 = new MyClass();
assert.equal(
inst1.#privateMethod,
inst2.#privateMethod
);
}
}
由于它们的语法与原型公共方法相似,因此它们在这里进行了介绍。
以下代码演示了私有方法和访问器的工作原理:
class PrivateMethodClass {
#privateMethod() {
return 'privateMethod';
}
get #privateAccessor() {
return 'privateGetter';
}
set #privateAccessor(value) {
assert.equal(value, 'privateSetter');
}
callPrivateMembers() {
assert.equal(this.#privateMethod(), 'privateMethod');
assert.equal(this.#privateAccessor, 'privateGetter');
this.#privateAccessor = 'privateSetter';
}
}
assert.deepEqual(
Reflect.ownKeys(new PrivateMethodClass()), []
);
29.4.2.1 各种私有方法和访问器(高级)
使用私有槽时,键始终是标识符:
class PrivateMethodClass2 {
get #accessor() {}
set #accessor(value) {}
#syncMethod() {}
* #syncGeneratorMethod() {}
async #asyncMethod() {}
async * #asyncGeneratorMethod() {}
}
更多关于访问器(通过 getter 和/或 setter 定义)、生成器、异步方法和异步生成器方法的信息:
-
§28.3.6 “对象字面量:访问器”
-
§38 “同步生成器”
-
§41 “异步函数”
-
§42.2 “异步生成器”
29.5 实例类成员 [ES2022]
29.5.1 实例公共字段
以下类的实例具有两个实例属性(在 A 行和 B 行创建):
class InstPublicClass {
// Instance public field
instancePublicField = 0; // (A)
constructor(value) {
// We don’t need to mention .property elsewhere!
this.property = value; // (B)
}
}
const inst = new InstPublicClass('constrArg');
assert.deepEqual(
Reflect.ownKeys(inst),
['instancePublicField', 'property']
);
assert.equal(
inst.instancePublicField, 0
);
assert.equal(
inst.property, 'constrArg'
);
如果我们在构造函数中创建了一个实例属性(B 行),我们就不需要在其他地方“声明”它。正如我们已经看到的,对于实例私有字段来说是不同的。
请注意,实例属性在 JavaScript 中相对常见;比如在 Java 中,大多数实例状态都是私有的。
29.5.1.1 具有带引号和计算键的实例公共字段(高级)
const computedFieldKey = Symbol('computedFieldKey');
class InstPublicClass2 {
'quoted field key' = 1;
[computedFieldKey] = 2;
}
const inst = new InstPublicClass2();
assert.equal(inst['quoted field key'], 1);
assert.equal(inst[computedFieldKey], 2);
29.5.1.2 实例公共字段中this
的值是什么?(高级)
在实例公共字段的初始化程序中,this
指的是新创建的实例:
class MyClass {
instancePublicField = this;
}
const inst = new MyClass();
assert.equal(
inst.instancePublicField, inst
);
29.5.1.3 实例公共字段何时执行?(高级)
实例公共字段的执行大致遵循这两条规则:
-
在基类(没有超类的类)中,实例公共字段在构造函数之前立即执行。
-
在派生类(具有超类的类)中:
-
当
super()
被调用时,超类设置其实例槽。 -
实例公共字段在
super()
之后立即执行。
-
以下示例演示了这些规则:
class SuperClass {
superProp = console.log('superProp');
constructor() {
console.log('super-constructor');
}
}
class SubClass extends SuperClass {
subProp = console.log('subProp');
constructor() {
console.log('BEFORE super()');
super();
console.log('AFTER super()');
}
}
new SubClass();
// Output:
// 'BEFORE super()'
// 'superProp'
// 'super-constructor'
// 'subProp'
// 'AFTER super()'
extends
和子类化在本章后面有解释。
29.5.2 实例私有字段
以下类包含两个实例私有字段(A 行和 B 行):
class InstPrivateClass {
#privateField1 = 'private field 1'; // (A)
#privateField2; // (B) required!
constructor(value) {
this.#privateField2 = value; // (C)
}
/**
* Private fields are not accessible outside the class body.
*/
checkPrivateValues() {
assert.equal(
this.#privateField1, 'private field 1'
);
assert.equal(
this.#privateField2, 'constructor argument'
);
}
}
const inst = new InstPrivateClass('constructor argument');
inst.checkPrivateValues();
// No instance properties were created
assert.deepEqual(
Reflect.ownKeys(inst),
[]
);
请注意,如果我们在类主体中声明了.#privateField2
,那么我们只能在 C 行中使用它。
29.5.3 ES2022 之前的私有实例数据(高级)
在本节中,我们将介绍两种保持实例数据私有的技术。因为它们不依赖于类,所以我们也可以用它们来处理其他方式创建的对象,比如通过对象字面量。
29.5.3.1 ES6 之前:通过命名约定实现私有成员
第一种技术是通过在属性名称前加下划线使其私有化。这并不以任何方式保护属性;它只是向外界发出信号:“你不需要知道这个属性。”
在以下代码中,属性._counter
和._action
是私有的。
class Countdown {
constructor(counter, action) {
this._counter = counter;
this._action = action;
}
dec() {
this._counter--;
if (this._counter === 0) {
this._action();
}
}
}
// The two properties aren’t really private:
assert.deepEqual(
Object.keys(new Countdown()),
['_counter', '_action']);
使用这种技术,我们不会得到任何保护,私有名称可能会冲突。但好处是,它很容易使用。
私有方法的工作方式类似:它们是以下划线开头的普通方法。
29.5.3.2 ES6 及以后:通过 WeakMaps 实现私有实例数据
我们也可以通过 WeakMaps 管理私有实例数据:
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
let counter = _counter.get(this);
counter--;
_counter.set(this, counter);
if (counter === 0) {
_action.get(this)();
}
}
}
// The two pseudo-properties are truly private:
assert.deepEqual(
Object.keys(new Countdown()),
[]);
关于它的工作原理的详细解释在 WeakMaps 章节中有说明。
这种技术为我们提供了相当大的保护,防止外部访问和名称冲突。但它也更复杂一些。
通过控制谁可以访问 _superProp
来控制伪属性 _superProp
的可见性——例如:如果变量存在于模块内并且未导出,则模块内的所有人都可以访问它,模块外的任何人都无法访问它。换句话说:在这种情况下,隐私的范围不是类,而是模块。不过,我们可以缩小范围:
let Countdown;
{ // class scope
const _counter = new WeakMap();
const _action = new WeakMap();
Countdown = class {
// ···
}
}
这种技术实际上不支持私有方法。但是,具有对 _superProp
的访问权限的模块局部函数是下一个最好的选择:
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
privateDec(this);
}
}
function privateDec(_this) { // (A)
let counter = _counter.get(_this);
counter--;
_counter.set(_this, counter);
if (counter === 0) {
_action.get(_this)();
}
}
注意,this
变成了显式函数参数 _this
(A 行)。
29.5.4 通过 WeakMaps 模拟受保护的可见性和友元可见性(高级)
如前所述,实例私有字段只在其类内部可见,甚至在子类中也不可见。因此,没有内置的方法可以获取:
-
受保护的可见性:一个类及其所有子类可以访问一个实例数据。
-
友元可见性:一个类及其“友元”(指定的函数、对象或类)可以访问一个实例数据。
在前一小节中,我们通过 WeakMaps 模拟了“模块可见性”(模块内的所有人都可以访问一个实例数据)。因此:
-
如果我们将一个类及其子类放入同一个模块中,我们就会得到受保护的可见性。
-
如果我们将一个类及其友元放入同一个模块中,我们就会得到友元可见性。
下一个示例演示了受保护的可见性:
const _superProp = new WeakMap();
class SuperClass {
constructor() {
_superProp.set(this, 'superProp');
}
}
class SubClass extends SuperClass {
getSuperProp() {
return _superProp.get(this);
}
}
assert.equal(
new SubClass().getSuperProp(),
'superProp'
);
通过 extends
进行子类化 在本章后面有解释。
29.6 类的静态成员
29.6.1 静态公共方法和访问器
在以下类声明的主体中,所有成员都创建所谓的 静态 属性——StaticClass
本身的属性。
class StaticPublicMethodsClass {
static staticMethod() {
return 'staticMethod';
}
static get staticAccessor() {
return 'staticGetter';
}
static set staticAccessor(value) {
assert.equal(value, 'staticSetter');
}
}
assert.equal(
StaticPublicMethodsClass.staticMethod(), 'staticMethod'
);
assert.equal(
StaticPublicMethodsClass.staticAccessor, 'staticGetter'
);
StaticPublicMethodsClass.staticAccessor = 'staticSetter';
29.6.1.1 所有种类的静态公共方法和访问器(高级)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');
class StaticPublicMethodsClass2 {
// Identifier keys
static get accessor() {}
static set accessor(value) {}
static syncMethod() {}
static * syncGeneratorMethod() {}
static async asyncMethod() {}
static async * asyncGeneratorMethod() {}
// Quoted keys
static get 'an accessor'() {}
static set 'an accessor'(value) {}
static 'sync method'() {}
static * 'sync generator method'() {}
static async 'async method'() {}
static async * 'async generator method'() {}
// Computed keys
static get [accessorKey]() {}
static set accessorKey {}
static [syncMethodKey]() {}
static * [syncGenMethodKey]() {}
static async [asyncMethodKey]() {}
static async * [asyncGenMethodKey]() {}
}
// Quoted and computed keys are accessed via square brackets:
StaticPublicMethodsClass2['sync method']();
StaticPublicMethodsClass2[syncMethodKey]();
引号和计算键也可以在对象字面量中使用:
-
§28.7.1 “对象字面量中的引号键”
-
§28.7.2 “对象字面量中的计算键”
有关访问器(通过 getter 和/或 setter 定义)、生成器、异步方法和异步生成器方法的更多信息:
-
§28.3.6 “对象字面量:访问器”
-
§38 “同步生成器”
-
§41 “异步函数”
-
§42.2 “异步生成器”
29.6.2 静态公共字段 [ES2022]
以下代码演示了静态公共字段。StaticPublicFieldClass
有三个静态公共字段:
const computedFieldKey = Symbol('computedFieldKey');
class StaticPublicFieldClass {
static identifierFieldKey = 1;
static 'quoted field key' = 2;
static [computedFieldKey] = 3;
}
assert.deepEqual(
Reflect.ownKeys(StaticPublicFieldClass),
[
'length', // number of constructor parameters
'name', // 'StaticPublicFieldClass'
'prototype',
'identifierFieldKey',
'quoted field key',
computedFieldKey,
],
);
assert.equal(StaticPublicFieldClass.identifierFieldKey, 1);
assert.equal(StaticPublicFieldClass['quoted field key'], 2);
assert.equal(StaticPublicFieldClass[computedFieldKey], 3);
29.6.3 静态私有方法、访问器和字段 [ES2022]
以下类有两个静态私有槽(A 行和 B 行):
class StaticPrivateClass {
// Declare and initialize
static #staticPrivateField = 'hello'; // (A)
static #twice() { // (B)
const str = StaticPrivateClass.#staticPrivateField;
return str + ' ' + str;
}
static getResultOfTwice() {
return StaticPrivateClass.#twice();
}
}
assert.deepEqual(
Reflect.ownKeys(StaticPrivateClass),
[
'length', // number of constructor parameters
'name', // 'StaticPublicFieldClass'
'prototype',
'getResultOfTwice',
],
);
assert.equal(
StaticPrivateClass.getResultOfTwice(),
'hello hello'
);
这是所有种类的静态私有槽的完整列表:
class MyClass {
static #staticPrivateMethod() {}
static * #staticPrivateGeneratorMethod() {}
static async #staticPrivateAsyncMethod() {}
static async * #staticPrivateAsyncGeneratorMethod() {}
static get #staticPrivateAccessor() {}
static set #staticPrivateAccessor(value) {}
}
29.6.4 类中的静态初始化块 [ES2022]
通过类设置实例数据,我们有两种构造:
-
字段,用于创建并可选地初始化实例数据
-
构造函数,每次创建新实例时执行的代码块
对于静态数据,我们有:
-
静态字段
-
静态块 在类创建时执行
以下代码演示了静态块(A 行):
class Translator {
static translations = {
yes: 'ja',
no: 'nein',
maybe: 'vielleicht',
};
static englishWords = [];
static germanWords = [];
static { // (A)
for (const [english, german] of Object.entries(this.translations)) {
this.englishWords.push(english);
this.germanWords.push(german);
}
}
}
我们也可以在类后执行静态块中的代码(在顶层)。然而,使用静态块有两个好处:
-
所有与类相关的代码都在类内部。
-
静态块中的代码可以访问私有槽。
29.6.4.1 静态初始化块的规则
静态初始化块的工作规则相对简单:
-
每个类可以有多个静态块。
-
静态块的执行与静态字段初始化的执行交错进行。
-
超类的静态成员在子类的静态成员之前执行。
以下代码演示了这些规则:
class SuperClass {
static superField1 = console.log('superField1');
static {
assert.equal(this, SuperClass);
console.log('static block 1 SuperClass');
}
static superField2 = console.log('superField2');
static {
console.log('static block 2 SuperClass');
}
}
class SubClass extends SuperClass {
static subField1 = console.log('subField1');
static {
assert.equal(this, SubClass);
console.log('static block 1 SubClass');
}
static subField2 = console.log('subField2');
static {
console.log('static block 2 SubClass');
}
}
// Output:
// 'superField1'
// 'static block 1 SuperClass'
// 'superField2'
// 'static block 2 SuperClass'
// 'subField1'
// 'static block 1 SubClass'
// 'subField2'
// 'static block 2 SubClass'
通过 extends
进行子类化 在本章后面有解释。
29.6.5 陷阱:使用 this
访问静态私有字段
在静态公共成员中,我们可以通过this
访问静态公共插槽。遗憾的是,我们不应该用它来访问静态私有插槽。
29.6.5.1 this
和静态公共字段
考虑以下代码:
class SuperClass {
static publicData = 1;
static getPublicViaThis() {
return this.publicData;
}
}
class SubClass extends SuperClass {
}
通过extends
进行子类化在本章后面有解释。
静态公共字段是属性。如果我们调用该方法
assert.equal(SuperClass.getPublicViaThis(), 1);
然后this
指向SuperClass
,一切都按预期工作。我们还可以通过子类调用.getPublicViaThis()
:
assert.equal(SubClass.getPublicViaThis(), 1);
SubClass
从其原型SuperClass
继承了.getPublicViaThis()
。this
指向SubClass
,事情继续进行,因为SubClass
还继承了.publicData
属性。
顺便说一句,如果我们在getPublicViaThis()
中为this.publicData
赋值,并通过SubClass.getPublicViaThis()
调用它,那么我们将创建一个新的SubClass
自己的属性,它(非破坏性地)覆盖了从SuperClass
继承的属性。
29.6.5.2 this
和静态私有字段
考虑以下代码:
class SuperClass {
static #privateData = 2;
static getPrivateDataViaThis() {
return this.#privateData;
}
static getPrivateDataViaClassName() {
return SuperClass.#privateData;
}
}
class SubClass extends SuperClass {
}
通过SuperClass
调用.getPrivateDataViaThis()
是有效的,因为this
指向SuperClass
:
assert.equal(SuperClass.getPrivateDataViaThis(), 2);
然而,通过SubClass
调用.getPrivateDataViaThis()
是无效的,因为此时this
指向SubClass
,而SubClass
没有静态私有字段.#privateData
(原型链中的私有插槽不会被继承):
assert.throws(
() => SubClass.getPrivateDataViaThis(),
{
name: 'TypeError',
message: 'Cannot read private member #privateData from'
+ ' an object whose class did not declare it',
}
);
解决方法是直接访问.#privateData
,通过SuperClass
:
assert.equal(SubClass.getPrivateDataViaClassName(), 2);
使用静态私有方法时,我们面临相同的问题。
29.6.6 所有成员(静态、原型、实例)都可以访问所有私有成员
类中的每个成员都可以访问该类中的所有其他成员-包括公共成员和私有成员:
class DemoClass {
static #staticPrivateField = 1;
#instPrivField = 2;
static staticMethod(inst) {
// A static method can access static private fields
// and instance private fields
assert.equal(DemoClass.#staticPrivateField, 1);
assert.equal(inst.#instPrivField, 2);
}
protoMethod() {
// A prototype method can access instance private fields
// and static private fields
assert.equal(this.#instPrivField, 2);
assert.equal(DemoClass.#staticPrivateField, 1);
}
}
相反,外部任何人都无法访问私有成员:
// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
() => eval('DemoClass.#staticPrivateField'),
{
name: 'SyntaxError',
message: "Private field '#staticPrivateField' must"
+ " be declared in an enclosing class",
}
);
// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
() => eval('new DemoClass().#instPrivField'),
{
name: 'SyntaxError',
message: "Private field '#instPrivField' must"
+ " be declared in an enclosing class",
}
);
29.6.7 ES2022 之前的静态私有方法和数据
以下代码仅在 ES2022 中有效-由于每一行中都有一个井号(#
):
class StaticClass {
static #secret = 'Rumpelstiltskin';
static #getSecretInParens() {
return `(${StaticClass.#secret})`;
}
static callStaticPrivateMethod() {
return StaticClass.#getSecretInParens();
}
}
由于私有插槽只存在于每个类中一次,我们可以将#secret
和#getSecretInParens
移到围绕类的范围,并使用模块将它们隐藏在模块外的世界中。
const secret = 'Rumpelstiltskin';
function getSecretInParens() {
return `(${secret})`;
}
// Only the class is accessible outside the module
export class StaticClass {
static callStaticPrivateMethod() {
return getSecretInParens();
}
}
29.6.8 静态工厂方法
有时类可以以多种方式实例化。然后我们可以实现“静态工厂方法”,比如Point.fromPolar()
:
class Point {
static fromPolar(radius, angle) {
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
return new Point(x, y);
}
constructor(x=0, y=0) {
this.x = x;
this.y = y;
}
}
assert.deepEqual(
Point.fromPolar(13, 0.39479111969976155),
new Point(12, 5)
);
我喜欢静态工厂方法的描述性:fromPolar
描述了如何创建实例。JavaScript 的标准库也有这样的工厂方法-例如:
-
Array.from()
-
Object.create()
我更喜欢要么没有静态工厂方法,要么只有静态工厂方法。在后一种情况下需要考虑的事项:
-
一个工厂方法可能会直接调用构造函数(但具有描述性的名称)。
-
我们需要找到一种方法来防止构造函数被外部调用。
在下面的代码中,我们使用一个秘密令牌(A 行)来防止构造函数被外部模块调用。
// Only accessible inside the current module
const secretToken = Symbol('secretToken'); // (A)
export class Point {
static create(x=0, y=0) {
return new Point(secretToken, x, y);
}
static fromPolar(radius, angle) {
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
return new Point(secretToken, x, y);
}
constructor(token, x, y) {
if (token !== secretToken) {
throw new TypeError('Must use static factory method');
}
this.x = x;
this.y = y;
}
}
Point.create(3, 4); // OK
assert.throws(
() => new Point(3, 4),
TypeError
);
29.7 子类化
类也可以扩展现有的类。例如,以下类Employee
扩展了Person
:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
class Employee extends Person {
constructor(firstName, title) {
super(firstName);
this.title = title;
}
describe() {
return super.describe() +
` (${this.title})`;
}
}
const jane = new Employee('Jane', 'CTO');
assert.equal(
jane.title,
'CTO'
);
assert.equal(
jane.describe(),
'Person named Jane (CTO)'
);
与扩展相关的术语:
-
“扩展”的另一个词是“子类化”。
-
Person
是Employee
的超类。 -
Employee
是Person
的子类。 -
“基类”是一个没有超类的类。
-
“派生类”是一个有超类的类。
在派生类的.constructor()
中,我们必须在访问this
之前通过super()
调用超级构造函数。为什么?
让我们考虑一系列的类:
-
基类
A
-
类
B
扩展了A
。 -
类
C
扩展了B
。
如果我们调用new C()
,C
的构造函数会调用B
的构造函数,后者会调用A
的构造函数。实例总是在基类中创建,然后在子类的构造函数添加插槽之前。因此,在调用super()
之前实例不存在,我们无法通过this
访问它。
请注意,静态公共插槽是继承的。例如,Employee
继承了静态方法.extractNames()
:
> 'extractNames' in Employee
true
练习:子类化
exercises/classes/color_point_class_test.mjs
29.7.1 子类的内部(高级)
图 15:这些是类Person
及其子类Employee
的对象。左列是关于类的,右列是关于Employee
实例jane
及其原型链的。
前一节中的类Person
和Employee
由几个对象组成(图 15)。理解这些对象如何相关的一个关键见解是,有两个原型链:
-
实例的原型链,位于右侧。
-
类的原型链,位于左侧。
29.7.1.1 实例原型链(右列)
实例的原型链以jane
开始,然后是Employee.prototype
和Person.prototype
。原则上,原型链在这一点上结束,但我们得到了另一个对象:Object.prototype
。这个原型为几乎所有对象提供服务,这就是为什么它也被包括在这里:
> Object.getPrototypeOf(Person.prototype) === Object.prototype
true
29.7.1.2 类原型链(左列)
在类原型链中,Employee
排在第一位,Person
排在其后。之后,链条继续包括Function.prototype
,这是因为Person
是一个函数,函数需要Function.prototype
的服务。
> Object.getPrototypeOf(Person) === Function.prototype
true
29.7.2 instanceof
和子类(高级)
我们还没有学习instanceof
的真正工作原理。instanceof
如何确定值x
是否是类C
的实例(它可以是C
的直接实例或C
的子类的直接实例)?它检查C.prototype
是否在x
的原型链中。也就是说,以下两个表达式是等价的:
x instanceof C
C.prototype.isPrototypeOf(x)
如果我们回到图 15,我们可以确认原型链确实给我们带来了以下正确的答案:
> jane instanceof Employee
true
> jane instanceof Person
true
> jane instanceof Object
true
请注意,如果instanceof
的自身操作数是原始值,instanceof
总是返回false
:
> 'abc' instanceof String
false
> 123 instanceof Number
false
29.7.3 并非所有对象都是Object
的实例(高级)
只有对象(非原始值)是Object
的实例,如果Object.prototype
在它们的原型链中(见上一小节)。几乎所有对象都是Object
的实例——例如:
assert.equal(
{a: 1} instanceof Object, true
);
assert.equal(
['a'] instanceof Object, true
);
assert.equal(
/abc/g instanceof Object, true
);
assert.equal(
new Map() instanceof Object, true
);
class C {}
assert.equal(
new C() instanceof Object, true
);
在下一个例子中,obj1
和obj2
都是对象(行 A 和行 C),但它们不是Object
的实例(行 B 和行 D):Object.prototype
不在它们的原型链中,因为它们没有任何原型。
const obj1 = {__proto__: null};
assert.equal(
typeof obj1, 'object' // (A)
);
assert.equal(
obj1 instanceof Object, false // (B)
);
const obj2 = Object.create(null);
assert.equal(
typeof obj2, 'object' // (C)
);
assert.equal(
obj2 instanceof Object, false // (D)
);
Object.prototype
是结束大多数原型链的对象。它的原型是null
,这意味着它也不是Object
的实例:
> typeof Object.prototype
'object'
> Object.getPrototypeOf(Object.prototype)
null
> Object.prototype instanceof Object
false
29.7.4 内置对象的原型链(高级)
接下来,我们将利用我们对子类的了解来理解一些内置对象的原型链。以下工具函数p()
将帮助我们进行探索。
const p = Object.getPrototypeOf.bind(Object);
我们提取了Object
的方法.getPrototypeOf()
并将其赋值给p
。
29.7.4.1 {}
的原型链
让我们从检查普通对象开始:
> p({}) === Object.prototype
true
> p(p({})) === null
true
图 16:通过对象字面量创建的对象的原型链以该对象开始,继续包括Object.prototype
,最后为null
。
图 16 显示了这个原型链的图表。我们可以看到{}
确实是Object
的一个实例——Object.prototype
在它的原型链中。
29.7.4.2 []
的原型链
数组的原型链是什么样的?
> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true
图 17:Array 的原型链有这些成员:Array 实例,Array.prototype
,Object.prototype
,null
。
这个原型链(在图 17 中可视化)告诉我们,Array 对象是Array
和Object
的实例。
29.7.4.3 function () {}
的原型链
最后,普通函数的原型链告诉我们,所有函数都是对象:
> p(function () {}) === Function.prototype
true
> p(p(function () {})) === Object.prototype
true
29.7.4.4 内置类的原型链
基类的原型是Function.prototype
,这意味着它是一个函数(Function
的实例):
class A {}
assert.equal(
Object.getPrototypeOf(A),
Function.prototype
);
assert.equal(
Object.getPrototypeOf(class {}),
Function.prototype
);
派生类的原型是它的超类:
class B extends A {}
assert.equal(
Object.getPrototypeOf(B),
A
);
assert.equal(
Object.getPrototypeOf(class extends Object {}),
Object
);
有趣的是,Object
、Array
和Function
都是基类:
> Object.getPrototypeOf(Object) === Function.prototype
true
> Object.getPrototypeOf(Array) === Function.prototype
true
> Object.getPrototypeOf(Function) === Function.prototype
true
然而,正如我们所见,即使基类的实例也在它们的原型链中有Object.prototype
,因为它提供了所有对象都需要的服务。
为什么Array
和Function
是基类?
基类是实例实际创建的地方。Array
和Function
都需要创建自己的实例,因为它们有所谓的“内部插槽”,这些插槽不能后来添加到由Object
创建的实例中。
29.7.5 mixin 类(高级)
JavaScript 的类系统只支持单一继承。也就是说,每个类最多只能有一个超类。绕过这个限制的一种方法是通过一种称为mixin 类(简称:mixin)的技术。
思路是这样的:假设我们想让一个类C
继承自两个超类S1
和S2
。这将是多重继承,JavaScript 不支持。
我们的解决方法是将S1
和S2
转换为mixins,子类的工厂:
const S1 = (Sup) => class extends Sup { /*···*/ };
const S2 = (Sup) => class extends Sup { /*···*/ };
这两个函数中的每一个都返回一个扩展给定超类Sup
的类。我们创建类C
如下:
class C extends S2(S1(Object)) {
/*···*/
}
我们现在有一个类C
,它扩展了由S2()
返回的类,该类又扩展了由S1()
返回的类,该类又扩展了Object
。
29.7.5.1 品牌管理的 mixin 示例
我们实现了一个 mixinBranded
,它具有设置和获取对象品牌的辅助方法:
const Named = (Sup) => class extends Sup {
name = '(Unnamed)';
toString() {
const className = this.constructor.name;
return `${className} named ${this.name}`;
}
};
我们使用这个 mixin 来实现一个具有名称的类City
:
class City extends Named(Object) {
constructor(name) {
super();
this.name = name;
}
}
以下代码确认 mixin 起作用:
const paris = new City('Paris');
assert.equal(
paris.name, 'Paris'
);
assert.equal(
paris.toString(), 'City named Paris'
);
29.7.5.2 mixin 的好处
Mixin 使我们摆脱了单一继承的限制:
-
相同的类可以扩展一个超类和零个或多个 mixins。
-
相同的 mixin 可以被多个类使用。
29.8 Object.prototype
的方法和访问器(高级)
正如我们在§29.7.3 “并非所有对象都是Object
的实例”中所看到的,几乎所有对象都是Object
的实例。这个类提供了几个有用的方法和一个访问器给它的实例:
-
配置如何将对象转换为原始值(例如通过
+
运算符):以下方法有默认实现,但通常在子类或实例中被覆盖。-
.toString()
:配置如何将对象转换为字符串。 -
.toLocaleString()
:.toString()
的一个版本,可以通过参数(语言、地区等)以各种方式配置。 -
.valueOf()
:配置如何将对象转换为非字符串原始值(通常是数字)。
-
-
有用的方法(带有陷阱-见下一小节):
-
.isPrototypeOf()
:接收者是否在给定对象的原型链中? -
.propertyIsEnumerable()
:接收者是否具有具有给定键的可枚举自有属性?
-
-
避免使用这些特性(有更好的替代方法):
-
.__proto__
:获取和设置接收者的原型。-
不建议使用这个访问器。替代方法:
-
Object.getPrototypeOf()
-
Object.setPrototypeOf()
-
-
-
.hasOwnProperty()
: 接收者是否具有给定键的自有属性?- 不建议使用这个方法。ES2022 及以后的替代方法:
Object.hasOwn()
。
- 不建议使用这个方法。ES2022 及以后的替代方法:
-
在我们更详细地了解这些特性之前,我们将了解一个重要的陷阱(以及如何解决它):我们不能在所有对象上使用 Object.prototype
的特性。
29.8.1 安全地使用 Object.prototype
的方法
在任意对象上调用 Object.prototype
的方法并不总是有效。为了说明这一点,我们使用了 Object.prototype.hasOwnProperty
方法,它返回 true
如果对象具有给定键的自有属性:
> {ownProp: true}.hasOwnProperty('ownProp')
true
> {ownProp: true}.hasOwnProperty('abc')
false
在任意对象上调用 .hasOwnProperty()
可能会失败。一方面,如果对象不是 Object
的实例,则此方法不可用(参见§29.7.3 “并非所有对象都是 Object
的实例”):
const obj = Object.create(null);
assert.equal(obj instanceof Object, false);
assert.throws(
() => obj.hasOwnProperty('prop'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
另一方面,如果对象用自有属性覆盖了 .hasOwnProperty()
,则我们不能使用它(行 A):
const obj = {
hasOwnProperty: 'yes' // (A)
};
assert.throws(
() => obj.hasOwnProperty('prop'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
然而,有一种安全的方法来使用 .hasOwnProperty()
:
function hasOwnProp(obj, propName) {
return Object.prototype.hasOwnProperty.call(obj, propName); // (A)
}
assert.equal(
hasOwnProp(Object.create(null), 'prop'), false
);
assert.equal(
hasOwnProp({hasOwnProperty: 'yes'}, 'prop'), false
);
assert.equal(
hasOwnProp({hasOwnProperty: 'yes'}, 'hasOwnProperty'), true
);
行 A 中的方法调用在§29.3.5 “分派 vs. 直接方法调用”中有解释。
我们还可以使用.bind()
来实现 hasOwnProp()
:
const hasOwnProp = Object.prototype.hasOwnProperty.call
.bind(Object.prototype.hasOwnProperty);
这是如何工作的?当我们像前面的示例中的行 A 那样调用 .call()
时,它确切地执行了 hasOwnProp()
应该执行的操作,包括避免陷阱。然而,如果我们想要函数调用它,我们不能简单地提取它,我们还必须确保它的 this
始终具有正确的值。这就是 .bind()
的作用。
通过动态分派使用
Object.prototype
方法永远不可以吗?
在某些情况下,我们可以懒惰地像普通方法一样调用 Object.prototype
的方法(不需要 .call()
或 .bind()
):如果我们知道接收者并且它们是固定布局的对象。
另一方面,如果我们不知道它们的接收者和/或它们是字典对象,那么我们需要采取预防措施。
29.8.2 Object.prototype.toString()
通过重写 .toString()
(在子类或实例中),我们可以配置对象如何转换为字符串:
> String({toString() { return 'Hello!' }})
'Hello!'
> String({})
'[object Object]'
将对象转换为字符串最好使用 String()
,因为它也适用于 undefined
和 null
:
> undefined.toString()
TypeError: Cannot read properties of undefined (reading 'toString')
> null.toString()
TypeError: Cannot read properties of null (reading 'toString')
> String(undefined)
'undefined'
> String(null)
'null'
29.8.3 Object.prototype.toLocaleString()
.toLocaleString()
是 .toString()
的一个版本,可以通过区域设置和通常的附加选项进行配置。任何类或实例都可以实现这个方法。在标准库中,以下类可以实现:
-
Array.prototype.toLocaleString()
-
Number.prototype.toLocaleString()
-
Date.prototype.toLocaleString()
-
TypedArray.prototype.toLocaleString()
-
BigInt.prototype.toLocaleString()
例如,这是十进制小数如何根据区域设置(‘fr’ 是法语,‘en’ 是英语)而被转换为不同的字符串的示例:
> 123.45.toLocaleString('fr')
'123,45'
> 123.45.toLocaleString('en')
'123.45'
29.8.4 Object.prototype.valueOf()
通过重写 .valueOf()
(在子类或实例中),我们可以配置对象如何转换为非字符串值(通常是数字):
> Number({valueOf() { return 123 }})
123
> Number({})
NaN
29.8.5 Object.prototype.isPrototypeOf()
proto.isPrototypeOf(obj)
如果 proto
在 obj
的原型链中则返回 true
,否则返回 false
。
const a = {};
const b = {__proto__: a};
const c = {__proto__: b};
assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);
assert.equal(a.isPrototypeOf(a), false);
assert.equal(c.isPrototypeOf(a), false);
这是如何安全使用这个方法的(详情见§29.8.1 “安全使用 Object.prototype
方法”):
const obj = {
// Overrides Object.prototype.isPrototypeOf
isPrototypeOf: true,
};
// Doesn’t work in this case:
assert.throws(
() => obj.isPrototypeOf(Object.prototype),
{
name: 'TypeError',
message: 'obj.isPrototypeOf is not a function',
}
);
// Safe way of using .isPrototypeOf():
assert.equal(
Object.prototype.isPrototypeOf.call(obj, Object.prototype), false
);
29.8.6 Object.prototype.propertyIsEnumerable()
obj.propertyIsEnumerable(propKey)
如果 obj
具有一个自有可枚举属性,其键为 propKey
则返回 true
,否则返回 false
。
const proto = {
enumerableProtoProp: true,
};
const obj = {
__proto__: proto,
enumerableObjProp: true,
nonEnumObjProp: true,
};
Object.defineProperty(
obj, 'nonEnumObjProp',
{
enumerable: false,
}
);
assert.equal(
obj.propertyIsEnumerable('enumerableProtoProp'),
false // not an own property
);
assert.equal(
obj.propertyIsEnumerable('enumerableObjProp'),
true
);
assert.equal(
obj.propertyIsEnumerable('nonEnumObjProp'),
false // not enumerable
);
assert.equal(
obj.propertyIsEnumerable('unknownProp'),
false // not a property
);
这是如何安全使用这个方法的(详情见§29.8.1 “安全使用 Object.prototype
方法”):
const obj = {
// Overrides Object.prototype.propertyIsEnumerable
propertyIsEnumerable: true,
enumerableProp: 'yes',
};
// Doesn’t work in this case:
assert.throws(
() => obj.propertyIsEnumerable('enumerableProp'),
{
name: 'TypeError',
message: 'obj.propertyIsEnumerable is not a function',
}
);
// Safe way of using .propertyIsEnumerable():
assert.equal(
Object.prototype.propertyIsEnumerable.call(obj, 'enumerableProp'),
true
);
另一个安全的替代方法是使用属性描述符:
assert.deepEqual(
Object.getOwnPropertyDescriptor(obj, 'enumerableProp'),
{
value: 'yes',
writable: true,
enumerable: true,
configurable: true,
}
);
29.8.7 Object.prototype.__proto__
(访问器)
属性 __proto__
存在两个版本:
-
所有
Object
的实例都有的访问器。 -
对象文字的一个属性,它设置了由它们创建的对象的原型。
我建议避免前一种特性:
-
如§29.8.1 “Using
Object.prototype
methods safely”中所解释的,它并不适用于所有对象。 -
ECMAScript 规范已经将其弃用,并称其为“可选的”和“遗留的”(https://tc39.es/ecma262/#sec-object.prototype.proto)。
相比之下,对象文字中的__proto__
总是有效的,而且没有被弃用。
如果你对访问器__proto__
的工作原理感兴趣,请继续阅读。
__proto__
是Object.prototype
的一个访问器,它被所有Object
的实例继承。通过类实现它会像这样:
class Object {
get __proto__() {
return Object.getPrototypeOf(this);
}
set __proto__(other) {
Object.setPrototypeOf(this, other);
}
// ···
}
由于__proto__
是从Object.prototype
继承的,我们可以通过创建一个不在其原型链中具有Object.prototype
的对象来移除这个特性(参见§29.7.3 “Not all objects are instances of Object
”):
> '__proto__' in {}
true
> '__proto__' in Object.create(null)
false
29.8.8 Object.prototype.hasOwnProperty()
Better alternative to
.hasOwnProperty()
: Object.hasOwn()
[ES2022]
参见§28.9.4 “Object.hasOwn()
: Is a given property own (non-inherited)? [ES2022]”。
obj.hasOwnProperty(propKey)
如果obj
有一个自有的(非继承的)属性,其键是propKey
,则返回true
,否则返回false
。
const obj = { ownProp: true };
assert.equal(
obj.hasOwnProperty('ownProp'), true // own
);
assert.equal(
'toString' in obj, true // inherited
);
assert.equal(
obj.hasOwnProperty('toString'), false
);
这是如何安全使用这个方法的(详情见§29.8.1 “Using Object.prototype
methods safely”):
const obj = {
// Overrides Object.prototype.hasOwnProperty
hasOwnProperty: true,
};
// Doesn’t work in this case:
assert.throws(
() => obj.hasOwnProperty('anyPropKey'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
// Safe way of using .hasOwnProperty():
assert.equal(
Object.prototype.hasOwnProperty.call(obj, 'anyPropKey'), false
);
29.9 常见问题:类
29.9.1 为什么在本书中称它们为“实例私有字段”,而不是“私有实例字段”?
这样做是为了突出不同的属性(公共槽)和私有槽:通过改变形容词的顺序,“public”和“field”以及“private”和“field”这些词总是一起提到。
29.9.2 为什么标识符前缀#
?为什么不通过private
声明私有字段?
私有字段是否可以通过private
声明并使用普通标识符?让我们来看看如果可能的话会发生什么:
class MyClass {
private value; // (A)
compare(other) {
return this.value === other.value;
}
}
每当other.value
这样的表达式出现在MyClass
的主体中时,JavaScript 都必须决定:
-
.value
是一个属性吗? -
.value
是一个私有字段吗?
在编译时,JavaScript 不知道 A 行的声明是否适用于other
(因为它是MyClass
的一个实例)或者不适用。这留下了两种选择来做决定:
-
.value
总是被解释为私有字段。 -
JavaScript 在运行时决定:
-
如果
other
是MyClass
的一个实例,那么.value
会被解释为私有字段。 -
否则
.value
被解释为属性。
-
这两个选项都有缺点:
-
使用选项(1),我们不能再将
.value
作为属性使用——对于任何对象都是如此。 -
选项(2)会对性能产生负面影响。
这就是为什么引入了名字前缀#
。现在决定很容易:如果我们使用#
,我们想要访问一个私有字段。如果不使用,我们想要访问一个属性。
private
适用于静态类型语言(如 TypeScript),因为它们在编译时知道other
是否是MyClass
的一个实例,然后可以将.value
视为私有或公共的。
Quiz
参见 quiz app。