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

29 原型链和类



在本书中,JavaScript 的面向对象编程 (OOP) 风格分四个步骤介绍。本章涵盖步骤 2-4,上一章 涵盖步骤 1。这些步骤是(图 9

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

29.1 原型链

原型是 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);

鉴于原型对象本身可以有一个原型,我们得到一个对象链——所谓的 *原型链*。这意味着继承让我们感觉像是在处理单个对象,但实际上是在处理对象链。

10 显示了 obj 的原型链是什么样的。

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

非继承属性称为 *自身属性*。obj 有一个自身属性 .objProp

29.1.1 JavaScript 的操作:所有属性与自身属性

某些操作会考虑所有属性(自身属性和继承属性)——例如,获取属性

> const obj = { foo: 1 };
> typeof obj.foo // own
'number'
> typeof obj.toString // inherited
'function'

其他操作只考虑自身属性——例如,Object.keys()

> Object.keys(obj)
[ 'foo' ]

继续阅读另一个也只考虑自身属性的操作:设置属性。

29.1.2 陷阱:只有原型链的第一个成员会被修改

原型链的一个可能违反直觉的方面是,通过对象(甚至是继承的对象)设置 *任何* 属性只会更改该对象本身,而不会更改任何原型。

考虑以下对象 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 的原型链如图 11 所示。

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

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

29.1.3.1 最佳实践:避免使用 __proto__,除非在对象字面量中

我建议避免使用伪属性 __proto__:正如我们将在 后面 看到的,并非所有对象都有它。

但是,对象字面量中的 __proto__ 不同。在那里,它是一个内置功能,始终可用。

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

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

const proto1 = {};
const proto2 = {};

const obj = Object.create(proto1);
assert.equal(Object.getPrototypeOf(obj), proto1);

Object.setPrototypeOf(obj, proto2);
assert.equal(Object.getPrototypeOf(obj), proto2);
29.1.3.2 检查:一个对象是另一个对象的原型吗?

到目前为止,“po 的原型”始终意味着“po 的 *直接* 原型”。但它也可以更宽松地使用,表示 po 的原型链中。这种更宽松的关系可以通过以下方式检查

p.isPrototypeOf(o)

例如

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.1.4 通过原型共享数据

考虑以下代码

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

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

我们有两个非常相似的对象。两者都有两个属性,名称分别为 .name.describe。此外,方法 .describe() 是相同的。我们如何避免重复该方法?

我们可以将其移动到对象 PersonProto 并使该对象成为 janetarzan 的原型

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

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

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

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

当您进行方法调用 jane.describe() 时,this 指向该方法调用的接收者 jane(在图的左下角)。这就是该方法仍然有效的原因。tarzan.describe() 的工作原理类似。

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

29.2 类

我们现在准备学习类,它基本上是用于设置原型链的紧凑语法。在底层,JavaScript 的类是非传统的。但这是您在使用它们时很少看到的东西。对于使用过其他面向对象编程语言的人来说,它们通常应该很熟悉。

29.2.1 人员类

我们之前使用过 janetarzan,它们是表示人员的单个对象。让我们使用 *类声明* 来实现人员对象的工厂

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return 'Person named '+this.name;
  }
}

现在可以通过 new Person() 创建 janetarzan

const jane = new Person('Jane');
assert.equal(jane.name, 'Jane');
assert.equal(jane.describe(), 'Person named Jane');

const tarzan = new Person('Tarzan');
assert.equal(tarzan.name, 'Tarzan');
assert.equal(tarzan.describe(), 'Person named Tarzan');

Person 有两种方法

29.2.1.1 类表达式

有两种 *类定义*(定义类的方式)

类表达式可以是匿名的,也可以是命名的

// Anonymous class expression
const Person = class { ··· };

// Named class expression
const Person = class MyClass { ··· };

命名类表达式的名称的工作方式类似于 命名函数表达式的名称

这是对类的初步了解。我们很快就会探索更多功能,但首先我们需要学习类的内部机制。

29.2.2 类的底层机制

