ES6 深入:子类化

ES6 深入 是一个关于 ECMAScript 标准第 6 版(简称 ES6)中添加的 JavaScript 编程语言的新特性的系列文章。

两周前,我们介绍了 ES6 中添加的新类系统,用于处理对象构造函数创建的简单情况。我们展示了如何使用它来编写如下代码

<pre>
class Circle {
constructor(radius) {
this.radius = radius;
Circle.circlesMade++;
};

static draw(circle, canvas) {
// Canvas 绘制代码
};

static get circlesMade() {
return !this._count ? 0 : this._count;
};
static set circlesMade(val) {
this._count = val;
};

area() {
return Math.pow(this.radius, 2) * Math.PI;
};

get radius() {
return this._radius;
};
set radius(radius) {
if (!Number.isInteger(radius))
throw new Error("圆形半径必须是整数。");
this._radius = radius;
};
}
</pre>

不幸的是,正如一些人指出的那样,当时没有时间讨论 ES6 中类功能的其余部分。与传统的类系统(例如 C++ 或 Java)一样,ES6 允许继承,其中一个类使用另一个类作为基类,然后通过添加自己的更多功能来扩展它。让我们仔细看看这个新功能的可能性。

在我们开始讨论子类化之前,花点时间回顾一下属性继承和动态原型链将很有帮助。

JavaScript 继承

当我们创建一个对象时,我们有机会在其中添加属性,但它也继承了其原型对象的属性。JavaScript 程序员会熟悉现有的 Object.create API,它允许我们轻松地做到这一点

<pre>
var proto = {
value: 4,
method() { return 14; }
}

var obj = Object.create(proto);

obj.value; // 4
obj.method(); // 14
</pre>

此外,当我们在 obj 中添加与 proto 上相同的名称的属性时,obj 上的属性会遮蔽 proto 上的属性。

<pre>
obj.value = 5;
obj.value; // 5
proto.value; // 4
</pre>

基本子类化

考虑到这一点,我们现在可以了解如何连接由类创建的对象的原型链。回想一下,当我们创建一个类时,我们创建一个对应于类定义中 constructor 方法的新函数,该函数包含所有静态方法。我们还创建一个对象作为该创建函数的 prototype 属性,它将包含所有实例方法。要创建一个继承所有静态属性的新类,我们将不得不使新的函数对象从超类的函数对象继承。类似地,为了获取实例方法,我们将不得不使新函数的 prototype 对象从超类的 prototype 对象继承。

该描述非常密集。让我们尝试一个示例,展示如何在我们没有新语法的情况下连接它,然后添加一个微不足道的扩展以使事情更美观。

继续我们之前的示例,假设我们有一个名为 Shape 的类,我们希望对它进行子类化

<pre>
class Shape {
get color() {
return this._color;
}
set color(c) {
this._color = parseColorAsRGB(c);
this.markChanged(); // 稍后重新绘制画布
}
}
</pre>

当我们尝试编写执行此操作的代码时,我们遇到了与上一篇文章中 static 属性相同的难题:在定义函数时没有语法方式来更改函数的原型。虽然你可以通过 Object.setPrototypeOf 来解决这个问题,但这种方法通常比使用一种创建具有预期原型的方法的引擎效率更低,并且难以优化。

<pre>
class Circle {
// 如上
}

// 连接实例属性
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// 连接静态属性
Object.setPrototypeOf(Circle, Shape);
</pre>

这非常难看。我们添加了类语法,这样我们就可以在一个地方封装所有关于最终对象外观的逻辑,而不是在之后进行其他“连接事物”的逻辑。Java、Ruby 和其他面向对象语言都有一种方法来声明一个类声明是另一个类的子类,我们也应该有。我们使用关键字 extends,所以我们可以写

<pre>
class Circle extends Shape {
// 如上
}
</pre>

你可以在 extends 后放置任何你想要的表达式,只要它是一个具有 prototype 属性的有效构造函数即可。例如

  • 另一个类
  • 来自现有继承框架的类类似函数
  • 普通函数
  • 包含函数或类的变量
  • 对象上的属性访问
  • 函数调用

