28

继承

我们知道JS是OO编程,自然少不了OO编程所拥有的特性,学习完原型之后,我们趁热打铁,来聊聊OO编程三大特性之一——继承。

继承这个词应该比较好理解,我们耳熟能详的,继承财产,继承家业等,他们的前提是有个继承人,然后你是继承者,这样才有继承而言。没错,JS中的继承正如你所理解的一样,也是成对出现的。
继承就是将对象的属性复制一份给需要继承的对象

OO语言支持两种继承方式:接口继承和实现继承,其中接口继承只继承方法签名,而实现继承则继承实际的方法。由于ECMAScript中的函数没有签名,因此无法实现接口继承,只支持实现继承,而继承的主要方式,是通过原型链实现的,要理解原型链,首先要知道什么是原型,不懂的小伙伴,可以看这篇博文一眼看穿👀JS原型
其实继承说白了就是
①它上面必须有个父级
②且它获取了这个父级的所有实例和方法

这里普及一个小概念,上文提到的没有签名,第一次看这个字眼也不是很懂,搜索了一下,觉得这个说法还是比较认可的。

没有签名
我们知道JS是弱类型语言,它的参数可以由0个或多个值的数组来表示,我们可以为JS函数命名参数,这个做法只是为了方便,但不是必须,也就是说,我们命不命名参数和传不传参数没有必然联系,既可以命名参数,但不传(此时默认为undefined),也可以不命名参数,但传参数,这种写法在JS中是合法的,反之,强类型语言,对这个要求就非常严格,定义了几个参数就一定要传几个参数下去。命名参数这块必须要求事先创建函数签名,而将来的调用也必须与该签名一致。(也就是说定义几个参数就要传几个下去)**,而js没有这些条条框框,解析器不会验证命名参数,所以说js没有签名。

举个🌰

function JSNoSignature () {  
  console.log("first params" + arguments[0] + "," + "second params" + arguments[1]);
}
JSNoSignature ("hello", "world");

这个🌰很明显了。命名参数为空,但我们依旧可以传参,调用该方法。所谓的参数类型,参数个数,参数位置,出入参数,js统统不关心,它所有的值都被放到arguments中了,需要返回值的话直接return,不用声明,这就叫做js没有签名。

原型链

什么是原型链呢?字面上也很好理解,就是将所有的原型串在一起就叫做原型链。当然这个解释只是为了方便理解罢了,原型链是作为实现继承的主要方法,其基本思想是利用原型一个引用类型继承另一个引用类型的属性和方法。我们知道每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型的内部指针。此时,如果我们让原型对象等于另外一个类型的实例,那么此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针,这种周而复始的连接关系,就构成了实例与原型的链条,这就是原型链。

一句话说白了,就是实例→原型→实例→原型→实例... 连接下去就是原型链。

我觉得继承就是原型链的一种表现形式

我们知道了原型链后,要知道他如何去使用,ECMA提供一套原型链的基本模式基本模式👇

原型链的基本模式

// 创建一个父类
function FatherType(){
    this.fatherName = '命名最头痛';
}

FatherType.prototype.getFatherValue = function() {
    return this.fatherName;
}

function ChildType(){
    this.childName = 'George';
}

// 继承了FatherType,即将一个实例赋值给函数原型,我们就说这个原型继承了另一个函数实例
// 将子类的原型指向这个父类的实例
ChildType.prototype = new FatherType();

ChildType.prototype.getChildValue = function() {
    return this.childName;
}

let instance = new ChildType();
console.log(instance.getFatherValue()); // 命名最头痛
 

调用instance.getFatherValue()时会经历三个搜索步骤
①搜索实例
②搜索ChildType.prototype
③搜索FatherType.prototype,此时在这步找到该方法,在找不到属性或方法的情况下,搜索过程总是要一环一环地向前行到原型链末端才会停下来。

此时的原型链是instance → ChildType.prototype → FatherType.prototype
执行instance.getFatherValue()后,getFatherValue里面的this是ChildType,此时ChildType会根据原型链去找fatherName属性,最终在FatherType中找到。
此时instance.constructor是指向FatherType的

默认的原型

所有的引用类型默认都继承了Object,而这个继承也是通过原型链实现的,因此,所有函数的默认原型都是Object的实例,因此默认原型都会包含一个内部指针,指向Object。prototype,这也就是所有自定义类型都会继承toString(),valueOf()等默认方法的根本原因。
Array类型也是继承了Object类型的。
因此,我们可以总结一下,在原型链的最顶端就是Object类型,所有的函数默认都继承了Object中的属性。

原型和实例关系的确认

isPrototypeOf方法

一眼看穿👀JS原型中我们有提到过isPrototypeOf方法可以用于判断这个实例的指针是否指向这个原型,这一章我们学习了原型链,这里做个补充,按照原型链的先后顺序,isPrototypeOf方法可以用于判断这个实例是否属于这个原型的