类的底层机制有很多东西。让我们看一下 jane 的图表(图 13)。

Figure 13: The class Person has the property .prototype that points to an object that is the prototype of all instances of Person. jane is one such instance.

Person 的主要目的是设置右侧的原型链(jane,后跟 Person.prototype)。有趣的是,类 Person 内部的两个构造(.constructor.describe())都为 Person.prototype 创建了属性,而不是为 Person 创建了属性。

这种略显奇怪的方法的原因是向后兼容性:在类之前,*构造函数*(普通函数,通过 new 运算符调用)通常用作对象的工厂。类主要是构造函数的更好语法,因此与旧代码保持兼容。这解释了为什么类是函数

> typeof Person
'function'

在本书中,我交替使用术语 *构造(函数)* 和 *类*。

很容易混淆 .__proto__.prototype。希望图 13 能清楚地说明它们之间的区别

29.2.2.1 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
// (the instanceof operator is explained later)
assert.equal(cheeta instanceof Person, true);

其次,您可以获取创建给定实例的类的名称

const tarzan = new Person('Tarzan');

assert.equal(tarzan.constructor.name, 'Person');

29.2.3 类定义:原型属性

以下类声明主体中的所有构造都创建 Foo.prototype 的属性。

class Foo {
  constructor(prop) {
    this.prop = prop;
  }
  protoMethod() {
    return 'protoMethod';
  }
  get protoGetter() {
    return 'protoGetter';
  }
}

让我们按顺序检查它们

以下交互使用类 Foo

> const foo = new Foo(123);
> foo.prop
123

> foo.protoMethod()
'protoMethod'
> foo.protoGetter
'protoGetter'

29.2.4 类定义:静态属性

以下类声明主体中的所有构造都创建所谓的 *静态* 属性——Bar 本身的属性。

class Bar {
  static staticMethod() {
    return 'staticMethod';
  }
  static get staticGetter() {
    return 'staticGetter';
  }
}

静态方法和静态 getter 的使用方法如下

> Bar.staticMethod()
'staticMethod'
> Bar.staticGetter
'staticGetter'

29.2.5 instanceof 运算符

instanceof 运算符告诉您一个值是否是给定类的实例

> new Person('Jane') instanceof Person
true
> ({}) instanceof Person
false
> ({}) instanceof Object
true
> [] instanceof Array
true

我们将在 后面 了解子类化之后更详细地探讨 instanceof 运算符。

29.2.6 我为什么推荐使用类

我建议使用类的原因如下

这并不意味着类是完美的

  练习:编写一个类

exercises/proto-chains-classes/point_class_test.mjs

29.3 类的私有数据

本节介绍用于对外部隐藏对象某些数据的技术。我们在类的上下文中讨论它们,但它们也适用于直接创建的对象,例如,通过对象字面量创建的对象。

29.3.1 私有数据:命名约定

第一种技术通过在属性名称前添加下划线来使其私有化。这不会以任何方式保护属性;它只是向外部发出信号:“您不需要了解此属性。”

在以下代码中,属性 ._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.3.2 私有数据:WeakMap

另一种技术是使用 WeakMap。关于其工作原理的详细说明,请参阅WeakMap 章节。这是一个预览

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()),
  []);

此技术为您提供了相当大的外部访问保护,并且不会发生任何名称冲突。但使用起来也更复杂。

29.3.3 更多私有数据技术

本书介绍了类中私有数据最重要的技术。而且可能很快就会有内置支持。有关详细信息,请参阅 ECMAScript 提案 “类公共实例字段和私有实例字段”

Exploring ES6 中介绍了一些其他技术。

29.4 子类化

类也可以将现有类子类化(“扩展”)。例如,以下类 EmployeePerson 的子类

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return `Person named ${this.name}`;
  }
  static logNames(persons) {
    for (const person of persons) {
      console.log(person.name);
    }
  }
}

class Employee extends Person {
  constructor(name, title) {
    super(name);
    this.title = title;
  }
  describe() {
    return super.describe() +
      ` (${this.title})`;
  }
}

