面向急性子的程序员的 JavaScript (ES2021 版)
请支持本书:购买捐赠
(广告,请不要屏蔽。)

28 单个对象



在本书中,JavaScript 的面向对象编程 (OOP) 风格分四步介绍。本章涵盖步骤 1;下一章 涵盖步骤 2-4。步骤如下(图 8

  1. **单个对象(本章):** JavaScript 的基本 OOP 构建块 *对象* 如何独立工作?
  2. **原型链(下一章):** 每个对象都有一个由零个或多个 *原型对象* 组成的链。原型是 JavaScript 的核心继承机制。
  3. **类(下一章):** JavaScript 的 *类* 是对象的工厂。类与其实例之间的关系基于原型继承。
  4. **子类化(下一章):** *子类* 与其 *超类* 之间的关系也基于原型继承。
Figure 8: This book introduces object-oriented programming in JavaScript in four steps.

28.1 什么是对象?

在 JavaScript 中

28.1.1 对象的角色:记录与字典

对象在 JavaScript 中扮演着两个角色

这些角色会影响本章中对对象的解释方式

28.2 对象作为记录

让我们首先探讨对象的 *记录* 角色。

28.2.1 对象字面量:属性

*对象字面量* 是创建作为记录的对象的一种方式。它们是 JavaScript 的一个突出特性:我们可以直接创建对象——不需要类!这是一个例子

const jane = {
  first: 'Jane',
  last: 'Doe', // optional trailing comma
};

在这个例子中,我们通过一个对象字面量创建了一个对象,它以花括号 {} 开始和结束。在它里面,我们定义了两个 *属性*(键值对)

从 ES5 开始,对象字面量中允许使用尾随逗号。

稍后我们将看到其他指定属性键的方法,但使用这种指定方法时,它们必须遵循 JavaScript 变量名的规则。例如,我们可以使用 first_name 作为属性键,但不能使用 first-name)。但是,允许使用保留字

const obj = {
  if: true,
  const: true,
};

为了检查各种操作对对象的影响,我们将在本章的这一部分偶尔使用 Object.keys()。它列出属性键

> Object.keys({a:1, b:2})
[ 'a', 'b' ]

28.2.2 对象字面量:属性值简写

每当属性的值是通过变量名定义的,并且该名称与键相同时,我们可以省略该键。

function createPoint(x, y) {
  return {x, y};
}
assert.deepEqual(
  createPoint(9, 2),
  { x: 9, y: 2 }
);

28.2.3 获取属性

这就是我们 *获取*(读取)属性的方式(A 行)

const jane = {
  first: 'Jane',
  last: 'Doe',
};

// Get property .first
assert.equal(jane.first, 'Jane'); // (A)

获取未知属性会产生 undefined

assert.equal(jane.unknownProperty, undefined);

28.2.4 设置属性

这就是我们 *设置*(写入)属性的方式

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.2.5 对象字面量:方法

以下代码展示了如何通过对象字面量创建方法 .says()