你甚至可以使用 null,如果你不希望实例从 Object.prototype 继承。

超类属性

因此,我们可以创建子类,并且可以继承属性,有时我们的方法甚至会遮蔽(想想覆盖)我们继承的方法。但如果你想绕过这种遮蔽机制怎么办?

假设我们想编写一个 Circle 类的子类来处理以某个因子缩放圆形。为此,我们可以编写以下有点人为的类

<pre>
class ScalableCircle extends Circle {
get radius() {
return this.scalingFactor * super.radius;
}
set radius() {
throw new Error("ScalableCircle 半径是常量。" +
"请设置缩放因子。");
}

// 处理 scalingFactor 的代码
}
</pre>

请注意,radius getter 使用 super.radius。这个新的 super 关键字允许我们绕过我们自己的属性,并从我们的原型开始查找属性,从而绕过我们可能做出的任何遮蔽。

超类属性访问(顺便说一句,super[expr] 也有效)可以在使用方法定义语法定义的任何函数中使用。虽然这些函数可以从原始对象中提取出来,但这些访问与最初定义方法的对象相关联。这意味着将方法拉到本地变量中不会改变 super 访问的行为。

<pre>
var obj = {
toString() {
return "MyObject: " + super.toString();
}
}

obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]
</pre>

对内置类型的子类化

你可能还想做的另一件事是编写对 JavaScript 语言内置类型的扩展。内置数据结构为语言增加了巨大的功能,并且能够创建利用这种功能的新类型非常有用,并且是子类化设计的基石。假设你想编写一个版本化的数组。(我知道。相信我,我知道。)你应该能够进行更改,然后提交更改,或者回滚到以前提交的更改。编写此快速版本的一种方法是对 Array 进行子类化。

<pre>
class VersionedArray extends Array {
constructor() {
super();
this.history = [[]];
}
commit() {
// 将更改保存到历史记录中。
this.history.push(this.slice());
}
revert() {
this.splice(0, this.length, this.history[this.history.length - 1]);
}
}
</pre>

VersionedArray 的实例保留了一些重要的属性。它们是货真价实的 Array 实例,包含 mapfiltersortArray.isArray() 会将它们视为数组,它们甚至会获得自动更新的数组 length 属性。更进一步,返回新数组的函数(如 Array.prototype.slice())将返回一个 VersionedArray

派生类构造函数

你可能已经注意到最后一个示例中 constructor 方法中的 super()。这是怎么回事?

在传统的类模型中,构造函数用于初始化类的实例的任何内部状态。每个连续的子类负责初始化与该特定子类关联的状态。我们希望将这些调用链接起来,以便子类与它们扩展的类共享相同的初始化代码。

要调用超类构造函数,我们再次使用 super 关键字,这次是作为函数使用。此语法仅在使用 extends 的类的 constructor 方法中有效。使用 super,我们可以重写我们的 Shape 类。

<pre>
class Shape {
constructor(color) {
this._color = color;
}
}

class Circle extends Shape {
constructor(color, radius) {
super(color);

this.radius = radius;
}

// 与上面相同
}
</pre>

在 JavaScript 中,我们倾向于编写在this对象上操作的构造函数,安装属性并初始化内部状态。通常,当我们使用new调用构造函数时,this对象会被创建,就像在构造函数的prototype属性上使用Object.create()一样。然而,一些内置函数具有不同的内部对象布局。例如,数组在内存中的布局与普通对象不同。因为我们想要能够对内置函数进行子类化,所以我们让最底层的构造函数分配this对象。如果它是一个内置函数,我们将得到我们想要的对象布局,如果它是一个普通构造函数,我们将得到我们期望的默认this对象。

可能最奇怪的结果是this在子类构造函数中的绑定方式。在我们运行基类构造函数并允许它分配this对象之前,我们**没有this值**。因此,在调用超类构造函数之前,子类构造函数中对this的所有访问都将导致ReferenceError