依旧用上面那个🌰
// 注意,这里用的是原型,Object.prototype,FatherType.prototype,ChildType.prototype
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(FatherType.prototype.isPrototypeOf(instance)); // true
console.log(ChildType.prototype.isPrototypeOf(instance)); // true

下面再介绍另一种方法,通过instanceof操作符,也可以确定原型和实例之间的关系

instanceof操作符

instanceof操作符是用来测试原型链中的构造函数是否有这个实例

console.log(instance instanceof Object); // true
console.log(instance instanceof FatherType); // true
console.log(instance instanceof ChildType); // true

与isPrototypeOf不同的是,前者用的是原型,后者用的是构造函数

定义方法时需要注意的问题

①给原型添加方法的代码一定要放在继承之后,这是因为,在继承的时候被继承者会覆盖掉继承者原型上的所有方法

function FatherType(){
    this.fatherName = '命名最头痛';
}

FatherType.prototype.getFatherValue = function() {
    return this.fatherName;
}

function ChildType(){
    this.childName = 'George';
}

// 继承了FatherType
ChildType.prototype = new FatherType();

// 创建实例
let instance = new ChildType();

// 为ChildType原型上添加新方法,要放在继承FatherType之后,这是因为new FatherType()会将ChildType原型上添加的新方法全部覆盖掉

ChildType.prototype.getChildValue = function() {
    return this.childName;
}

// 此时getFatherValue被重写了
ChildType.prototype.getFatherValue = function() {
    return true
}

console.log(instance.getFatherValue()); // true 

②通过原型链实现继承时,不能使用对象字面量创建原型方法,因为这样会重写原型链。这部分的🌰和解释在一眼看穿👀JS原型中《原型对象的问题》这一小节已经表述过了。一样的道理,只不过把原型换成了原型链罢了。

原型链的bug

原型链虽然强大,可以用它来实现继承,但是也是存在bug的,它最大的bug来自包含引用类型值的原型。也就是说原型链上面定义的原型属性会被所有的实例共享。
它还有另外一个bug,即在创建子类型的实例时,不能向父类型(超类型)的构造函数中传递参数。或者说没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。
基于以上这两个原因,实践过程中很少会单独使用原型链

借用构造函数

设计思想就是在子类型构造函数的内部调用父类(超类)构造函数
由于函数只不过是在特定环境中执行代码的对象,因此通过apply()和call()方法也可以在(将来)新创建的对象上执行构造函数。

function FatherType() {
  this.name = 'George';
} 

function ChildType() {
  //通过call方法改变this的指向,此时FatherType中的this指的是ChildType,相当于在构造函数中定义自己的属性。
  FatherType.call(this);
}

let instance1 = new ChildType(); 
instance1.name = '命名最头痛';
console.log(instance1.name); // '命名最头痛'

let instance2 = new ChildType();
console.log(instance2.name); // George

通过上述方法很好解决了原型属性共享问题,此外,既然是一个函数,它也能传相应的参数,因此也能实现在子类型构造函数中向超类型构造函数传递参数

function FatherType(name){
  this.name = name
}
function ChildType(){
  FatherType.call(this, "George");
  this.age = 18
}
let instance = new ChildType();
console.log(instance.name);  // George
console.log(instance.age);   // 18

借用构造函数的问题
借用构造函数,方法都在构造函数中定义,那么函数的复用就无从谈起,而且在父类(超类型)的原型定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。

组合继承

组合继承也叫伪经典继承,其设计思想是将原型链和借用构造函数的技术组合到一块,发挥二者之长的一种继承模式,其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承,这样既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。

