高程读后感(三)— JS对象实现继承的6种模式及其优缺点


目录
  • 1.原型链
    • 1.1.默认的原型
    • 1.2.原型和实例的关系
    • 1.3.原型链的问题
  • 2.借用构造函数
    • 2.1.传递参数
    • 2.2.借用构造函数的问题
  • 3.组合继承
  • 4.原型式继承
  • 5.寄生式继承
  • 6.寄生组合式继承
  • 小结

1.原型链

原型链是实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。让原型对象等于另一个类型的实例,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函的指针。假如上述关系层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。

function Parents() {
  this.property = true;
}
           
Parents.prototype.getParentsValue = function() {
  return this.property;
};
           
function Son() {
  this.sonproperty = false;
}
           
Son.prototype = new Parents();
           
Son.prototype.getSonValue = function () {
  return this.sonproperty;
};
           
let instance = new Son();
console.log(instance.getParentsValue());  // true

通过创建Parents的实例,并将该实例赋给 Son.prototype实现了Son继承 Parents,实现的本质就是用一个新类型的实例重写原型对象,原来存在于Parents的实例中的所有属性和方法,此时同样存在于Son.prototype中。在确立了继承关系之后,给Son.prototype 添加一个方法,这样就在继承了Parents的属性和方法的基础上又添加了一个新方法。
最终结果就是:instance指向Son的原型,Son的原型又指向Parents的原型。

  1. 因为getParantsValue()方法原型方法所以仍然还在 Parents.prototype 中,

  2. 而property 是实例属性则位于Son.prototype中。因为 Son.prototype 现在是 Parents的实例,那么property当然就位于该实例中了。

  3. 此外,instance.constructor现在指向Parents,因为原来Son.prototype 中的constructor被重写了。

通过原型链实现继承,扩展到原型链搜索机制:当以读取模式访问一个实例的属性时,首先会在实例中搜索该属性,如果没有找到该属性,则会继续搜索实例的原型,在通过原型链实现继承的情况下,搜索过程就得以沿着原型链继续向上,搜索过程总是要一环一环地前行,直到原型链末端才会停下来。

1.1.默认的原型

事实上,所有函数的默认原型都是Object的实例,且都会包含一个内部指针,指向Object.prototype。因此所有自定义类型才会继承toString 、valucof等默认方法。

1.2.原型和实例的关系

可以通过两种方式来确定原型和实例之间的关系。

第一种方式是使用instanceof操作符,测试实例与原型链中出现过的构造函数,也就是说,操作符右侧的构造函数是否出现在左侧实例的原型链上,出现即返回true。

由于原型链的关系,instance是Object、Parents或Son中任何一个类型的实例。因此结果都会返回true

console.log(instance instanceof Object);  //true 
console.log(instance instanceof Parents); //true 
console.log(instance instanceof Son);     //true 

第二种方式是使用isPrototypeOf()方法。同样。只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型,因此也会返回true。

console.log(Object.prototype.isPrototypeOf(instance));  //true 
console.log(Parents.prototype.isPrototypeOf(instance)); //true 
console.log(Son.prototype.isPrototypeOf(instance));     //true 

1.3.原型链的问题

  1. 其最主要的问题来自包含引用类型值的原型。在时,介绍过包含引用类型值的原型属性会被所有实例共享,这也正是为什么要在构造函数中定义属性而不是在原型对象中的原因。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先Parents的实例属性也就顺理成章地变成现在Son的原型属性了。
function Parents() {
  this.colors = ["red", "blue", "green"];
}
           
function Son() {}
           
Son.prototype = new Parents();
           
let son1 = new Son();
let son2 = new Son();
son1.colors.push("black");
console.log(son1.colors);   // ['red', 'blue', 'green', 'black']
console.log(son2.colors);   // ['red', 'blue', 'green', 'black']  

console.log(new Parents().colors);  // ['red', 'blue', 'green']

Parents 的每个实例都会有各自的colors属性(一个数组,引用类型)。当Son通过原型链继承了 Parents之后,Son.prototype就变成了Parents的一个实例,因此它也拥有了一个它自己的 colors 属性--就如同创建了Son.prototype.colors属性一样。结果是导致Son的所有实例都会共享这一个colors属性。

  1. 第二个问题是:在创建子类实例时,没有办法在不影响所有对象实例的情况下,给超类构造函数传递参数。鉴于这两大问题,实践中很少会单独使用原型链。

2.借用构造函数

借用构造函数也叫做伪造对象或经典继承。在子类型构造函数的内部调用超类型构造函数。通过使用apply()和call()方法可以在新创建的对象上执行构造函数。