正如我们在上一篇文章中看到的,您可以省略constructor方法,派生类构造函数可以省略,就像您写了

<pre>
constructor(...args) {
super(...args);
}
</pre>

有时,构造函数不会与this对象交互。相反,它们以某种方式创建对象,初始化它,并直接返回它。如果是这样,则无需使用super。任何构造函数都可以直接返回一个对象,无论是否曾经调用过超类构造函数。

new.target

让最底层的类分配this对象的另一个奇怪副作用是,有时最底层的类不知道要分配什么类型的对象。假设您正在编写一个对象框架库,并且您想要一个基类Collection,其中一些子类是数组,而另一些子类是映射。那么,当您运行Collection构造函数时,您将无法判断要创建哪种类型的对象!

由于我们能够对内置函数进行子类化,因此当我们运行内置函数构造函数时,在内部,我们已经必须了解原始类的prototype。如果没有它,我们就无法创建具有正确实例方法的对象。为了解决这种奇怪的Collection情况,我们添加了语法来将这些信息公开给 JavaScript 代码。我们添加了一个新的元属性new.target,它对应于使用new直接调用的构造函数。使用new调用函数会将new.target设置为被调用的函数,并在该函数中调用super会转发new.target值。

这很难理解,所以我只告诉你我的意思

<pre>
class foo {
constructor() {
return new.target;
}
}

class bar extends foo {
// 这明确包含是为了清晰起见。它不是必要的
// 获得这些结果。
constructor() {
super();
}
}

// foo 直接调用,所以 new.target 是 foo
new foo(); // foo

// 1) bar 直接调用,所以 new.target 是 bar
// 2) bar 通过 super() 调用 foo,所以 new.target 仍然是 bar
new bar(); // bar
</pre>

我们已经解决了上面描述的Collection问题,因为Collection构造函数可以检查new.target并使用它来推断类血统,并确定要使用哪个内置函数。

new.target在任何函数内部都有效,如果函数没有使用new调用,它将被设置为undefined

两全其美

希望你已经从这次关于新功能的脑力激荡中幸存下来。感谢你坚持下来。现在让我们花点时间谈谈它们是否很好地解决了问题。许多人对继承是否应该成为语言特性中的一种好东西持不同意见。你可能认为继承永远不如组合来创建对象,或者新的语法的简洁性不值得由此产生的设计灵活性缺乏,相比之下,旧的原型模型。不可否认,mixin 已经成为创建以可扩展方式共享代码的对象的主要习惯用法,而且理由充分:它们提供了一种简单的方法来将不相关的代码共享到同一个对象,而无需理解这两个不相关的部分应该如何融入同一个继承结构。

关于这个话题有很多坚定的信念,但我认为有几件事值得注意。首先,添加类作为语言特性并不会使其使用成为强制性的。其次,同样重要的是,添加类作为语言特性并不意味着它们总是解决继承问题的最佳方式!事实上,有些问题更适合用原型继承来建模。归根结底,类只是你可以使用的另一种工具;不是唯一的工具,也不是最好的。

如果你想继续使用 mixin,你可能希望你能够使用从多个东西继承的类,这样你就可以从每个 mixin 继承,并且一切都很好。不幸的是,现在更改继承模型会非常令人震惊,因此 JavaScript 并没有为类实现多重继承。话虽如此,有一个混合解决方案允许在基于类的框架中使用 mixin。考虑以下函数,基于众所周知的extend mixin 习惯用法。

<pre>
function mix(...mixins) {
class Mix {}

// 以编程方式添加所有方法和访问器
// mixin 到 class Mix。
for (let mixin of mixins) {
copyProperties(Mix, mixin);
copyProperties(Mix.prototype, mixin.prototype);
}

return Mix;
}

function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if (key !== "constructor" && key !== "prototype" && key !== "name") {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}
</pre>

我们现在可以使用这个函数mix来创建一个组合的超类,而无需在各种 mixin 之间创建显式的继承关系。想象一下,编写一个协作编辑工具,其中编辑操作被记录,并且它们的內容需要被序列化。您可以使用mix函数编写一个类DistributedEdit