const jane = new Employee('Jane', 'CTO');
assert.equal(
  jane.describe(),
  'Person named Jane (CTO)');

两点注释

  练习:子类化

exercises/proto-chains-classes/color_point_class_test.mjs

29.4.1 底层子类(高级)

Figure 14: These are the objects that make up class Person and its subclass, Employee. The left column is about classes. The right column is about the Employee instance jane and its prototype chain.

上一节中的类 PersonEmployee 由多个对象组成(图 14)。理解这些对象如何关联的一个关键见解是存在两条原型链

29.4.1.1 实例原型链(右列)

实例原型链从 jane 开始,然后是 Employee.prototypePerson.prototype。原则上,原型链到此结束,但我们还有一个对象:Object.prototype。此原型为几乎所有对象提供服务,这就是为什么它也包含在此处的原因

> Object.getPrototypeOf(Person.prototype) === Object.prototype
true
29.4.1.2 类原型链(左列)

在类原型链中,首先是 Employee,然后是 Person。之后,链条继续到 Function.prototype,它之所以存在是因为 Person 是一个函数,而函数需要 Function.prototype 的服务。

> Object.getPrototypeOf(Person) === Function.prototype
true

29.4.2 更详细的 instanceof(高级)

我们还没有看到 instanceof 的真正工作原理。给定表达式

x instanceof C

instanceof 如何确定 x 是否是 C 的实例(或 C 的子类)?它通过检查 C.prototype 是否在 x 的原型链中来实现。也就是说,以下表达式是等效的

C.prototype.isPrototypeOf(x)

如果我们回到图 14,我们可以确认原型链确实引导我们得到了以下正确答案

> jane instanceof Employee
true
> jane instanceof Person
true
> jane instanceof Object
true

29.4.3 内置对象的原型链(高级)

接下来,我们将利用我们对子类化的知识来理解一些内置对象的原型链。以下工具函数 p() 可帮助我们进行探索。

const p = Object.getPrototypeOf.bind(Object);

我们提取了 Object 的方法 .getPrototypeOf() 并将其分配给 p

29.4.3.1 {} 的原型链

让我们从检查普通对象开始

> p({}) === Object.prototype
true
> p(p({})) === null
true
Figure 15: The prototype chain of an object created via an object literal starts with that object, continues with Object.prototype, and ends with null.

15 显示了此原型链的示意图。我们可以看到 {} 确实是 Object 的实例 - Object.prototype 在其原型链中。

29.4.3.2 [] 的原型链

数组的原型链是什么样的?

> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true
Figure 16: The prototype chain of an Array has these members: the Array instance, Array.prototype, Object.prototype, null.

此原型链(在图 16 中可视化)告诉我们,数组对象是 Array 的实例,而 ArrayObject 的子类。

29.4.3.3 function () {} 的原型链

最后,普通函数的原型链告诉我们所有函数都是对象

> p(function () {}) === Function.prototype
true
> p(p(function () {})) === Object.prototype
true
29.4.3.4 不是 Object 实例的对象

仅当 Object.prototype 在其原型链中时,对象才是 Object 的实例。通过各种字面量创建的大多数对象都是 Object 的实例

> ({}) instanceof Object
true
> (() => {}) instanceof Object
true
> /abc/ug instanceof Object
true

没有原型的对象不是 Object 的实例

> ({ __proto__: null }) instanceof Object
false

Object.prototype 终止了大多数原型链。它的原型是 null,这意味着它本身也不是 Object 的实例

> Object.prototype instanceof Object
false
29.4.3.5 伪属性 .__proto__ 究竟是如何工作的?

伪属性 .__proto__ 由类 Object 通过 getter 和 setter 实现。它可以像这样实现

class Object {
  get __proto__() {
    return Object.getPrototypeOf(this);
  }
  set __proto__(other) {
    Object.setPrototypeOf(this, other);
  }
  // ···
}

这意味着您可以通过创建一个原型链中没有 Object.prototype 的对象来关闭 .__proto__(请参阅上一节)