Son实例的环境中调用了Parents构造函数,就会在Son对象上执行Parents()函数中定义的所有对象初始化代码,Son的每个实例就都会具有自己的colors属性的副本了。

function Parents(){
	this.colors = ["red","blue","green"];
}
function Son(){
    //继承Parents
    Parents.call(this);
}
var son1 = new Son(); 
son1.colors.push("black");
console.log(son1.colors); // ['red', 'blue', 'green', 'black']
var son2 = new Son();
console.log(son2.colors); // ['red', 'blue', 'green']

2.1.传递参数

相对于原型链而言。借用构造函数有一个很大的优势,就是子类构造函数中可以向超类构数传递参数。

function Parents(name){
	this.name = name;
}
function Son(){
    //继承了Parents,同时还传递了参数 
    Parents.call(this,"Echo");   
    // 实例属性
	this.age = 18;
}

var son1 = new Son();
console.log(son1.name); // Echo
console.log(son1.age);  // 18

为了确保超类构造函数不会重写子类对象的属性,可以在调用超类构造函数,再添加子类自定义属性

2.2.借用构造函数的问题

如果仅仅是借用构造函数,那么也将无法避免构造函数模式存在的问题,对象的方法都在构造函数中定义,因此函数复用就无从谈起了。同样也存在使用构造函数模式创建对象时,导致的内存浪费等问题,而且,在超类的原型中方法,对子类是不可见的,因此很少单独使用借用构造函数模式实现继承。

3.组合继承

组合继承也叫做伪经典继承:是结合了原型链和借用构造函数,发挥二者之长的一种继承模式。

原理:使用原型链实现对原型属性和方法的继承,借用构造函数实现对实例属性的继承。因此通过在原型上定义方法实现了函数复用,又能够保证每个实例都有自己的属性。

function Parents(name){
    this.name = name;
    this.colors = ["red","blue","green"];
}
Parents.prototype.sayName = function(){
    console.log(this.name);
} 
function Son(name, age){
    //继承属性
    Parents.call(this, name);
	this.age = age;
}
//继承方法
Son.prototype = new Parents();
Son.prototype.constructor = Son; 
Son.prototype.sayAge = function(){
    console.log(this.age); 
}
 
var son1 = new Son("Echo", 18); 
son1.colors.push("black");
console.log(son1.colors); // ['red', 'blue', 'green', 'black']
son1.sayName(); // Echo; 
son1.sayAge();  // 18 

var son2 = new Son("yya",17);
console.log(son2.colors); // ['red', 'blue', 'green']
son2.sayName(); // yya 
son2.sayAge();  // 17 
  1. Parents构造函数定义了 :两个属性name,colors,一个原型方法sayName()。
  2. Son构造函数在调用Parents构造函数时传入了name参数,定义自己的属性age。然后,将Parents的实例赋值给Son的原型,又在该新原型上定义了方法sayAge()。这样就可以让两个不同的Son实例既分别拥有自己属性,又可以使用相同的方法。
  3. 组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为JavaScript中最常用的继承模式。而且,instanceof和isPrototypeOf()也能够识别基于组合继承创建的对象。

4.原型式继承

借助原型可以基于已有的对象创建新对象。在object()函数内部,先创建了一个临时性的构造函数F,然后将传入的对象作为F的原型,最后返回F类型的一个实例。

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

从本质上讲,object()对传入其中的对象执行了一次浅拷贝。

var person = {
    name: "nhy",
    friends: ["shelby","Court","Van"]
}

var o1 = object(person); 
o1.name = "Echo";
o1.friends.push("Rob");

var o2 = object(person); 
o2.name = "yya";
o2.friends.push("Barbie");
console.log(person.friends); // ['shelby', 'Court', 'Van', 'Rob', 'Barbie']

把基础对象person传入object()函数中,就会返回一个将person作为原型的新对象。它的原型中就包含一个基本类型值属性和一个引用类型值属性。这意味着friends不仅属于person,而且也会被o1及o2共享。这就相当于创建了person对象的两个副本。
ES5新增Object.create()方法规范化了原型式继承。接收两个参数:新对象的原型和为新对象定义额外属性的对象(可选)。仅传一个参数时Object.create()与object()相同。而第二个参数格式同Object.defineProperties()方法,每个属性都是通过自己的描述符定义。

var person = {
    name: "nhy",
    friends: ["shelby","Court","Van"]
}
var o3 = Object.create(person, {
    name: {
        value: "Echo"
    }
});
console.log(o3.name); // Echo"

var o4 = Object.create(person, {
    name: 'yya' // 错误写法 
});
console.log(o4.name); // TypeError: Property description must be an object: yya

只想让一个对象与另一个对象保持类似时,没有必要创建构造函数。原型式继承是完全可以胜任。不过包含引用类型值的属性始终都会共享相应的值,同使用原型模式一样。

