天天学前端 第二部分 ES6 中的 Class
共 5826字,需浏览 12分钟
·
2021-10-28 22:45
附录 A ES6 中的 Class
可以用一句话总结本书的第二部分(第 4 章至第 6 章):类是一种可选(而不是必须)的设计模式,而且在 JavaScript 这样的[[Prototype]]语言中实现类是很别扭的。
这种别扭的感觉不只是来源于语法, 虽然语法是很重要的原因。第 4 章和第 5 章介绍了许多语法的缺点:繁琐杂乱的.prototype 引用、 试图调用原型链上层同名函数时的显式伪多态(参见第 4 章)以及不可靠、不美观而且容易被误解成“构造函数”的.constructor。
除此之外, 类设计其实还存在更深刻的问题。第 4 章指出, 传统面向类的语言中父类和子类、 子类和实例之间其实是复制操作, 但是在[[Prototype]]中并没有复制, 相反, 它们之间只有委托关联。
对象关联代码和行为委托(参见第 6 章)使用了[[Prototype]]而不是将它藏起来, 对比其简洁性可以看出,类并不适用于 JavaScript。
A.1 class
不过我们并不需要再纠结于这个问题,这里提到只是让你简单回忆一下;现在我们来看看 ES6 的 class 机制。我们会介绍它的工作原理并分析 class 是否改进了之前提到的那些缺点。
首先回顾一下第 6 章中的 Widget/Button 例子:
class Widget {
constructor(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
}
}
class Button extends Widget {
constructor(width, height, label) {
super(width, height);
this.label = label || "Default";
this.$elem = $(").text(this.label);
}
render($where) {
super($where);
this.$elem.click(this.onClick.bind(this));
}
onClick(evt) {
console.log("Button '" + this.label + "' clicked!");
}
}
除了语法更好看之外,ES6 还解决了什么问题呢?
(基本上,下面会详细介绍)不再引用杂乱的.prototype 了。
Button 声明时直接“继承”了 Widget,不再需要通过 Object.create(..)来替换.prototype 对象,也不需要设置.proto 或者 Object.setPrototypeOf(..)。
可以通过 super(..)来实现相对多态, 这样任何方法都可以引用原型链上层的同名方法。这可以解决第 4 章提到过的那个问题:构造函数不属于类, 所以无法互相引用——super()可以完美解决构造函数的问题。
class 字面语法不能声明属性(只能声明方法)。看起来这是一种限制, 但是它会排除掉许多不好的情况, 如果没有这种限制的话, 原型链末端的“实例”可能会意外地获取其他地方的属性(这些属性隐式被所有“实例”所“共享”)。所以,class 语法实际上可以帮助你避免犯错。
可以通过 extends 很自然地扩展对象(子)类型, 甚至是内置的对象(子)类型, 比如 Array 或 RegExp。没有 class ..extends 语法时, 想实现这一点是非常困难的, 基本上只有框架的作者才能搞清楚这一点。但是现在可以轻而易举地做到!
平心而论,class 语法确实解决了典型原型风格代码中许多显而易见的(语法)问题和缺点。
A.2 class 陷阱
然而,class 语法并没有解决所有的问题, 在 JavaScript 中使用“类”设计模式仍然存在许多深层问题。
首先, 你可能会认为 ES6 的 class 语法是向 JavaScript 中引入了一种新的“类”机制, 其实不是这样。class 基本上只是现有[[Prototype]](委托!)机制的一种语法糖。
也就是说,class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你(有意或无意)修改或者替换了父“类”中的一个方法, 那子“类”和所有实例都会受到影响,因为它们在定义时并没有进行复制,只是使用基于[[Prototype]]的实时委托:
class C {
constructor() {
() this.num = Math.random();
}
rand() {
console.log("Random: " + this.num);
}
}
var c1 = new C();
c1.rand(); // _"Random: 0.4324299..."_
C.prototype.rand = function () {
console.log("Random: " + Math.round(this.num \* 1000));
};
var c2 = new C();
c2.rand(); // "Random: 867"
c1.rand(); // "Random: 432" ——噢!
如果你已经明白委托的原理所以并不会期望得到“类”的副本的话, 那这种行为才看起来比较合理。所以你需要问自己:为什么要使用本质上不是类的 class 语法呢?ES6 中的 class 语法不是会让传统类和委托对象之间的区别更加难以发现和理解吗?class 语法无法定义类成员属性(只能定义方法),如果为了跟踪实例之间共享状态必须要这么做,那你只能使用丑陋的.prototype 语法,像这样:
class C {
constructor() {
// 确保修改的是共享状态而不是在实例上创建一个屏蔽属性!
C.prototype.count++;
// this.count 可以通过委托实现我们想要的功能
console.log("Hello: " + this.count);
}
}
// 直接向prototype对象上添加一个共享状态
C.prototype.count = 0;
var c1 = new C();
// Hello: 1
var c2 = new C();
// Hello: 2
c1.count === 2; // true
c1.count === c2.count; // true
这种方法最大的问题是, 它违背了 class 语法的本意, 在实现中暴露(泄露!)了.prototype。
如果使用 this.count++的话, 我们会很惊讶地发现在对象 c1 和 c2 上都创建了.count 属性, 而不是更新共享状态。class 没有办法解决这个问题, 并且干脆就不提供相应的语法支持,所以你根本就不应该这样做。
此外,class 语法仍然面临意外屏蔽的问题:
class C {
constructor(id) {
// 噢,郁闷,我们的id属性屏蔽了id()方法
this.id = id;
}
id() {
console.log("Id: " + id);
}
}
var c1 = new C("c1");
c1.id(); // TypeError -- c1.id现在是字符串"c1"
除此之外,super 也存在一些非常细微的问题。你可能认为 super 的绑定方法和 this 类似(参见第 2 章),也就是说, 无论目前的方法在原型链中处于什么位置,super 总会绑定到链中的上一层。
然而, 出于性能考虑(this 绑定已经是很大的开销了),super 并不是动态绑定的, 它会在声明时“静态”绑定。没什么大不了的,是吧?
呃...... 可能, 可能不是这样。如果你和大多数 JavaScript 开发者一样, 会用许多不同的方法把函数应用在不同的(使用 class 定义的)对象上, 那你可能不知道, 每次执行这些操作时都必须重新绑定 super。
此外, 根据应用方式的不同,super 可能不会绑定到合适的对象(至少和你想的不一样),所以你可能(写作本书时,TC39 正在讨论这个话题)需要用 toMethod(..)来手动绑定 super(类似用 bind(..)来绑定 this——参见第 2 章) 。
你已经习惯了把方法应用到不同的对象上,从而可以自动利用 this 的隐式绑定规则(参见第 2 章) 。但是这对于 super 来说是行不通的。
思考下面代码中 super 的行为(D 和 E 上) :
class P {
foo() {
console.log("P.foo");
}
}
class C extends P {
foo() {
super();
}
}
var c1 = new C();
c1.foo(); // "P.foo"
var D = {
foo: function () {
console.log("D.foo");
}
};
var E = {
foo: C.prototype.foo
};
// 把E委托到D
Object.setPrototypeOf(E, D);
E.foo(); // "P.foo"
如果你认为 super 会动态绑定(非常合理!) ,那你可能期望 super()会自动识别出 E 委托了 D,所以 E.foo()中的 super()应该调用 D.foo()。
但事实并不是这样。出于性能考虑,super 并不像 this 一样是晚绑定(late bound, 或者说动态绑定)的, 它在[[HomeObject]].[[Prototype]]上,[[HomeObject]]会在创建时静态绑定。
在本例中,super()会调用 P.foo(),因为方法的[[HomeObject]]仍然是 C,C.[[Prototype]]是 P。
确实可以手动修改 super 绑定, 使用 toMethod(..)绑定或重新绑定方法的[[HomeObject]](就像设置对象的[[Prototype]]一样!)就可以解决本例的问题:
var D = {
foo: function () { console.log("D.foo"); }
};
// 把 E 委托到 D
var E = Object.create(D);
// 手动把foo的[[HomeObject]]绑定到E,E.[[Prototype]]是D, 所以 super()是D.foo()
E.foo = C.prototype.foo.toMethod(E, "foo");
E.foo(); // "D.foo"
toMethod(..)会复制方法并把 homeObject 当作第一个参数(也就是我们传入的 E),第二个参数(可选)是新方法的名称(默认是原方法名)。
除此之外, 开发者还有可能会遇到其他问题, 这有待观察。无论如何, 对于引擎自动绑定的 super 来说,你必须时刻警惕是否需要进行手动绑定。唉!
A.3 静态大于动态吗
通过上面的这些特性可以看出,ES6 的 class 最大的问题在于,(像传统的类一样)它的语法有时会让你认为, 定义了一个 class 后, 它就变成了一个(未来会被实例化的)东西的静态定义。你会彻底忽略 C 是一个对象,是一个具体的可以直接交互的东西。
在传统面向类的语言中, 类定义之后就不会进行修改, 所以类的设计模式就不支持修改。但是 JavaScript 最强大的特性之一就是它的动态性, 任何对象的定义都可以修改(除非你把它设置成不可变)。
class 似乎不赞成这样做, 所以强制让你使用丑陋的.prototype 语法以及 super 问题, 等等。而且对于这种动态产生的问题,class 基本上都没有提供解决方案。
换句话说,class 似乎想告诉你:“动态太难实现了, 所以这可能不是个好主意。这里有一种看起来像静态的语法,所以编写静态代码吧。”
对于 JavaScript 来说这是多么悲伤的评论啊:动态太难实现了, 我们假装成静态吧。(但是实际上并不是!)
总地来说,ES6 的 class 想伪装成一种很好的语法问题的解决方案, 但是实际上却让问题更难解决而且让 JavaScript 更加难以理解。
如果你使用.bind(..)函数来硬绑定函数(参见第 2 章),那么这个函数不会像普通函数那样被 ES6 的 extend 扩展到子类中。
A.4 小结
class 很好地伪装成 JavaScript 中类和继承设计模式的解决方案, 但是它实际上起到了反作用:它隐藏了许多问题并且带来了更多更细小但是危险的问题。
class 加深了过去 20 年中对于 JavaScript 中“类”的误解, 在某些方面, 它产生的问题比解决的多,而且让本来优雅简洁的[[Prototype]]机制变得非常别扭。
结论:如果 ES6 的 class 让[[Prototype]]变得更加难用而且隐藏了 JavaScript 对象最重要的机制—— 对象之间的实时委托关联, 我们难道不应该认为 class 产生的问题比解决的多吗?难道不应该抵制这种设计模式吗?
我无法替你回答这些问题, 但是我希望本书能从前所未有的深度分析这些问题, 并且能够为你提供回答问题所需的所有信息。
最后听一首悦耳的歌放松放松,回忆学到的东西。
点击下面播放音乐
长按二维码关注,一起努力。
助力寻人启事
微信公众号回复 加群 一起学习。