function FatherType(name){
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

FatherType.prototype.sayName = function() {
  console.log(this.name)
}

// 借用构造函数实现对实例的继承
function ChildType(name, age){
  // 使用call方法继承FatherType中的属性
  FatherType.call(this, name);
  this.age = age
}

// 利用原型链实现对原型属性和方法的继承
ChildType.prototype = new FatherType(); //将FatherType的实例赋值给ChildType原型
ChildType.prototype.constructor = ChildType; // 让ChildType的原型指向ChildType函数
ChildType.prototype.sayAge = function(){
  console.log(this.age)
}
let instance1 = new ChildType('命名最头痛', 18);
instance1.colors.push('black');
console.log(instance1.colors);         // 'red, blue, green, black'
instance1.sayName();
instance1.sayAge();

var instance2 = new ChildType('命名最头痛', 18);
console.log(instance2.colors);         // 'red, blue, green'
instance2.sayName();                   // '命名最头痛'
instance2.sayAge();                    // 18

组合继承方式避免了原型链和借用构造函数的缺陷,是JS中常用的继承方式。

原型链继承

原型链继承没有使用严格意义上的构造函数,其思想是基于已有的对象创建新对象

// 此object函数返回一个实例, 实际上object()对传入其中的对象执行了一次浅复制.
function object(o) {
  function F() {}  // 创建一个临时构造函数
  F.prototype = o; // 将传入的对象作为构造函数的原型
  return new F();  // 返回这个临时构造函数的新实例
}

let demo = {
  name: 'George',
  like: ['apple', 'dog']
}

let demo1 = object(demo);
demo1.name = '命名';     // 基本类型
demo1.like.push('cat'); // 引用类型共用一个内存地址

let demo2 = object(demo);
demo2.name = '头痛';    // 基本类型
demo2.like.push('chicken') // 引用类型共用一个内存地址
console.log(demo.name) // George
console.log(demo.like) // ["apple", "dog", "cat", "chicken"]

原型链继承的前提是必须要有一个对象可以作为另一个对象的基础。通过object()函数生成新对象后,再根据需求对新对象进行修改即可。 由于新对象(demo1, demo2)是将传入对象(demo)作为原型的,因此当涉及到引用类型时,他们会共用一个内存地址,引用类型会被所有实例所共享,实际上相当于创建了demo对象的两个副本。

Object.create()方法

ECMA5中新增Object.create()方法规范化了原型式继承。该方法接收两个参数
①基础对象,这个参数的实际作用是定义了模板对象中有的属性,就像上面🌰中的demo,只有一个参数情况下,Object.create()与上🌰中的object相同
②这个是可选参数,一个为基础对象定义额外属性的对象, 该对象的书写格式与Object.defineProperties()方法的第二个参数格式相同,每个属性都是通过自己的描述符定义的,以这种方式指定的任何属性都会覆盖原型对象上的同名属性。
// 只有一个参数
var demoObj = {
  name: 'George',
  like: ['apple', 'dog', 'cat']
}
let demo1Obj = Object.create(demoObj);
demo1Obj.name = '命名';
demo1Obj.like.push('banana');

let demo2Obj = Object.create(demoObj);
demo2Obj.name = '头痛';
demo2Obj.like.push('walk');

console.log(demoObj.like) //["apple", "dog", "cat", "banana", "walk"]

// 两个参数
var demoObj = {
  name: 'George',
  like: ['apple', 'dog', 'cat']
}

let demo1Obj = Object.create(demoObj, {
  name: {
    value:'命名'
  },
  like:{
    value: ['monkey']
  },
  new_val: {
    value: 'new_val'
  }
});
console.log(demoObj.name) // George
console.log(demo1Obj.name) // 命名
console.log(demo1Obj.like) // ["monkey"]
console.log(demo1Obj.new_val) // new_val
console.log(Object.getOwnPropertyDescriptor(demo1Obj,'new_val')) // {value: "new_val", writable: false, enumerable: false, configurable: false}

如果只想让一个对象与另一个对象保持类型的情况下,原型式继承是完全可以胜任的,不过要注意的是,引用类型值的属性始终都会共享相应的值。

寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路,其设计思想与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数内部以某种方式来增强对象,最后返回一个对象。

// 这个函数所返回的对象,既有original的所有属性和方法,也有自己的sayHello方法
function createAnother(original) {
  let clone = Object.create(original);
  clone.sayHello = function(){            
    console.log('HELLO WORLD')
  }
  return clone;
}

let person = {
  name: 'George',
  foods: ['apple', 'banana']
}

let anotherPerson = createAnother(person);
anotherPerson.sayHello();  // HELLO WORLD

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。

寄生组合式继承

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混合形式来继承方法。其背后思想:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。说白了就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

function inheritPrototype(childType, fatherType){
  let fatherObj = Object.create(fatherType.prototype);  // 创建对象
  fatherObj.constructor = childType;   // 弥补重写原型而失去的默认constructor属性
  childType.prototype = fatherObj;     // 指定对象
}

上🌰是寄生组合式继承最简单的形式,这个函数接受两个参数:子类型构造函数和超类型构造函数,在函数内部,①创建了父类型原型的一个副本,②为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性。③将新创建的对象(即副本)赋值给子类型的原型。

function FatherType(name){
  this.name = name;
  this.foods = ['apple', 'banana'];
}
FatherType.prototype.sayName = function(){
  console.log(this.name)
}
function ChildType(name, age){
  FatherType.call(this, name);
  this.age = age;
}
inheritPrototype(ChildType, FatherType);
ChildType.prototype.sayAge = function(){
  console.log(this.age)
}

小结

  • JS继承的主要方式是通过原型链实现的
  • 实例-原型-实例-原型...无限链接下去就是原型链
  • 所有引用类型的默认原型都是Object
  • instanceof操作符和isPrototypeOf方法都可以用于判断实例与原型的关系,其区别是,前者用的是原型,后者用的是构造函数
  • 给原型添加方法的代码一定要放在继承之后,这是因为,在继承的时候被继承者会覆盖掉继承者原型上的所有方法
  • Object.create()方法用于创建一个新对象,其属性会放置在该对象的原型上
  • 继承有6种方式,分别是原型链,借用构造函数,组合继承,原型式继承,寄生式继承和寄生组合式继承

命名最头痛
385 声望655 粉丝

a == b ? a : b