5.寄生式继承

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

function createAnother(o){ // o 作为新对象基础的对象
    var clone = object(o);  // 通过调用函数创建一个新对象
    clone.sayHi = function(){ // 以某种方式来增强这个对象 
        console.log("hi");
    }; 
    return clone; //返回这个对象 
}
var person = {
    name: "nhy",
    friends: ["shelby","Court","Van"]
}
var ca1 = createAnother(person); 
ca1.sayHi(); // hi

基于person返回了一个新对象,该对象不仅具有person的所有属性和方法,还有自己的sayHi()方法。

  1. 在主要考虑对象而不是自定义类型或构造函数的情况下,寄生式继承也是一种有用的模式。而object()函数不是必需的,任何能够返回新对象的函数都适用于此模式。
  2. 使用寄生式继承来为对象添加函数,由于不能做到函数复用而降低效率,这一点同构造函数模式。

6.寄生组合式继承

前面说组合继承是JavaScript最常用的继承模式,不过它也有不足。组合继承其最大的问题就是无论什么情况,都会调用两次超类型构造函数:

  1. 第一次是在创建子类型原型时,
  2. 第二次在子类型构造函数内部。

虽然子类型最终会包含超类型全部实例属性,但却不得不在调用子类型构造函数时重写这些属性。

function Parents(name){
    this.name = name;
    this.colors = ["red","blue","green"];
}
Parents.prototype.sayName = function(){
    console.log(this.name);
}
function Son(name, age){
	Parents.call(this,name);     // 第二次调用 Parents() 
	this.age = age
}
Son.prototype = new Parents();   // 第一次调用 Parents() 
Son.prototype.constructor = Son; 
Son.prototype.sayAge = function(){
	console.log(this.age);
}
var son1 = new Son('Echo',18);
son1.sayAge();  // 18

第一次调用Parents构造函数:Son.prototype会得到属性name和colors,它们是Parents的实例属性,只不过现在位于Son的原型中。当调用Son构造函数时,又会调用一次Parents构造函数,这一次又在新对象上创建了实例属性name和colors。于是,这两个属性就屏蔽了原型中的两个同名属性。

有两组name和colors:一组在实例上,一组在Son原型上。这就是调用两次Parents构造函数的结果。解决这个问题方法就是寄生组合式继承。通过借用构造函数来继承属性,通过原型链的混成形式来继承方法

其实现思路:不必为了指定子类的原型而调用超类的构造函数,我们所需要的仅是超类原型的一个副本。本质上,就是使用寄生式继承来继承超类的原型,然后再将结果指定给子类的原型。

function inheritPrototype(son, parents){
    var prototype = Object(parents.prototype); // 创建对象
    prototype.constructor = son;               // 增强对象 
    son.prototype = prototype;                 // 指定对象
}

函数接收两个参数:子类构造函数和超类构造函数。

  1. 第一步是创建超类原型的一个副本。
  2. 第二步是为创建的副本添加constructor属性,弥补因重写原型而失去的默认的constructor属性
  3. 第三部将新创建的对象(即副本)赋值给子类的原型。调用inheritPrototype,替换为子类原型赋值。
function Parents (name){
    this.name = name;  
    this.colors = ["red","blue","green"];
} 
Parents.prototype.sayName = function(){
    console.log(this.name);
}
function Son(name, age){
	Parents.call(this,name);     
	this.age = age
}
inheritPrototype(Son, Parents)
Son.prototype.sayAge = function(){
	console.log(this.age);
}

var s1 = new Son('Echo',18);
s1.sayName();  // Echo

只调用了一次Parents构造函数,原型链还能保持不变,并且避免了在Son.prototype上创建多余的属性。还能够正常使 instanceof和isPrototypeOf()。

小结

  • 原型链继承:原型链的构建是通过将超类的实例赋给子类构造函数的原型实现的。这样,子类就能访问超类的所有属性和方法。缺点是实例对象共享所有继承的属性和方法,不适宜单独使用。
  • 借用构造函数继承:在子类构造函数的内部调用超类构造函数。使得每个实例都有自己的属性,缺点是方法定义在构造函数中,无法实现复用。
  • 组合继承:(最常用)使用原型链继承共享属性和方法,通过借用构造函数继承实例属性。
  • 原型式继承:可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅拷贝。得到的副本还可以进一步改造。
  • 寄生式继承:与原型式继承非常相似,也是基于某个对象创建一个对象,然后增强对象,最后返回对象。
  • 寄生组合式继承:为了解决组合继承模式多次调用超类构造函数而导致的低效率的同题,可以将寄生式继承与组合继承一起使用。

相关