const jane = {
  first: 'Jane', // data 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.4 “方法和特殊变量 this)。这使得方法 .says() 能够访问 A 行中的兄弟属性 .first

28.2.6 对象字面量:访问器

JavaScript 中有两种访问器

28.2.6.1 获取器

获取器是通过在方法定义前加上修饰符 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');
28.2.6.2 设置器

设置器是通过在方法定义前加上修饰符 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/single-objects/color_point_object_test.mjs

28.3 展开到对象字面量 (...) [ES2018]

在函数调用中,展开 (...) 将 *可迭代对象* 的迭代值转换为参数。

在对象字面量中,*展开属性* 将另一个对象的属性添加到当前对象中

> const obj = {foo: 1, bar: 2};
> {...obj, baz: 3}
{ foo: 1, bar: 2, baz: 3 }

如果属性键发生冲突,则最后提到的属性“获胜”

> const obj = {foo: 1, bar: 2, baz: 3};
> {...obj, foo: true}
{ foo: true, bar: 2, baz: 3 }
> {foo: true, ...obj}
{ foo: 1, bar: 2, baz: 3 }

所有值都是可展开的,甚至是 undefinednull

> {...undefined}
{}
> {...null}
{}
> {...123}
{}
> {...'abc'}
{ '0': 'a', '1': 'b', '2': 'c' }
> {...['a', 'b']}
{ '0': 'a', '1': 'b' }

字符串和数组的属性 .length 在这种操作中是隐藏的(它不可 *枚举*;有关更多信息,请参阅 §28.8.3 “属性特性和属性描述符 [ES5]”)。

28.3.1 展开的用例:复制对象

我们可以使用展开来创建对象 original 的副本

const copy = {...original};

注意——复制是 *浅* 的:copy 是一个新对象,其中包含 original 的所有属性(键值对)的副本。但是,如果属性值是对象,则不会复制这些对象本身;它们在 originalcopy 之间共享。让我们看一个例子

const original = { a: 1, b: {foo: true} };
const copy = {...original};

copy 的第一层确实是一个副本:如果我们更改该层上的任何属性,它不会影响原始对象

copy.a = 2;
assert.deepEqual(
  original, { a: 1, b: {foo: true} }); // no change

但是,不会复制更深的层。例如,.b 的值在原始对象和副本之间共享。在副本中更改 .b 也会在原始对象中更改它。

copy.b.foo = false;
assert.deepEqual(
  original, { a: 1, b: {foo: false} });

  **JavaScript 不支持内置的深度复制**

*深度复制* 对象(复制所有层)通常很难通用地完成。因此,JavaScript 没有针对它们的内置操作(目前)。如果我们需要这样的操作,我们必须自己实现它。

28.3.2 展开的用例:缺少属性的默认值

如果我们代码的一个输入是一个包含数据的对象,我们可以通过指定在缺少这些属性时使用的默认值来使属性可选。一种实现此目的的技术是通过一个对象,其属性包含默认值。在以下示例中,该对象是 DEFAULTS

const DEFAULTS = {foo: 'a', bar: 'b'};
const providedData = {foo: 1};

const allData = {...DEFAULTS, ...providedData};
assert.deepEqual(allData, {foo: 1, bar: 'b'});

结果对象 allData 是通过复制 DEFAULTS 并使用 providedData 的属性覆盖其属性来创建的。

但我们不需要一个对象来指定默认值;我们也可以在对象字面量中单独指定它们

const providedData = {foo: 1};

const allData = {foo: 'a', bar: 'b', ...providedData};
assert.deepEqual(allData, {foo: 1, bar: 'b'});

28.3.3 展开的用例:非破坏性地更改属性

到目前为止,我们已经遇到了一种更改对象的属性 .foo 的方法:我们 *设置* 它(A 行)并改变对象。也就是说,这种更改属性的方式是 *破坏性* 的。

const obj = {foo: 'a', bar: 'b'};
obj.foo = 1; // (A)
assert.deepEqual(obj, {foo: 1, bar: 'b'});

通过展开,我们可以 *非破坏性* 地更改 .foo——我们创建一个 obj 的副本,其中 .foo 具有不同的值

const obj = {foo: 'a', bar: 'b'};
const updatedObj = {...obj, foo: 1};
assert.deepEqual(updatedObj, {foo: 1, bar: 'b'});

  **练习:通过展开非破坏性地更新属性(固定键)**

exercises/single-objects/update_name_test.mjs

28.4 方法和特殊变量 this

28.4.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.4.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 是方法调用的 *接收者*。它通过一个名为 this 的隐式(隐藏)参数传递给存储在 obj.someMethod 中的函数(A 行)。

这是一个重点:理解 this 最佳的方式是将其视为普通函数(因此也是方法)的隐式参数。

28.4.3 方法和 .call()

方法是函数,在 §25.7 “函数的方法: .call().apply().bind() 中,我们看到函数本身也有方法。其中一个方法是 .call()。让我们看一个例子来理解这个方法是如何工作的。

在上一节中,有这样一个方法调用

obj.someMethod('a', 'b')

此调用等效于

obj.someMethod.call(obj, 'a', 'b');

也等效于

const func = obj.someMethod;
func.call(obj, 'a', 'b');

.call() 使通常隐式的参数 this 显式化:当通过 .call() 调用函数时,第一个参数是 this,后面跟着常规的(显式)函数参数。

顺便说一句,这意味着实际上有两个不同的点运算符

  1. 一个用于访问属性:obj.prop
  2. 另一个用于调用方法:obj.prop()

它们的不同之处在于 (2) 不仅仅是 (1) 后面跟着函数调用运算符 ()。相反,(2) 还提供了一个 this 的值。

28.4.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() 将无法正常工作,因为在 A 行中使用了 this。在下一节中,我们将探讨原因。

28.4.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 property 'first' of undefined",
  });

