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

28 对象



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

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

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

注意

每个对象都继承其原型的所有属性

// `obj2` inherits .protoProp from `proto`
assert.equal(
  obj2.protoProp, 'protoProp'
);
assert.deepEqual(
  Reflect.ownKeys(obj2),
  ['objProp'] // own properties of `obj2`
);

对象的非继承属性称为其 自身 属性。

原型最重要的用例是多个对象可以通过从公共原型继承方法来共享方法。

28.2 什么是对象?

JavaScript 中的对象

28.2.1 使用对象的两种方式

在 JavaScript 中有两种使用对象的方式

请注意,这两种方式也可以混合使用:某些对象既是固定布局对象又是字典对象。

使用对象的方式会影响本章对它们的解释方式

28.3 固定布局对象

让我们首先探索 固定布局对象

28.3.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.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(即,每个访问器都有其中一个或两个)。

调用访问器看起来像访问值属性

28.3.6.1 Getters

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');
28.3.6.2 Setters

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

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 }

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

> {...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 的所有属性(键值对)的副本。但是,如果属性值是对象,则这些对象本身不会被复制;它们在 originalcopy 之间共享。让我们看一个例子

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 目前没有内置的操作来实现它。如果我们需要这样的操作,我们必须自己实现。

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 是方法调用的*接收者*。它通过一个名为 this 的隐式(隐藏)参数传递给存储在 obj.someMethod 中的函数(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,后面跟着常规的(显式)函数参数。

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

  1. 一个用于访问属性:obj.prop
  2. 另一个用于调用方法: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() 将无法正常工作,因为在 A 行中使用了 this。在下一节中,我们将探讨原因。

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 行中,我们正在进行正常的函数调用。在正常的函数调用中,thisundefined(如果 严格模式处于活动状态,而它几乎总是处于活动状态)。因此,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 示例:提取方法

以下是我们在实际 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() 都会创建一个新函数。这就是为什么如果我们想稍后删除它,我们需要将结果存储在某个地方。

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() 为回调函数指定一个固定的 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.5.6.1 避免意外遮蔽 this 的陷阱

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

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

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

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

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

  提示:假装顶级作用域中不存在 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

大致思路是

稍后将更详细地介绍这三种语法。以下是一些初步示例

> 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 通过空值合并运算符处理默认值

空值合并运算符 允许我们使用默认值 '(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);

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

28.6.3 使用可选属性获取进行短路求值

在一系列属性获取和方法调用中,一旦第一个可选运算符在其左侧遇到 undefinednull,求值就会停止

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?.aundefined。因此,我们预计 A 行中的 .b 会失败。但它没有:?. 运算符遇到了值 undefined,整个表达式的求值立即返回 undefined

此行为不同于普通运算符,在普通运算符中,JavaScript 始终先计算所有操作数,然后再计算运算符。这称为*短路求值*。其他短路运算符有

28.6.4 可选链式操作:缺点和替代方案

可选链式操作也有缺点

可选链式操作的一种替代方法是在单个位置一次性提取信息

使用这两种方法,都可以执行检查,并在出现问题时尽早失败。

扩展阅读

28.6.5 常见问题

28.6.5.1 对于可选链运算符 (?.),有什么好的助记符吗?

您是否偶尔不确定可选链运算符是以点 (.?) 还是问号 (?.) 开头的?那么这个助记符可能会对您有所帮助

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 带来了 映射)。因此,对象必须用作字典,这带来了一个重大限制:字典键必须是字符串(符号也是在 ES6 中引入的)。

我们首先看一下与字典相关的对象特性,这些特性对固定布局对象也很有用。本节最后提供了一些将对象实际用作字典的技巧。(剧透:如果可能,最好使用映射。)

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(为假)。

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

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()

表 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 属性按确定性顺序列出

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

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

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

> 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 安全地将对象用作字典

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

以下代码演示了如何使用无原型对象作为字典

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

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

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

我们避免了这两个陷阱

  练习:将对象用作字典

exercises/objects/simple_dict_test.mjs

28.8 属性属性和冻结对象(高级)

28.8.1 属性属性和属性描述符 [ES5]

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

当我们使用其中一种操作来处理属性属性时,属性是通过属性描述符指定的:对象,其中每个属性代表一个属性。例如,这就是我们读取属性 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), []);

扩展阅读

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 的原型链是什么样的。

Figure 9: obj starts a chain of objects that continues with proto and other objects.

非继承属性称为自身属性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 所示。

Figure 10: The own property .protoProp of obj overrides the property inherited from proto.

28.9.3 使用原型的技巧(高级)

28.9.3.1 获取和设置原型

关于 __proto__ 的建议

获取和设置原型的推荐方法是

以下是这些功能的使用方法

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 检查一个对象是否在另一个对象的原型链中

到目前为止,“protoobj 的原型”始终表示“protoobj 的*直接*原型”。但它也可以更宽松地使用,表示 protoobj 的原型链中。这种更宽松的关系可以通过 .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,并使该对象成为 janetarzan 的原型

const PersonProto = {
  describe() {
    return 'Person named ' + this.firstName;
  },
};
const jane = {
  __proto__: PersonProto,
  firstName: 'Jane',
};
const tarzan = {
  __proto__: PersonProto,
  firstName: 'Tarzan',
};

原型的名称反映了 janetarzan 都是人。

Figure 11: Objects jane and tarzan share method .describe(), via their common prototype PersonProto.

图 11 说明了这三个对象是如何连接的:底部的对象现在包含特定于 janetarzan 的属性。顶部的对象包含它们之间共享的属性。

当我们进行方法调用 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 为什么对象保留属性的插入顺序?

原则上,对象是无序的。对属性进行排序的主要原因是为了使列出条目、键或值的操作具有确定性。这有助于例如测试。

  测验

请参阅 测验应用程序