前言(Preface)
Object Oriented
的一个标志就是 Class
(类),通过类可以创建任意多个具有相同属性和方法的对象。然而,JavaScript中只有对象,没有类。 ECMA-262
把对象定义成:无序的属性集合。由于许多开发者都喜欢并习惯于面向类的软件设计,所以JavaScript中充斥着各种模拟类的实现,以致 ES6
专门新增了 class
语法使类的概念在JavaScript中落地。本文主要整理了在ES5和ES6中模拟类的不同实现方式,分析二者之间的联系,同时也指出了新的class语法的一些不完美之处。
内容(Contents)
一、ES5中是如何模拟“类”的
类是一种设计模式。许多语言提供了面向类软件设计的原生语法。JavaScript 也有类似的语法,但是和其他语言中的类完全不同。多年以来,JavaScript 中有一种奇怪的行为一直在被无耻地滥用,那就是模仿类。【1】
面向类的设计模式的三大核心:封装、继承、多态。下面简介它们在ES5中的实现(参考【2】)。
封装
JavaScript可以直接使用字面量创建对象,但是这样达不到“封装”的目的。
1. 工厂模式
function createPerson(name, age) {
let obj = {};
obj.name = name;
obj.age = age;
obj.greet = function() {
console.log("Hello, my name is " + this.name);
};
return obj;
}
let john = createPerson("john", 26);
let lily = createPerson("lily", 12);
【问题】:没有解决 对象识别 问题(无法确认 john
和 lily
都是 Person
)
2. 构造函数模式
ECMAScript中的 构造函数可用来创建特定类型的对象 。
function Person(name, age) {
this.name = name;
this.age = age;
this.greet = function() {
console.log("Hello, my name is " + this.name);
};
}
let maYun = new Person("maYun", 50);
let me = new Person("zhuPengji", 26);
me.constructor === Person; // true
me.constructor === maYun.constructor; // true
me instanceof Object; // true
me instanceof Person; // true
me.greet === maYun.greet; // false
【问题】:每个方法都在每个实例上重新创建一遍, 方法复用 问题( me
和 maYun
的 greet
方法是两个不同的 Function
实例)
3. 原型模式
使用函数的原型对象的好处是,可以让所有对象实例共享原型对象所包含的属性和方法。也就是说,原型对象适合 共享公有方法和属性 。所有原生的引用类型都是采用这种模式创建的。
function Person() {};
Person.prototype.name = "maYun";
Person.prototype.greet = function() {
console.log("Hello, my name is " + this.name);
};
let maYun = new Person();
maYun.greet();
原型的动态性 : 在原型中查找值的过程是一次搜索,所以原型的更改会动态影响所有实例
function Person() {};
let me = new Person();
me.greet(); // TypeError
Person.prototype.greet = function() { return "Hello"; };
me.greet(); // "Hello"
// 切断了引用,会影响新生成的实例,无法影响已生成的实例
Person.prototype = {
bye: function() { return "bye"; }
}
me.bye(); // TypeError
【问题】:原型模式最大的优点在于共享,最大的问题也在于啥都共享
4. 组合构造函数和原型模式
构造函数模式用于定义实例属性,而原型模式定义方法和共享属性。这是目前使用最广泛,认可度最高的创建自定义类型的方法。
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function() {
console.log("Hello, my name is " + this.name);
};
let me = new Person("zpj", 26);
继承
1. 原型链式继承
使用原型链继承核心在于,让子类的原型对象指向父类的实例。
function SuperType(property) {
this.property = property;
}
SuperType.prototype.method = function() { return this.property; };
function SubType() {}
SubType.prototype = new SuperType(["Hello"]);
let sub = new SubType();
let sub2 = new SubType();
sub.property.push("World");
sub2.property; // ["Hello", "World"]
【问题】:过度依赖父类的属性共享(副作用:父类实例的属性成为了子类的原型属性)
2. 借用构造函数式继承
借用构造函数( constructor stealing
)也被称为经典继承。具体操作方法是:在子类构造函数内部调用父类构造函数,同时改变this指向。
function SuperType(property) {
this.property = property;
/* 注意到我们把方法写到了构造函数内部,这是这种模式被称为“经典继承”的原因 */
this.method = function() {
return this.property;
};
}
function SubType(property) {
SuperType.call(this, property); /* 每个子类实例都会有自己的独立副本(包括属性和方法) */
}
let sub = new SubType("Hello");
let sub2 = new SubType("World");
sub.method(); // "Hello"
sub2.method(); // "World"
【问题】:方法没有共享,函数复用无从谈起
3. 组合原型链和构造函数式继承
组合继承( combination inheritance
)也被称作伪经典继承,其实现思路是使用原型链来继承原型属性和方法,而通过构造函数继承实例属性。
function SuperType(property) {
this.property = property;
}
SuperType.prototype.method = function() { return this.property; };
function SubType(property, subProp) {
SuperType.call(this, property);
this.subProp = subProp;
}
SubType.prototype = new SuperType("Hello");
let sub = new SubType("World");
sub.method(); // "World"
【问题】:1. 调用了两次父类函数;2. 多余的父类实例属性留在原型上,永远被子类同名属性屏蔽
4. 寄生组合式继承
寄生( parasitic
)组合式继承本质上,是让子类的原型对象指向父类的原型对象的副本,而非父类实例。
function SuperType(property) {
this.property = property;
}
SuperType.prototype.method = function() { return this.property; };
function SubType(property) {
SuperType.call(this, property);
}
SubType.prototype = Object.create(SuperType.prototype); /* 寄生:父类原型的副本 */
SubType.prototype.constructor = SubType; /* 修正constructor指向 */
let sub = new SubType("World");
sub instanceof SubType; // true
sub.constructor === SubType; // true
开发人员普遍认为寄生组合式继承模式是引用类型最理想的继承范式。
多态
1. 重写
子类原型上的方法,来自于父类原型对象的副本,可以直接重写,并不会影响父类。
function SuperType() {}
SuperType.prototype.method = function() { return "Hello"; };
function SubType() {
SuperType.call(this);
}
SubType.prototype = Object.create(SuperType.prototype);
SubType.prototype.constructor = SubType;
/* 重写 */
SubType.prototype.method = function() { return "World"; };
let sup = new SuperType();
sup.method(); // "Hello"
let sub = new SubType();
sub.method(); // "World"
二、ES6的class语法
ES5的实现具有明显的缺点:繁琐杂乱的
.prototype
引用、试图调用原型链上层同名函数时的 显式伪多态 以及不可靠、不美观而且容易被误解成“构造函数”的.constructor
。【1】ES6的
class
语法可以看做是一个语法糖,他的绝大部分功能,ES5都可以做到,新的class语法只是让对象原型的写法更加清晰、更像是面向对象编程的语法而已。【3】
下面的两种写法效果是一样的。
// ES5
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.distance = function() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
// ES6
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
}
class还是function
对于真正的类来说,构造函数是属于类的,但是在JavaScript中,类是属于构造函数的。【1】
一般语言都是new class
, ES5在new function
,虽然ES6带来了class
,可以new class
了, class
语法也提供了constructor
来完成曾经的function
的功能,但是谨记,这一切都只是语法糖而已。
class Point {
// ...
}
typeof Point; // function
Point === Point.prototype.constructor; // true
构造函数constructor
ES6的[class]
,其实就是ES5的[function.prototype.constructor]
,但是这造成了[class.prototype]
这样不伦不类的存在。
- 构造方法
constructor
和所有普通方法一样,都是[class.prototype]
中的一员 - 一个类必须有
constructor
方法,不显式添加,也会自动添加空的constructor
方法 constructor
必须使用new
调用,默认返回实例对象,即this
实际上JavaScript中没有“构造函数”,只是函数有两种调用方式:“普通函数调用”和“构造函数调用”
- 你无法区分一个函数是普通函数还是构造函数
- 当你通过
new
调用函数时,叫做“构造函数调用”
构造函数与其他函数的唯一区别,就在于调用它们的方式不同。不过,构造函数毕竟也是函数,不存在定义构造函数的特殊语法。任何函数只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那它跟普通函数也不会有什么两样。【2】
实例
和ES5一样,类的所有实例共享一个原型对象。我们可以通过 __proto__
或者 Object.getPrototypeOf()
来获取实例对象的原型对象 [class.prototype]
,这就意味着可以通过实例为“类”添加方法!又一次不伦不类?
let p1 = new Point(1, 2);
let p2 = new Point(3, 4);
p1.__proto__ === p2.__proto__; // true
Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2); // true
Object.getPrototypeOf(p1).printName = function() {
return "Astonished";
}
p2.printName(); // "Astonished"
getter和setter
在“类”的内部可以使用 get
和 set
关键字,对某个属性设置存值函数和取值函数。 class
语法比起ES5更加简洁,易读。
// ES5
function Person(name) {
this.name = name;
}
Object.defineProperty(Person.prototype, "lowerName", {
get: function() {
return this.name.toLowerCase();
}
});
// ES6
class Person {
constructor(name) {
this.name = name;
}
get lowerName() {
return this.name.toLowerCase();
}
}
class表达式
class
表达式是一个很有“JavaScript特色”的名字。但是明白 class
、 constructor
、 function
之间的关系,就能够理解这种“特色”了,这个名字来自于函数表达式。
const MyClass = class Me {
getClassName() {
return Me.name;
}
}
let m = new MyClass();
m.getClassName(); // "Me"
let n = new Me(); // Me is not defined
立即执行的匿名 class
let person = new class {
constructor(name) {
this.name = name;
}
greet() {
return "Hello " + this.name;
}
}("trump");
person.greet();
静态方法
加上 static
关键字的方法成为类的静态方法。
// ES5
function Person(){
this.name = "zpj";
}
Person.prototype.greet = function() { return "Hello " + this.name; };
Person.bye = function() { return "GoodBye"; };
// ES6
class Person {
name = "zpj"
static bye() { return "GoodBye"; }
greet() { return "Hello " + this.name; }
}
继承
1. super
super
有两种用法,一种是当函数,一种是当对象。
作为函数,只能用在子类的构造函数内,其作用相当于 SuperType.prototype.constructor.call(this)
。
class SuperType {}
class SubType extends SuperType {
constructor() {
super();
}
}
作为对象,在普通方法内指向父类原型对象,相当于在执行 SuperType.prototype.method.call(this)
,在静态方法中指向父类,相当于在执行 SuperType.method.call(this)
。
class SuperType {
hello() {
return "Hello World";
}
static world() {
return "World";
}
}
class SubType extends SuperType {
hello() {
return "SubType " + super.hello();
}
static world() {
return "SubType " + super.world();
}
}
另外, super
作为父类在子类中的代言人,在内建对象类型中也可以使用。
var obj = {
toString() {
return "MyObject " + super.toString();
}
}
obj.toString(); // "MyObject [object Object]"
2. 继承关系
Object.getPrototypeOf()
可以获取当前类的父类。 getPrototypeOf
这个名字在“语义”上依然不够“面向类”,配不上 class
语法,有点不伦不类。但是 instanceof
现在更符合其本意了,检测对象是否是某个类的实例。
class A {}
class B extends A {}
class C extends B {}
Object.getPrototypeOf(B) === A; // true
Object.getPrototypeOf(C) === A; // false
let b = new B();
b instanceof B; // true
b instanceof A; // true
3. ES5实现一个继承自Widget的Button类
function Widget(width, height) {
this.width = width || 100;
this.height = height || 30;
this.$elem = null;
}
// 繁琐杂乱的.prototype引用
Widget.prototype.render = function (container) {
if (this.$elem) {
this.$elem.style.width = this.width + "px";
this.$elem.style.height = this.height + "px";
container.appendChild(this.$elem);
}
}
function Button(label, width, height) {
Widget.call(this, width, height);
this.label = label;
this.$elem = document.createElement("button");
}
Button.prototype = Object.create(Widget.prototype);
// 不可靠的constructor
Button.prototype.constructor = Button;
Button.prototype.render = function (container) {
this.$elem.innerText = this.label;
// 试图调用原型链上层同名函数的显式伪多态
Widget.prototype.render.call(this, container);
}
let btn = new Button("按钮");
btn.render(document.body);
4. ES6实现一个继承自Widget的Button类
class Widget {
constructor(width, height) {
this.width = width || 100;
this.height = height || 30;
this.$elem = null;
}
render(container) {
if (this.$elem) {
this.$elem.style.width = this.width + "px";
this.$elem.style.height = this.height + "px";
container.appendChild(this.$elem);
}
}
}
class Button extends Widget {
constructor(label, width, height) {
super(width, height);
this.label = label;
this.$elem = document.createElement("button");
}
render(container) {
this.$elem.innerText = this.label;
super.render(container);
}
}
let btn = new Button("按钮");
btn.render(document.body);
参考(References)
- 【1】你不知道的JavaScript
- 【2】JavaScript高级程序设计
- 【3】ES6入门-阮一峰