在 A 行中,我们正在进行一个普通的函数调用。在普通的函数调用中,thisundefined(如果 严格模式 处于活动状态,几乎总是如此)。因此,A 行等效于

assert.throws(
  () => jane.says.call(undefined, 'hello'), // `this` is undefined!
  {
    name: 'TypeError',
    message: "Cannot read property 'first' of undefined",
  });

我们如何解决这个问题?我们需要使用 .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.4.5.1 示例:提取方法

以下是我们在实际 Web 开发中可能会看到的代码的简化版本

class ClickHandler {
  constructor(id, elem) {
    this.id = id;
    elem.addEventListener('click', this.handleClick); // (A)
  }
  handleClick(event) {
    alert('Clicked ' + this.id);
  }
}

在 A 行中,我们没有正确地提取方法 .handleClick()。相反,我们应该这样做

elem.addEventListener('click', this.handleClick.bind(this));
28.4.5.2 如何避免提取方法的陷阱

唉,没有简单的方法可以绕过提取方法的陷阱:每当我们提取方法时,我们都必须小心谨慎地正确操作——例如,通过绑定 this 或使用箭头函数。

  练习:提取方法

exercises/single-objects/method_extraction_exrc.mjs

28.4.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']),
  /^TypeError: Cannot read property 'prefix' of undefined$/);

在 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)
},
28.4.6.1 避免意外遮蔽 this 的陷阱

如果您遵循 §25.3.4 “建议:优先使用专用函数而不是普通函数” 中的建议,则可以避免意外遮蔽 this 的陷阱。以下是总结

28.4.7 this 在各种上下文中的值(高级)

this 在各种上下文中的值是什么?

在可调用实体内部,this 的值取决于如何调用可调用实体以及它是什么类型的可调用实体

我们还可以在所有常见的顶级作用域中访问 this

  提示:假装顶级作用域中不存在 this

我喜欢这样做,因为顶级 this 令人困惑,而且很少有用。

28.5 用于属性访问和方法调用的可选链 [ES2020](高级)

存在以下几种可选链操作

obj?.prop     // optional static property access
obj?.[«expr»] // optional dynamic property access
func?.(«arg0», «arg1») // optional function or method call

大致思路是

28.5.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.5.1.1 通过空值合并处理默认值

空值合并运算符 允许我们使用默认值 '(无街道)' 而不是 undefined

const streetNames = persons.map(
  p => p.address?.street?.name ?? '(no name)');
assert.deepEqual(
  streetNames, ['Sesame Street', '(no name)', '(no name)']
);

28.5.2 运算符的详细说明(高级)

28.5.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.5.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.5.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);

为什么?其理念是该运算符只容忍故意的省略。不可调用的值(undefinednull 除外)很可能是一个错误,应该报告,而不是绕过。

28.5.3 短路求值(高级)

在属性访问和函数/方法调用链中,一旦第一个可选运算符在其左侧遇到 undefinednull,求值就会停止

function isInvoked(obj) {
  let invoked = false;
  obj?.a.b.m(invoked = true);
  return invoked;
}

assert.equal(
  isInvoked({a: {b: {m() {}}}}), true);
  
// The left-hand side of ?. is undefined
// and the assignment is not executed
assert.equal(
  isInvoked(undefined), false);

此行为不同于普通运算符/函数,在普通运算符/函数中,JavaScript 始终在求值运算符/函数之前求值所有操作数/参数。这被称为短路求值。其他短路求值运算符:

28.5.4 常见问题解答

28.5.4.1 为什么 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.5.4.2 为什么 null?.prop 的结果是 undefined 而不是 null

运算符 ?. 主要与其右侧有关:属性 .prop 是否存在?如果不存在,则提前停止。因此,保留有关其左侧的信息很少有用。但是,只有一个“提前终止”值确实可以简化事情。

28.6 对象作为字典(高级)

对象最适合作为记录使用。但在 ES6 之前,JavaScript 没有用于字典的数据结构(ES6 带来了 Map)。因此,对象必须用作字典,这带来了一个很大的限制:键必须是字符串(符号也是在 ES6 中引入的)。