<pre>
class DistributedEdit extends mix(Loggable, Serializable) {
// 事件方法
}
</pre>

它是两全其美的。也很容易看出如何扩展此模型来处理本身具有超类的 mixin 类:我们可以简单地将超类传递给mix,并让返回的类扩展它。

当前可用性

好的,我们已经讨论了很多关于对内置函数进行子类化以及所有这些新事物,但是你现在可以使用这些东西吗?

嗯,有点。在主要浏览器供应商中,Chrome 已经发布了我们今天讨论的大部分内容。在严格模式下,您应该能够执行我们讨论过的几乎所有事情,除了对Array进行子类化。其他内置类型会起作用,但Array带来了一些额外的挑战,所以它还没有完成并不奇怪。我正在为 Firefox 编写实现,并且目标是尽快达到相同的目标(除了Array)。查看bug 1141863以获取更多信息,但它应该在几周内出现在 Firefox 的 Nightly 版本中。

Edge 已经支持super,但不能对内置函数进行子类化,而 Safari 不支持这些功能。

转译器在这里处于劣势。虽然它们能够创建类,并且能够执行super,但基本上没有办法伪造对内置函数进行子类化,因为你需要引擎支持才能从内置方法中获取基类的实例(比如Array.prototype.splice)。

Whew! 这篇文章很长。下周,Jason Orendorff 将回来讨论 ES6 模块系统。

关于 Eric Faust

更多 Eric Faust 的文章…


5 条评论

  1. PhistucK

    感谢您的帖子!

    但是,使用 Chrome 43,看起来我可以对 Array 进行子类化 -
    function getArraySubclass()
    {
    “use strict”;
    class SubclassedArray extends Array
    {
    }
    return new SubclassedArray();
    }
    var array = getArraySubclass();
    // undefined
    array;
    // []
    array.length;
    // 0
    array.push(1);
    // 1
    array;
    // [1]

    2015 年 8 月 8 日 凌晨 4:01

  2. simonleung

    Javascript 并不真正需要 class 或者子类。它是动态的,这与其他 OO 语言有根本区别,在其他 OO 语言中,“class”是构建对象所必需的。

    如果您想更改对象的结构,则无需构建扩展其他类的另一个类。

    因此,class 和子类语法从来不是强烈的愿望。

    此外,为了简单起见,如果扩展函数可以做到相同
    function Circle() extends Shape {

    }
    有效。

    2015 年 8 月 8 日 上午 7:51

  3. Freshers

    感谢您的帖子!
    但是,使用 Chrome 43,看起来我可以对 Array 进行子类化 -
    function getArraySubclass()
    {
    “use strict”;
    class SubclassedArray extends Array
    {
    }
    return new SubclassedArray();
    }
    var array = getArraySubclass();
    // undefined
    array;
    // []
    array.length;
    // 0
    array.push(1);
    // 1
    array;
    // [1]

    2015 年 8 月 8 日 上午 11:19

  4. maga

    T39 否决了多重继承提案,真是太可惜了;如果我没记错的话,来自 Google 的人一直在推动它。

    现在我对 Collection 的例子有点困惑。如果我们只能扩展一个类,我们该如何创建一个子类来扩展内置类?假设我们用 CustomArray 类扩展了 Collection 类,该类需要继承 Collection 和内置 Array 的方法,我们该如何做到呢?我看到的唯一方法是将 Collection 变成一个 mixin,也就是

    Collection = { }

    class CustomArray extends Array {
    constructor() {
    super();
    Object.assign(this, Collection);
    }
    }

    是否有更好的方法可以使用 new.target 属性做到这一点?

    2015 年 8 月 12 日 凌晨 4:24

  5. Nathaniel Tucker

    给出的 mixin 几乎没有用,因为如果任何类实现了相同的函数,一个会简单地覆盖另一个,而不是使用 super 将自己添加到调用链中。

    2015 年 9 月 2 日 下午 9:01

本文的评论已关闭。