> '__proto__' in {}
true
> '__proto__' in { __proto__: null }
false

29.4.4 调度方法调用与直接方法调用(高级)

让我们研究一下方法调用如何与类一起使用。我们正在重新审视之前的 jane

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return 'Person named '+this.name;
  }
}
const jane = new Person('Jane');

17 显示了 jane 的原型链图。

Figure 17: The prototype chain of jane starts with jane and continues with Person.prototype.

普通方法调用是_调度_的 - 方法调用 jane.describe() 分两步进行

这种动态查找方法并调用它的方式称为_动态调度_。

您可以_直接_进行相同的方法调用,而无需调度

Person.prototype.describe.call(jane)

这一次,我们通过 Person.prototype.describe 直接指向该方法,并且不在原型链中搜索它。我们还通过 .call() 以不同方式指定了 this

请注意,this 始终指向原型链的开头。这使得 .describe() 可以访问 .name

29.4.4.1 借用方法

当您使用 Object.prototype 的方法时,直接方法调用变得很有用。例如,Object.prototype.hasOwnProperty(k) 检查 this 是否具有键为 k 的非继承属性

> const obj = { foo: 123 };
> obj.hasOwnProperty('foo')
true
> obj.hasOwnProperty('bar')
false

但是,在对象的原型链中,可能存在另一个键为 'hasOwnProperty' 的属性,该属性会覆盖 Object.prototype 中的方法。然后,调度方法调用将不起作用

> const obj = { hasOwnProperty: true };
> obj.hasOwnProperty('bar')
TypeError: obj.hasOwnProperty is not a function

解决方法是使用直接方法调用

> Object.prototype.hasOwnProperty.call(obj, 'bar')
false
> Object.prototype.hasOwnProperty.call(obj, 'hasOwnProperty')
true

这种直接方法调用通常缩写如下

> ({}).hasOwnProperty.call(obj, 'bar')
false
> ({}).hasOwnProperty.call(obj, 'hasOwnProperty')
true

这种模式看起来效率低下,但大多数引擎都对此模式进行了优化,因此性能应该不是问题。

29.4.5 Mixin 类(高级)

JavaScript 的类系统仅支持_单继承_。也就是说,每个类最多只能有一个超类。解决此限制的一种方法是使用一种称为_mixin 类_(简称:_mixins_)的技术。

其思想如下:假设我们希望类 C 从两个超类 S1S2 继承。那就是_多重继承_,JavaScript 不支持。

我们的解决方法是将 S1S2 转换为_mixins_,即子类的工厂

const S1 = (Sup) => class extends Sup { /*···*/ };
const S2 = (Sup) => class extends Sup { /*···*/ };

这两个函数中的每一个都返回一个扩展了给定超类 Sup 的类。我们创建类 C 如下

class C extends S2(S1(Object)) {
  /*···*/
}

我们现在有了一个类 C,它扩展了类 S2,而 S2 扩展了类 S1,而 S1 扩展了 Object(大多数类都隐式地这样做)。

29.4.5.1 示例:用于品牌管理的 mixin

我们实现了一个 mixin Branded,它具有用于设置和获取对象品牌的辅助方法

const Branded = (Sup) => class extends Sup {
  setBrand(brand) {
    this._brand = brand;
    return this;
  }
  getBrand() {
    return this._brand;
  }
};

我们使用此 mixin 来实现类 Car 的品牌管理

class Car extends Branded(Object) {
  constructor(model) {
    super();
    this._model = model;
  }
  toString() {
    return `${this.getBrand()} ${this._model}`;
  }
}

以下代码确认 mixin 起作用了:Car 具有 Branded 的方法 .setBrand()

const modelT = new Car('Model T').setBrand('Ford');
assert.equal(modelT.toString(), 'Ford Model T');
29.4.5.2 mixins 的好处

Mixins 使我们摆脱了单继承的限制

29.5 常见问题解答:对象

29.5.1 为什么对象保留属性的插入顺序?

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

  测验

请参阅测验应用程序