我们首先来看一下与字典相关的对象特性,这些特性对作为记录的对象也很有用。本节最后将介绍实际使用对象作为字典的技巧(剧透:如果可以的话,请使用 Map)。

28.6.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.6.2 计算属性键

到目前为止,属性键始终是对象字面量中的固定字符串。在本节中,我们将学习如何动态计算属性键。这使我们能够使用任意字符串或符号。

对象字面量中动态计算的属性键的语法灵感来自动态访问属性。也就是说,我们可以使用方括号将表达式括起来

const obj = {
  ['Hello world!']: true,
  ['f'+'o'+'o']: 123,
  [Symbol.toStringTag]: 'Goodbye', // (A)
};

assert.equal(obj['Hello world!'], true);
assert.equal(obj.foo, 123);
assert.equal(obj[Symbol.toStringTag], 'Goodbye');

计算键的主要用例是将符号作为属性键(A 行)。

请注意,用于获取和设置属性的方括号运算符适用于任意表达式

assert.equal(obj['f'+'o'+'o'], 123);
assert.equal(obj['==> foo'.slice(-3)], 123);

方法也可以具有计算的属性键

const methodKey = Symbol();
const obj = {
  [methodKey]() {
    return 'Yes!';
  },
};

assert.equal(obj[methodKey](), 'Yes!');

在本章的其余部分,我们将主要再次使用固定的属性键(因为它们的语法更方便)。但所有特性也适用于任意字符串和符号。

  练习:通过展开运算符非破坏性地更新属性(计算键)

exercises/single-objects/update_property_test.mjs

28.6.3 in 运算符:是否存在具有给定键的属性?

in 运算符检查对象是否具有具有给定键的属性

const obj = {
  foo: 'abc',
  bar: false,
};

assert.equal('foo' in obj, true);
assert.equal('unknownKey' in obj, false);
28.6.3.1 通过真值检查属性是否存在

我们还可以使用真值检查来确定属性是否存在

assert.equal(
  obj.foo ? 'exists' : 'does not exist',
  'exists');
assert.equal(
  obj.unknownKey ? 'exists' : 'does not exist',
  'does not exist');

前面的检查之所以有效,是因为 obj.foo 是真值,并且因为读取缺少的属性会返回 undefined(它是假值)。

但是,有一个重要的注意事项:如果属性存在但其值为假值(undefinednullfalse0"" 等),则真值检查会失败

assert.equal(
  obj.bar ? 'exists' : 'does not exist',
  'does not exist'); // should be: 'exists'

28.6.4 删除属性

我们可以使用 delete 运算符删除属性

const obj = {
  foo: 123,
};
assert.deepEqual(Object.keys(obj), ['foo']);

delete obj.foo;

assert.deepEqual(Object.keys(obj), []);

28.6.5 列出属性键

表 19:用于列出自身(非继承)属性键的标准库方法。它们都返回包含字符串和/或符号的数组。
可枚举 不可枚举 字符串 符号
Object.keys()
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Reflect.ownKeys()

表 19 中的每个方法都返回一个数组,其中包含参数的自有属性键。在方法的名称中,我们可以看到做出了以下区分

下一节将介绍术语可枚举,并演示每个方法。

28.6.5.1 可枚举性

可枚举性是属性的特性。不可枚举的属性会被某些操作忽略,例如,会被 Object.keys()(参见表 19)和展开属性忽略。默认情况下,大多数属性都是可枚举的。下面的示例展示了如何更改它。它还演示了列出属性键的各种方法。

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,
  },
});

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.defineProperties() 将在本章稍后解释。

28.6.6 通过 Object.values() 列出属性值

Object.values() 列出对象所有可枚举属性的值

const obj = {foo: 1, bar: 2};
assert.deepEqual(
  Object.values(obj),
  [1, 2]);

28.6.7 通过 Object.entries() 列出属性条目 [ES2017]

Object.entries() 列出可枚举属性的键值对。每对都编码为一个包含两个元素的数组

const obj = {foo: 1, bar: 2};
assert.deepEqual(
  Object.entries(obj),
  [
    ['foo', 1],
    ['bar', 2],
  ]);

  练习:Object.entries()

exercises/single-objects/find_key_test.mjs

