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
实例,包含 map
、filter
和 sort
。Array.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 模块系统。
5 条评论