Object Oriented Javascript
类
如何声明一个类
在ES6之前,我们这样声明一个类
function Vehicle(engines) { |
当使用new
操作符调用一个函数(和函数名无关,用作类时习惯首字母大写)时,会使用该函数构造一个对象,大概发生了以下几件事:
- 所以在
Vehicle
函数中,创建一个空对象 - 将该对象绑定到函数调用的
this
- 将该对象的 [[Prototype]] 指向
Vehicle.prototype
- 如果
Vehicle
函数没有返回其他对象,默认返回该对象
初始化的值如engines
会分别绑定到实例化的所有对象上,而prototype
上的值(方法)则会被所有实例化的对象共享。
类和对象
类和对象是一种怎样的关系?对象是类的实例。没错,但是这背后有什么故事呢?首先列几个知识点。
类其实就是方法
function
,每个function
都有一个prototype
对象,该对象有一个特殊的属性constructor
指向该function
。实例也就是对象
object
,几乎每个object
都有一个 [[Prototype]] 属性的对象,可以通过非标准的接口__proto__
查看,该对象指向构造该实例的构造函数的原型链。 所以vehicle.__proto__ === Vehicle.prototype;
// trueES6之后新增了标准接口
Object.getPrototypeOf
和Object.setPrototypeOf
来获取和设置对象的 [[Prototype]] 。所有的
function
也是object
(由Function
构造)。所以Vehicle.__proto__ === Function.prototype
// true通过
instanceof
判断对象是否是某个类的实例。判断的逻辑是一层一层遍历对象的 [[Prototype]] , 如果某个类的prototype
出现在该对象的 [[Prototype]] 链中则返回true。vehicle instanceof Vehicle;
// true
function Foo() {}
vehicle instanceof Foo;
// false
vehicle.__proto__.__proto__ = Foo.prototype;
vehicle instanceof Foo;
// true
有图有真相
如何实现类的继承
实现类的继承方法很多,大概列举以下几种
原型链继承
function Car(wheels) { |
该方案思路是将子类Car
的原型链指向一个父类Vehicle
实例对象,这样通过子类实例化的对象car
就能从原型链上面调用到父类的方法ignition
了。注意该方案存在以下问题:
① 在实例化子类对象时没办法动态向父类构造函数值传参,就上面的代码我们就无法再构造一个具有4个engine
的car
了。
② 由于直接将prototype指向实例化的父类对象,导致初始化的属性被直接绑定到了子类Car
的原型链上(并非每个实例本身上),如果该属性是一个引用类型,这会造成一个实例的修改也会影响到其他的实例。
function Foo() { |
③ 通该子类Car
构造的实例的constructor
指向父类Vehicle
。这是因为对象的constructor
属性其实是指向其 [[Prototype]] 的constructor
,当然只需复写一下该属性即可。
console.log(car.constructor); |
借用构造函数继承
function Car(engines, wheels) { |
- 通过父类构造函数借用的方式,可以实现实例化子类对象时向父类构造函数动态传参(即上面提到的问题①)。
- 而且绑定
this
子类实例对象借用父类构造函数,通过父类构造函数初始化的属性会直接绑定到实例化的每一个对象上,因此不会存在多个实例之间互相影响的问题(即上面提到的问题②)。
但是单独使用该方式很少,因为不涉及到原型链继承没有太大意义。
混合继承
即上面的原型链继承与构造函数借用继承相结合,弥补各自的不足。
function Car(engines, wheels) { |
通过该方式继承仍有两个缺点
① 父类Vehicle
的构造方法调用了两次,造成了不必要的内存消耗。
② 在上例中,当将子类的prototype
指向父类的prototype
时调用并没有传任何参数,这是因为我们这时只想关联prototype
,并不关心他到底需要什么参数。在上例中并不会有什么大问题,但是这种操作存在隐患,有时会给我们带来意想不到的副作用。
function Foo(arr, ...rest) { |
当然这是我们假象的一个例子,本身是由于代码不够健壮造成的。你可以改造Foo的构造函数,检查参数或者配置默认参数。这里想表达的是,其实我们这里根本不需要执行Foo
构造函数,因为我们只关心其prototype
直接指向父类prototype
该方法没有任何使用价值,只是为了说明问题。
function Car() {} |
该方法直接将子类的prototype
指向父类的prototype
,避免了实例化父类对象,节省了内存。但是子类对prototype
的任何修改都会直接影响到父类以及其他子类,因为prototype
是同一个对象引用。
利用空对象继承
该方法是在上面的基础上演化而来的。
function F() {} |
由于直接将子类的prototype
指向父类的prototype
会存在上述问题,而且前面提到多次调用父类构造方法以及空着调用父类构造函数可能会导致副作用。该方法利用一个空对象作为介质,实例化F
几乎不占内存,而且修改子类的prototype
也不会影响到父类的prototype
。
通过同样的思路(指向子类的prototype
到一个空对象),该方法还可以通过下面一种实现:
function Car(engines, wheels) { |
Object.create(o: Object)
是ES5中的一个方法,接收一个对象o
,返回一个空对象,该返回对象的prototype
会指向传入的参数o
。所以该实现和上面的构造临时F
是等价的,而且由于该方法省去了空类F
的创建,所以会更加推荐使用此方法。
拷贝继承
以上的方法都是通过子类关联父类的原型链的方式继承。其实我们也可以直接从父类的原型链上面将这些不变的属性/方法拷贝到子类的原型链上面,这样不也能实现子类调用父类的方法了吗?
function Car(engines, wheels) { |
这里需要注意的是,由于for in
还会遍历继承下来的属性,但是并不会遍历enumerable
为false
的属性。
function Foo() {} |
并不存在的 类
当在真正弄清楚在Javascript中原型链prototype
的工作方式之后,你会发现这和传统的面向对象比如Java存在很大区别的。
在Javascript中,所谓的类的继承无非是实例通过 [[Prototype]] 一层一层向上查找实现的。与其说是继承,不如说是委托/代理,子类实例想要使用父类定义的方法,不就是将该方法委托给父类的prototype
吗?一旦父类在prototype
中定义的方法发生改变,子类再调用该方法就是改变后的方法,因为委托的对象发生了改变。
“类的多态”
在面向对象编程里面,多态是最重要的概念之一。多态的定义是接口的多种不同实现,在调用时父类类型的引用指向子类对象。
由于Javascript中并不存在类,更不存在接口。然而人们已经接受了面向对象的思维去理解原型链,那么来看下是怎么模拟多态的。
function Car(engines, wheels) { |
上面代码通过在子类的prototype
上定义与父类同名的ignition
方法,达到遮蔽父类方法的目的,实现了相对多态的效果。
行为委托
上面提到在类的世界里,通过定义一个同名的方法达到相对多态的目的。但是如果我们面对现实,不用类的思维去思考问题,在我们面前的紧紧是一个个普通的对象object
而已。这时定义同名方法往往使得代码难以理解,我们更倾向于定义另外一个方法,在这个方法里面去委托别的方法来完成相关联的逻辑。
var Widget = { |
上面代码中,完全通过纯对象的思维去定义了基础组件Widget
与一般组件Button
的关系。在Button
的setup
方法中委托Widget.init
方法,并添加了自己特有的逻辑,从而达到了代码抽象与重用的目的。
class in ES6
ES6提供了class
的新语法,不过只是一个语法糖而已,本质还是前面提到的通过原型链prototype
的实现。下面我们来看一下使用ES6是如何定义类的。
class Vehicle { |
我们可以看到通过ES6的方式去定义一个类以及继承的关系变得清爽了很多,其实它只是把prototype
隐藏了起来。当通过extends
继承父类时,可以通过super.ignition()
调用父类中定义的方法,其实它等于Vehicle.prototype.ignition.call(this)
。
ES6 class 那些小事
定义class的成员变量和静态属性
到目前为止,官方版本要定义class的成员变量只能通过在
constructor
中通过this.props = props
的方式来达到。那如何定义class的静态属性呢?就上面例子,可以通过Vehicle.version = 1
这种方式来定义。其实可以有更清晰友好的方式,但是目前没在ES6的正式版本中。如果使用babel,加入 stage-2 即可(stage提案的执行阶段)。然后就可以这样玩了。
class Vehicle {
timeCreated = new Date();
static version = '1.0';
}
理清关系
class A {
}
class B extends A {
}
B.__proto__ === A;
// true
B.prototype.__proto__ === A.prototype;
// true由于每个对象都有 [Prototype] 属性,指向构造该对象的构造函数的的
prototype
属性。class的本质是一个普通function
,同时也是一个普通对象。当通过extends
实现继承时,而同时存在prototype
和 [Prototype] 两条继承链。(1) 子类的 [Prototype] 属性,表示构造函数的继承,总是指向父类。
(2) 子类
prototype
属性的 [Prototype] 属性,表示方法的继承,总是指向父类的prototype
属性。super问题
我们上面提到了当调用
super.xxx
时,其实内部是会从父类的prototype
上查找。所以任何不存在protoype
上的属性/方法是无法通过super
得到的。class Vehicle {
constructor(engines) {
this.engnes = engines;
}
}
class Car extends Vehicle {
constructor(engines) {
super(engines);
}
print() {
console.log('super: ' + super.engines);
console.log('self: ' + this.engines);
}
}
const car = new Car(2);
car.print();
// super: undefined
// self: 2
访问权限
ES6并没有提供对于class的访问权限控制,因此也不存在私有变量/方法。但是如果你一定想要,也可以达到类似的效果。
const _log = Symbol();
class Vehicle {
constructor(engines) {
this.engnes = engines;
}
[_log]() {
const now = new Date();
console.log(`[${now}]: logging`);
}
ignition() {
console.log('Turning on ' + this.engines + ' engines!');
this[_log]();
}
}
export default Vechicle;上面的示例中利用ES的
Symbol
每次运行得到的都是不一样的值的特性,将需要私有的属性封装在模块内部,然后再通过模块的export
将该class暴露出去。