...) [ES2018]Object.assign() [ES6]thisthis.call().bind()this 陷阱:提取方法this 陷阱:意外遮蔽 thisthis 在各种上下文中的值(高级)in 运算符:是否存在具有给定键的属性?Object.keys() 等列出属性键Object.values() 列出属性值Object.entries() 列出属性条目 [ES2017]Object.fromEntries() 组装对象 [ES2019]Object.hasOwn():给定属性是自身属性(非继承的)吗? [ES2022]在本书中,JavaScript 的面向对象编程 (OOP) 风格分四个步骤介绍。本章涵盖步骤 1 和 2;下一章涵盖步骤 3 和 4。步骤如下(图 8)
通过 对象字面量 创建对象(以花括号开头和结尾)
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};原型是 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
);注意
每个对象都继承其原型的所有属性
// `obj2` inherits .protoProp from `proto`
assert.equal(
obj2.protoProp, 'protoProp'
);
assert.deepEqual(
Reflect.ownKeys(obj2),
['objProp'] // own properties of `obj2`
);对象的非继承属性称为其 自身 属性。
原型最重要的用例是多个对象可以通过从公共原型继承方法来共享方法。
JavaScript 中的对象
在 JavaScript 中有两种使用对象的方式
固定布局对象:以这种方式使用时,对象的工作方式类似于数据库中的记录。它们具有固定数量的属性,其键在开发时已知。它们的值通常具有不同的类型。
const fixedLayoutObject = {
product: 'carrot',
quantity: 4,
};字典对象:以这种方式使用时,对象的工作方式类似于查找表或映射。它们具有可变数量的属性,其键在开发时未知。它们的所有值都具有相同的类型。
const dictionaryObject = {
['one']: 1,
['two']: 2,
};请注意,这两种方式也可以混合使用:某些对象既是固定布局对象又是字典对象。
使用对象的方式会影响本章对它们的解释方式
让我们首先探索 固定布局对象。
对象字面量 是创建固定布局对象的一种方式。它们是 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' ]每当属性的值是通过与键同名的变量定义时,我们都可以省略该键。
function createPoint(x, y) {
return {x, y}; // Same as: {x: x, y: y}
}
assert.deepEqual(
createPoint(9, 2),
{ x: 9, y: 2 }
);这就是我们 获取(读取)属性的方式(A 行)
const jane = {
first: 'Jane',
last: 'Doe',
};
// Get property .first
assert.equal(jane.first, 'Jane'); // (A)获取未知属性会产生 undefined
assert.equal(jane.unknownProperty, undefined);这就是我们 设置(写入)属性的方式(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']);以下代码展示了如何通过对象字面量创建方法 .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。
访问器 是通过对象字面量内部的语法定义的,它看起来像方法:一个 getter 和/或一个 setter(即,每个访问器都有其中一个或两个)。
调用访问器看起来像访问值属性
getter 是通过在方法定义前加上修饰符 get 来创建的
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');setter 是通过在方法定义前加上修饰符 set 来创建的
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
...) [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,
}
);我们可以使用展开来创建对象 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 也会更改原始对象中的 .b。
copy.b.prop = false;
assert.deepEqual(
original, { a: 1, b: {prop: false} }); JavaScript 不支持内置的深度复制
对象的*深度复制*(复制所有级别)通常很难通用地实现。因此,JavaScript 目前没有内置的操作来实现它。如果我们需要这样的操作,我们必须自己实现。
如果我们代码的输入之一是一个包含数据的对象,我们可以通过指定在缺少这些属性时使用的默认值,使属性成为可选的。一种实现方法是使用一个对象,其属性包含默认值。在下面的示例中,该对象是 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'});到目前为止,我们已经遇到了一种更改对象属性 .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
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() 的用例与展开属性的用例相似。在某种程度上,它是破坏性地展开。
this让我们回顾一下用于介绍方法的示例
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}”`;
},
};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 是方法调用的*接收者*。它通过一个名为 this 的隐式(隐藏)参数传递给存储在 obj.someMethod 中的函数(A 行)。
如何理解
this
理解 this 的最佳方式是将其视为普通函数(因此也是方法)的隐式参数。
.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.propobj.prop()它们的不同之处在于,(2) 不仅仅是 (1) 后面跟着函数调用运算符 ()。相反,(2) 还提供了一个 this 的值。
.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() 将无法正常工作,因为在 A 行中使用了 this。在下一节中,我们将探讨原因。
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”');以下是我们在实际 Web 开发中可能会看到的代码的简化版本
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() 都会创建一个新函数。这就是为什么如果我们想稍后删除它,我们需要将结果存储在某个地方。
唉,提取方法的陷阱没有简单的解决方法:每当我们提取方法时,我们都必须小心谨慎地正确执行——例如,通过绑定 this 或使用箭头函数。
练习:提取方法
exercises/objects/method_extraction_exrc.mjs
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() 为回调函数指定一个固定的 this(A 行)
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)
},this 的陷阱如果我们遵循 §25.3.4 “建议:优先使用专用函数而不是普通函数” 中的建议,我们就可以避免意外遮蔽 this 的陷阱。以下是总结
将箭头函数用作匿名内联函数。它们没有 this 作为隐式参数,也不会遮蔽它。
对于命名的独立函数声明,我们可以使用箭头函数或函数声明。如果我们选择后者,我们必须确保在它们的函数体中没有提到 this。
this 在各种上下文中的值(高级)this 在各种上下文中的值是什么?
在可调用实体内部,this 的值取决于可调用实体是如何被调用的,以及它是什么类型的可调用实体
this === undefined(在 严格模式下)this 与外部作用域中的相同(词法 this)this 是调用的接收者new:this 指的是新创建的实例我们也可以在所有常见的顶级作用域中访问 this
<script> 元素:this === globalThisthis === undefinedthis === module.exports 提示:假装顶级作用域中不存在
this
我喜欢这样做,因为顶级 this 令人困惑,而且它的(少数)用例有更好的替代方案。
存在以下几种可选链式操作
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'考虑以下数据
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]
);空值合并运算符 允许我们使用默认值 '(no name)' 而不是 undefined
const streetNames = persons.map(
p => p.address?.street?.name ?? '(no name)');
assert.deepEqual(
streetNames, ['Sesame Street', '(no name)', '(no name)']
);以下两个表达式是等效的
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);以下两个表达式是等效的
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);以下两个表达式是等效的
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 除外)很可能是一个错误,应该报告,而不是绕过它。
在一系列属性获取和方法调用中,一旦第一个可选运算符在其左侧遇到 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。可选链式操作也有缺点
可选链式操作的一种替代方法是在单个位置一次性提取信息
使用这两种方法,都可以执行检查,并在出现问题时尽早失败。
扩展阅读
?.),有什么好的助记符吗?您是否偶尔不确定可选链运算符是以点 (.?) 还是问号 (?.) 开头的?那么这个助记符可能会对您有所帮助
?) 左侧不为空.) 访问属性。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) : []null?.prop 的计算结果为 undefined 而不是 null?运算符 ?. 主要与其右侧有关:属性 .prop 是否存在?如果不存在,则提前停止。因此,保留有关其左侧的信息很少有用。但是,只有一个“提前终止”值确实可以简化事情。
对象最适合作为固定布局对象。但在 ES6 之前,JavaScript 没有用于字典的数据结构(ES6 带来了 映射)。因此,对象必须用作字典,这带来了一个重大限制:字典键必须是字符串(符号也是在 ES6 中引入的)。
我们首先看一下与字典相关的对象特性,这些特性对固定布局对象也很有用。本节最后提供了一些将对象实际用作字典的技巧。(剧透:如果可能,最好使用映射。)
到目前为止,我们一直使用固定布局对象。属性键是固定的标记,必须是有效的标识符,并且在内部成为字符串
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!');在上一小节中,属性键是通过对象字面量中的固定字符串指定的。在本节中,我们将学习如何动态计算属性键。这使我们能够使用任意字符串或符号。
对象字面量中动态计算的属性键的语法灵感来自动态访问属性。也就是说,我们可以使用方括号来包装表达式
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
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);我们还可以使用真值检查来确定属性是否存在
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'我们可以通过 delete 运算符删除属性
const obj = {
myProp: 123,
};
assert.deepEqual(Object.keys(obj), ['myProp']);
delete obj.myProp;
assert.deepEqual(Object.keys(obj), []);可枚举性是属性的属性。某些操作会忽略不可枚举的属性,例如 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() 将在本章稍后解释。下一小节将展示这些操作如何受可枚举性的影响
Object.keys() 等列出属性键| 可枚举 | 不可枚举 | 字符串 | 符号 | |
|---|---|---|---|---|
Object.keys() |
✔ |
✔ |
||
Object.getOwnPropertyNames() |
✔ |
✔ |
✔ |
|
Object.getOwnPropertySymbols() |
✔ |
✔ |
✔ |
|
Reflect.ownKeys() |
✔ |
✔ |
✔ |
✔ |
表 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,
]
);Object.values() 列出属性值Object.values() 列出对象的所有可枚举字符串键属性的值
const firstName = Symbol('firstName');
const obj = {
[firstName]: 'Jane',
lastName: 'Doe',
};
assert.deepEqual(
Object.values(obj),
['Doe']);Object.entries() 列出属性条目 [ES2017]Object.entries() 将所有可枚举的字符串键属性列为键值对。每对都编码为一个包含两个元素的数组
const firstName = Symbol('firstName');
const obj = {
[firstName]: 'Jane',
lastName: 'Doe',
};
assert.deepEqual(
Object.entries(obj),
[
['lastName', 'Doe'],
]);Object.entries() 的简单实现以下函数是 Object.entries() 的简化版本
function entries(obj) {
return Object.keys(obj)
.map(key => [key, obj[key]]);
} 练习:
Object.entries()
exercises/objects/find_key_test.mjs
对象的自身(非继承)属性始终按以下顺序列出
以下示例演示了如何根据这些规则对属性键进行排序
> Object.keys({b:0,a:0, 10:0,2:0})
[ '2', '10', 'b', 'a' ] 属性的顺序
ECMAScript 规范更详细地描述了属性的排序方式。
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 中的两个工具函数。
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);
}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);
}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
如果我们将普通对象(通过对象字面量创建)用作字典,则必须注意两个陷阱。
第一个陷阱是 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), []);那么我们如何避免这两个陷阱呢?
以下代码演示了如何使用无原型对象作为字典
const dict = Object.create(null); // prototype is `null`
assert.equal('toString' in dict, false); // (A)
dict['__proto__'] = 123;
assert.deepEqual(Object.keys(dict), ['__proto__']);我们避免了这两个陷阱
__proto__ 是通过 Object.prototype 实现的。这意味着如果 Object.prototype 不在原型链中,它将被关闭。 练习:将对象用作字典
exercises/objects/simple_dict_test.mjs
正如对象由属性组成一样,属性也由属性组成。属性的值只是几个属性之一。其他包括
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), []);扩展阅读
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。
原型是 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 的原型链是什么样的。
非继承属性称为自身属性。obj 有一个自身属性 .objProp。
某些操作会考虑所有属性(自身属性和继承属性),例如获取属性
> const obj = { one: 1 };
> typeof obj.one // own
'number'
> typeof obj.toString // inherited
'function'其他操作只考虑自身属性——例如,Object.keys()
> Object.keys(obj)
[ 'one' ]继续阅读另一个也只考虑自身属性的操作:设置属性。
给定一个对象 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 所示。
关于 __proto__ 的建议
不要将 __proto__ 用作伪属性(所有 Object 实例的设置器)
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);到目前为止,“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()”。
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()”。此特性存在陷阱,但引用的章节解释了如何解决这些陷阱。
考虑以下代码
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 的属性。顶部的对象包含它们之间共享的属性。
当我们进行方法调用 jane.describe() 时,this 指向该方法调用的接收者 jane(在图的左下角)。这就是该方法仍然有效的原因。tarzan.describe() 的工作原理类似。
assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');展望下一章关于类的内容——这就是类在内部的组织方式
§29.3 “类的内部结构” 更详细地解释了这一点。
原则上,对象是无序的。对属性进行排序的主要原因是为了使列出条目、键或值的操作具有确定性。这有助于例如测试。
测验
请参阅 测验应用程序。