28.6.8 属性按确定性顺序列出

对象的自身(非继承)属性始终按以下顺序列出

  1. 包含整数索引的字符串键属性(包括数组索引
    按升序数字顺序
  2. 剩余的字符串键属性
    按添加顺序
  3. 符号键属性
    按添加顺序

以下示例演示了如何根据这些规则对属性键进行排序

> Object.keys({b:0,a:0, 10:0,2:0})
[ '2', '10', 'b', 'a' ]

  属性的顺序

ECMAScript 规范更详细地描述了属性的排序方式。

28.6.9 通过 Object.fromEntries() 组装对象 [ES2019]

给定一个 [键,值] 对的可迭代对象,Object.fromEntries() 会创建一个对象

assert.deepEqual(
  Object.fromEntries([['foo',1], ['bar',2]]),
  {
    foo: 1,
    bar: 2,
  }
);

Object.fromEntries()Object.entries() 相反。

为了演示两者,我们将在接下来的小节中使用它们来实现库 Underscore 中的两个工具函数。

28.6.9.1 示例:pick(object, ...keys)

pick 返回 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.6.9.2 示例:invert(object)

invert 返回 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.6.9.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/single-objects/omit_properties_test.mjs

28.6.10 使用对象作为字典的陷阱

如果我们使用普通对象(通过对象字面量创建)作为字典,我们必须注意两个陷阱。

第一个陷阱是 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.6.10.1 安全地使用对象作为字典

那么如何避免这两个陷阱呢?

以下代码演示了使用没有原型的对象作为字典

const dict = Object.create(null); // no prototype

assert.equal('toString' in dict, false); // (A)

dict['__proto__'] = 123;
assert.deepEqual(Object.keys(dict), ['__proto__']);

我们避免了这两个陷阱

  练习:使用对象作为字典

exercises/single-objects/simple_dict_test.mjs

28.7 标准方法(高级)

Object.prototype 定义了几个标准方法,可以通过覆盖这些方法来配置语言如何处理对象。其中两个重要的是

28.7.1 .toString()

.toString() 确定如何将对象转换为字符串

> String({toString() { return 'Hello!' }})
'Hello!'
> String({})
'[object Object]'

28.7.2 .valueOf()

.valueOf() 确定如何将对象转换为数字

> Number({valueOf() { return 123 }})
123
> Number({})
NaN

28.8 高级主题

以下小节简要概述了一些高级主题。

28.8.1 Object.assign() [ES6]

Object.assign() 是一个工具方法

Object.assign(target, source_1, source_2, ···)

此表达式将 source_1 的所有属性分配给 target,然后分配 source_2 的所有属性,依此类推。最后,它返回 target,例如

const target = { foo: 1 };

const result = Object.assign(
  target,
  {bar: 2},
  {baz: 3, bar: 4});

assert.deepEqual(
  result, { foo: 1, bar: 4, baz: 3 });
// target was modified and returned:
assert.equal(result, target);

Object.assign() 的用例与展开属性的用例类似。在某种程度上,它是破坏性地展开。

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(obj) 是浅层冻结。也就是说,只冻结 obj 的属性,而不冻结存储在属性中的对象。

  更多信息

有关冻结和其他锁定对象方法的更多信息,请参阅 深入理解 JavaScript

28.8.3 属性特性和属性描述符 [ES5]

正如对象由属性组成一样,属性也由特性组成。属性的值只是众多特性之一。其他特性包括

当我们使用其中一种操作来处理属性特性时,特性是通过属性描述符指定的:对象,其中每个属性表示一个特性。例如,这就是我们读取属性 obj.foo 的特性的方式

const obj = { foo: 123 };
assert.deepEqual(
  Object.getOwnPropertyDescriptor(obj, 'foo'),
  {
    value: 123,
    writable: true,
    enumerable: true,
    configurable: true,
  });

这就是我们设置属性 obj.bar 的特性的方式

const obj = {
  foo: 1,
  bar: 2,
};

assert.deepEqual(Object.keys(obj), ['foo', 'bar']);

// Hide property `bar` from Object.keys()
Object.defineProperty(obj, 'bar', {
  enumerable: false,
});

assert.deepEqual(Object.keys(obj), ['foo']);

延伸阅读

  测验

请参阅 测验应用程序