写久了ES6的class、extends,你是否还记得在ES5中如何实现类与类的继承

3,163 阅读7分钟

面向对象程序设计

面向对象程序设计(Object Orientend Programming, OOP) 是一种计算机编程范式,通过尽可能的模拟人类的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界,解决现实问题的方法和过程。其主要目标是重用、灵活性和扩展型。 OOP = 对象 + 类 + 多态 + 消息,其核心是类与对象。

类(class) 是对现实世界的抽象,包括表示静态属性的数据和对数据的操作,对象(Object 则是类的实例化。

面向对象程序设计具有封装性、继承性、多态性三大特点。封装性 是指将计算机系统中的数据以及对数据的操作组装到一起,一并封装到一个有机的实体中去,也就是一个类中。继承性 后者延续前者的特点,复用前者的数据和对数据的操作方法。多态性 即多个对象接收到同一个完全相同的消息之后,所表现出来的动作各不相同,具有多种形态。

ES6中的类与继承

看完上面枯燥无味的概念,我们回到Javascript中,在E6里,实现类只需用Class关键字即可,实现继承也只需要用extends关键子字即可,看起来和其他OOP语言(Java,C++)的写法类似,但内部的实现机理却完全不一样。

class Person {
  constructor(name) { this.name = name }
  say() { console.log(this.name) }
}

class Student extends Person { 
  constructor(name, id) {
    super(name);
    this.id = id;
  }
  say1() { console.log(this.name, this.id) }
}

ES6中虽然提供了类的语法,但本质还是基于函数模拟去实现类的,继承也是基于原型链

console.log(typeof Person); // function
console.log(Person.prototype.say); // [Function: say]
console.log(Student.prototype.__proto__); // Student {}

ES5中类的实现

前面提到ES6虽然提供了类语法,但本质还是基于函数模拟和原型链来实现类与类的继承,那么还记得ES5的JavaScript是如何去实现类嘛?

我们都知道,通过类可以创建任意多个具有相同属性的方法和对象,但ES5中并没有类的概念。那么既然是创建对象,就一定需要类嘛?不一定,比如我们经常写的一类函数,传入参数返回一个对象,这也被称为工厂模式

function createPerson(name) { 
  return {
     name: name,
     say: function() { console.log(this.name) } 
  }
}

当然为了更符合OOP的编程习惯(new),有了下面的构造函数模式

function Person(name) {
  this.name = name;				// 属性
  this.say = function() { // 方法
  	console.log(this.name)
  }
}

通过new关键字调用产生一个Person实例。而在new的过程中,会创建一个新对象,将构造函数的作用域赋给新对象,并且this指向了这个新对象。执行构造函数中的代码,为这个对象添加属性,然后返回新对象。值得注意的是,如果直接调用这个函数的话,this会指向global,相当于给global添加属性和方法。

然而细心的读者可能发现了这种模式的问题,即每一个方法都需要在每个实例上重新创建一遍,体现不出来类实例共享数据操作的优势。

由于JavaScript是一门基于原型的语言,创建的每一个对象都有一个prototype(原型属性),这个属性是一个指向某一个对象的指针,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。

function Person() {}
Person.prototype.name = "sundial-dreams";
Person.prototype.say = function() { console.log(this.name) }

当我们创建一个函数时,会为这个函数创建一个prototype属性,这个属性指向函数的原型对象,并且原型对象会自动获得一个constructor属性(不可枚举),该属性指向创建的函数本身。

console.log(Person.prototype); // Person { name: 'sundial-dreams', say: [Function] }
console.log(Person.prototype.contructor); // [Function: Person]
console.log(new Person().__proto__); // Person { name: 'sundial-dreams', say: [Function] }

基于原型的特点,我们可以将类方法放到原型中去实现,类的属性放到构造函数中去实现,因此有了下面这种组合模式(构造函数+原型)

function Person(name) {
  this.name = name;
}
Person.prototype.say = function () { console.log(this.name) }

这也是也是我们在ES5中最常用的一种实现类的方式。

ES5中的继承

说完ES5中类实现,结下来再来分析ES5中继承的实现。继承的本质是代码复用,复用前者的属性和方法,许多OOP语言都支持接口继承(implements)和实现继承(extends),而JavaScript只支持实现继承,而且还是依靠原型链去实现。

还记得对象是怎么查找自己的属性的嘛,先自己内部找,没有在去原型对象上找,还没有就去原型对象的原型对象上找(一直套娃),直到原型对象为null时还没找到,则返回个undefined。另外我们也知道函数的默认原型都是Object,其实找到最远也就是Object了。下面是按照链表的方式遍历每一个原型对象,遍历到最后面其实就是Object了。

var p = new Person(), t = p.__proto__;
while (t) {
    console.log(t.constructor)
    t =  t.__proto__;
}
// [Function: Person]
// [Function: Object]

下面这个例子也许更能解释清楚原型链

Object.prototype._name = "sundial-dreams";
var p = new Person("dpf");
console.log(p._name); // sundial-dreams 等价于p.__proto__.__proto__._name

补充了点原型链的知识,先来看看简单的继承如何实现,既然继承就是复用前者的属性和方法,那么可以利用JavaScript函数的特点,通过call来给后者的this添加前者的属性,也就是借用构造函数模式

function Person(name) {
    this.name = name;
}

Person.prototype.say = function () {
    console.log(this.name);
}

function Student(name, id) {
    Person.call(this, name); // 给this添加Person里的属性
    this.id = id;
}

Student.prototype.say1 = function () {
    console.log(this.name, this.id)
}

但读者也已经发现了问题,这种继承方式只把属性给拿过来,方法还没拿过来呢,这能配叫继承嘛。

所以既然要继承方法,那还不简单,利用原型链的知识,只要把Student的原型对象指向Person实例化的对象就可以了,也就是组合继承方式

function Student(name, id) {
    Person.call(this, name); // 给this添加Person里的属性
    this.id = id;
}
Student.prototype = new Person(); // 原型指向Person的实例,因此能有Person的属性和方法
Student.prototype.constructor = Student; // 别忘了修正构造函数

但我们只想继承父类的方法却每次都要实例化出父类的对象,并且还向原型中添加了父类的属性,感觉有点冗余,因此出现了下面这种原型式继承。利用个中间函数,剔除掉Person中的属性,只保留它的方法。

function F() {} // 中间函数
F.prototype = Person.prototype; // 剔除掉Person中的属性,保留函数定义的部分
Student.prototype = new F();    // 因此new F只包含Person的方法
Student.prototype.constructor = Student; // 修正构造函数

还记得Object.create函数嘛,他可以接受两个参数,第一个参数为原型,第二个参数为而外的新增属性。因此上面的写法可以转化为下面这种写法。

Student.prototype = Object.create(Person, { constructor: Student })

换个角度想,不就是想复用父类原型上的的方法嘛,那直接利用子类原型的__proto__属性,让他指向Person.prototype不就可以了嘛。按照上面讲的对象属性搜索规则,方法可以一直沿着原型链搜索到。暂且将这种方式称为优化版的原型式继承

Student.prototype.__proto__ = Person.prototype; // 这块没改Student.prototype.constructor,所以不需要修正它

当然如果觉得这么写可读性觉得差的话,还可以利用Object.setPrototypeOf来替代,比如

Object.setPrototypeOf(Student.prototype, Person.prototype); // 这块也没改Student.prototype.constructor,所以不需要修正它

当然,如果我们想继承静态属性或方法时,比如Person有一个静态方法

Person.hello = function () {
  console.log("hello world");
}

继承时其实可以继续使用Object.setPrototypeOf

Object.setPrototypeOf(Student, Person);

无论是哪种方式,本质上还是对原型链进行操作,所以只要对原型链理解深刻,对继承的本质有一定的理解,利用原型链写继承也不是什么难事。

总结

本文简单的扯了扯ES5中类的实现以及继承的实现,由于自己学的第一门OOP语言是C++,所以最开始接触到JavaScript的类与继承时其实理解了非常久,才慢慢的转化了过来,慢慢的接受了JavaScript的基于原型的设计思想。ES6的Class语法用起来确实舒服,但也别忘了类在JavaScript中最基础的实现。