九. 原型链和继承

92 阅读12分钟

九. 原型链和继承

9.1. 面向对象的特征

  • 面向对象有三大特性:封装、继承、多态

    • 封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程;
    • 继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中);
    • 多态:不同的对象在执行时表现出不同的形态;

这里先说继承,那么继承是做什么呢?

  • 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可。

那么JavaScript当中如何实现继承呢?

  • 我们先来看一下JavaScript原型链的机制; 再利用原型链的机制实现一下继承;

9.2. 原型链的理解

 var obj = {
     name: "why",
 }
 obj.__proto__ = {}
 obj.__proto__.__proto__ = {}
 obj.__proto__.__proto__.__proto__ = {
     address: "成都市"
 }
 console.log(obj.address);

image-20220224135020926.png

在obj对象里获取address属性,它会触发 [[Get]]的操作;

  • 这个操作会首先检查该属性是否有对应的属性,如果有的话就使用它;
  • 如果对象中没有该属性,那么会去原型链(_ _ proto_ _)对象上去找;
  • 如果顶级原型也没有找到,则返回undefined (这里先不讨论,下面会讲到)

假如在第一个{}里面增加address属性 ,那么在obj原型上就能找到address属性了,不会再往上找,

假如第一个{}原型里没有呢?又该怎么去查找,这时候就要引入原型链的概念了

  • 执行obj.address的目的是去obj上找address属性,如果找不到,会去obj的_ _ proto_ _ 找,就是我们第一个{},假如第一个{}也没有,就去第一个{}的_ _ proto_ _去接着找(一直找)...... 所有的原型会形成一个链条,我们称之为原型链

那么有个问题:原型链的终点是什么? 找到什么时候结束呢,

9.3. 顶层原型

顶层原型是什么?

 var obj = {
     name: "why",
 }
 console.log(obj.address); 
 console.log(obj.__proto__);  // Object函数的原型对象  
 console.log(obj.__proto__.__proto__);  // null

那么什么地方是原型链的尽头呢?

  • 打印obj._ _ proto _ _ 如下,他就是顶层原型(Object的原型),顶层原型的_ _ proto _ _指向null,

    image-20220224140606489.png

原型有什么特殊吗?

  • 特殊一:该对象有原型属性,但是它的原型属性已经指向的是null,也就是已经是顶层原型了;
  • 特殊二:该对象上有很多默认的属性和方法

顶层原型来自哪里

 var obj={}
 var obj2=new Object()

第一种字面量的写法实际是第二种的语法糖,内部还是通过new来创建对象的,创建obj对象时会进行如下操作

 function Object() {
     // 1.创建一个空对象
     var moni = {}
     // 2.将函数的prototype赋值给空对象的__proto__
     moni.__proto__ = Object.prototype
     // 3.this指向新对象
     this = moni
     // 4.执行函数内部代码
     // 5.返回this
     return this
 }

下面是验证obj的隐士原型 等于 Object函数的原型

 console.log(obj.__proto__);
 console.log(Object.prototype);
 console.log(obj.__proto__ === Object.prototype); // true

image-20220224201949102.png

如果下面代码,原型链又是怎样的呢?

 var obj = { name: "why", }
 obj.__proto__ = { address: "成都市" }
 console.log(obj.__proto__.__proto__ === Object.prototype); // true 验证0x002对象的原型指向顶层原型

image-20220225110459735.png

9.4. Object是所有类的父类

从我们上面的Object原型我们可以得出一个结论:原型链最顶层的原型对象就是Object的原型对象

我们现在回过头来再看下 Person构造函数的原型

 function Person(name){
     this.name=name
 }
 console.log(Person.prototype);
 console.log(Person.prototype.__proto__); // 顶层原型
 console.log(Person.prototype.__proto__.__proto__); // null

image-20220225130950908.png

9.5. 为什么需要继承

假如有这么一个场景:需要实现一个Student和Teacher对象,但是里面有太多重复代码(红色部分),这时候就需要把重复的代码放到父类,子类只放自己独有的代码

image-20220225151454892.png

9.6. 继承 - 原型链的继承方案

如果我们现在需要实现继承,那么就可以利用原型链来实现了:

  • Person 父类:公共属性和方法; Student 子类:独有属性和方法
 function Person() {
     this.name = "why"
 }
 Person.prototype.eating = function () {
     console.log(`${this.name}eating`);
 }
 ​
 function Student() {
     this.sno = 111
 }
 // 步骤四
 Student.prototype = new Person()
 // 步骤五
 Student.prototype.studying = function () {
     console.log(`${this.name}studying`);
 }
 ​
 let stu = new Student()
 console.log(stu.name);
 console.log(stu.eating());
  • 目前stu的原型是Person对象,而Person对象的原型是Person默认的原型,里面包含running等函数;

  • 注意:步骤4和步骤5不可以调整顺序,否则会有问题(studying函数是加在Student默认原型上的)

    • 步骤4是把Student的原型重新赋值为新建的Person对象,studying函数是在Person对象上添加的
    • 若将步骤4和步骤5交换顺序,则studying函数是在Student的原型上添加的,而Student原型后面都没有被引用了

    image-20220225165548889.png

但是目前有一个很大的弊端:某些属性其实是保存在Person对象上的;

  • 第一,打印stu对象,继承的属性(name, eating)看不到;

  • 第二,Person对象的属性会被多个对象共享,每个Student的实例都是Person对象

    • 如果修改Student原型上的属性,那么就会相互影响;如stu1对象修改friend,就影响了stu2对象的friend
    • 如果是直接修改对象上的属性,则没有影响;如stu1.name="a",实际是给本对象添加了一个name属性,stu1查找时,在本对象找到了name,便不会在沿着原型链往上找了,stu2查找时,本对象没有,但是在原型上找到了name。

    image-20220225181510711-16550998895891.png

  • 第三,不能给Person传递参数,因为这个对象是一次性创建的(没办法定制化);

    • 也就是没有把Student的参数给Person构造函数传过去

9.7. 继承 - 借用构造函数+继承的方案

为了解决原型链继承中存在的问题,开发人员(社区的大佬)提供了一种新的技术: constructor stealing (有很多名称: 借用构造函数或者称之为经典继承或者称之为伪造对象):

  • steal是偷窃、剽窃的意思,但是这里可以翻译成借用;

借用构造的做法非常简单:在子类型构造函数的内部调用父类型构造函数.

  • 因为函数可以在任意的时刻被调用;
  • 因此通过apply()和call()方法也可以在新创建的对象上执行构造函数;

原型链继承方案的三个问题就解决了

 function Person(name,friend) {
     this.name = name
     this.friend=friend
 }
 Person.prototype.eating = function () {
     console.log(`${this.name}eating`);
 }
 ​
 function Student(name,friend,sno) {
     Person.call(this,name,friend)
     this.sno = sno
 }
 // 步骤四
 Student.prototype = new Person()
 // 步骤五
 Student.prototype.studying = function () {
     console.log(`${this.name}studying`);
 }
 ​
 let stu1 = new Student("wangwu",[],111)
 let stu2 = new Student("lisi",[],222)
 stu1.friend.push("aaa")
 console.log(stu1); // Student {name: 'wangwu', friend: ['aaa'], sno: 111}
 console.log(stu2.friend); // []

注意:步骤四的new Person(),并没有传入name,friend,所有创建的Person对象有name,friend属性,但值为undefined

image-20220225194043271.png

借用、继承是JavaScript最常用的继承模式之一,它存在什么问题呢?

  • 第一,任何情况下,父类构造函数(Person)至少被调用两次

    • 步骤四,new Person()时会调用
    • 每次创建子类实例(Student)的时候会调用
  • stu1,stu2的原型对象上会多出一些属性,但是这些属性没有存在的必要。所有的子类实例事实上会拥有两份父类的属性,

    • 一份在当前的实例自己里面(也就是person本身的)
    • 另一份在子类对应的原型对象中(也就是person._ _proto _ _里面);

    但不用担心访问出现问题,因为默认一定是先访问实例本身这一部分的;

9.8. 继承 - 父类原型继承给子类

借用构造函数+继承方案的缺点,能否直接让 子类型的原型对象 = 父类型的原型对象 来解决呢?

 // 步骤四
 Student.prototype = Person.prototype

不要这么做,看起来执行代码没有问题,但从面向对象角度来说问题很大

  • 如果给Student原型添加一个独有属性studying,实际上studying是加在Person原型上,从面向对象来说,这是不对的,明明是给子类添加一个方法,却加在了父类的原型上,若后面又创建了一个Teacher对象,也在其父类上添加了一个私有属性teaching,studying属性也在Teacher的原型上,teaching属性也在Student的原型上,只要是继承了Person类,往原型上加东西实际都加在了所有子类的原型上,原型会原来越大,原型上的属性也会被所有子类共享

    image-20220226153106864.png

9.9. 继承 - 原型式继承-局限于对象

原型式继承的渊源

  • 这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的一篇文章说起: Prototypal Inheritance in JavaScript(在JS中使用原型式继承)
  • 在这篇文章中,它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的.
  • 为了理解这种方式,我们先再次回顾一下JavaScript想实现继承的目的:重复利用另外一个对象的属性和方法.

最终的目的:student对象的原型指向了person对象;

下面是将info对象的原型赋值为obj对象的三种实现方法

 let obj = {
     name: "why",
     age: 18
 }  
 //原型式继承函数(普遍的实现方式)
 function createObject(obj){
     let newObj= {}
     // Object.getPrototypeOf(newObj) = obj 
     Object.setPrototypeOf(newObj,obj) // 把obj赋值为newObj的原型
     return newObj
 }
 // 道格拉斯实现的方式(在那时候,还没有setPrototypeOf这个函数)
 function createObject2(obj){
     function fn(){}
     fn.prototype  = obj
     let newObj = new fn() // newObj.__proto__ = fn.prototype =obj
     return newObj
 }
 // ECMA 最新内置方法:将创建的info对象的隐士原型赋值为obj对象
 let info= Object.create(obj)
 ​
 // let info = createObject(obj)
 // let info = createObject2(obj)
 console.log(info);
 console.log(info.__proto__);
 console.log(info.name);

注:1. 开发中不要使用__ proto __ ;

  1. 寄生式继承只局限于对象

  2. 寄生式继承没有解决给子类添加属性问题,假如子类有100个,都需要挨个手动添加属性

9.10. 继承 - 寄生式继承-局限于对象

寄生式(Parasitic)继承 :新创建的stu对象,通过工厂函数,寄生在person对象里,

  • 寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的;
  • 寄生式继承的思路:原型式继承+工厂模式
  • 即创建一个封装继承过程的函数, 该函数在内部以某种方式来拓展对象,最后再将这个对象返回;
 let person = {
     runing: function () {
         console.log("runing");
     }
 }
 // let student= Object.create(person) ;不使用这种方式实现寄生式继承,
 // 原因:如果有100个student对象都有name属性,那么要添加100次该属性,重复代码太多·
 ​
 // 把原型式继承放到了工厂函数里面,在这个工厂函数里面再对某个对象(或者类)做一个增强/拓展。
 function createStudent(name) {
     let stu = Object.create(person)
     stu.name = name
     stu.studying = function () {
         console.log("studying");
     }
     return stu
 }
 let student1 = createStudent("zhangsan")
 let student2 = createStudent("lisi")

寄生式继承也有它的弊端:

  • 创建对象时都会创建一个studying函数,
  • 不知道工厂函数创建的对象是什么类型

9.11. 继承 - 寄生组合式继承

 // Person:父类
 function Person(name, age, friends) {
     this.name = name
     this.age = age
     this.friends = friends
 }
 Person.prototype.runing = function () {
     console.log("跑步");
 }
 Person.prototype.eating = function () {
     console.log("吃饭");
 }
 ​
 // Student:子类
 function Student(name, age, friends, sno, score) {
     Person.call(this, name, age, friends)
     this.sno = sno
     this.score = score
 }
 // 创建新对象的隐式原型指向Person的显示原型,将新对象赋值给Student的显示原型
 Student.prototype = Object.create(Person.prototype)
 // 给Student原型添加constructor属性  
 // 注:不要和上面代码交换位置,Student原型必须是新创建的对象,原来的原型后面都没有使用了
 Object.defineProperty(Student.prototype, "constructor", {
     enumerable: false,
     configurable: true,
     writable: true,
     value: Student
 })
 Student.prototype.studying = function () {
     console.log("学习");
 }
 ​
 let stu = new Student("张三", 18, "张衡", 111, 100)
 console.log(stu);
 stu.studying()
 stu.runing()
 stu.eating()

image-20220227184916197.png

为了方便使用,可以封装成一个函数,如下

 function inheritPrototype(subType, superType) {
     subType.prototype = Object.create(superType.prototype)
     Object.defineProperty(subType.prototype, "constructor", {
         enumerable: false,
         configurable: true,
         writable: true,
         value: subType
     })
 }
 // Person:父类
 function Person(name, age, friends) {
     this.name = name
     this.age = age
     this.friends = friends
 }
 Person.prototype.runing = function () {
     console.log("跑步");
 }
 Person.prototype.eating = function () {
     console.log("吃饭");
 }
 ​
 // Student:子类
 function Student(name, age, friends, sno, score) {
     Person.call(this, name, age, friends)
     this.sno = sno
     this.score = score
 }
 inheritPrototype(Student, Person)
 Student.prototype.studying = function () {
     console.log("学习");
 }
 ​
 let stu = new Student("张三", 18, "张衡", 111, 100)
 console.log(stu);
 stu.studying()
 stu.runing()
 stu.eating()

问:如果不给Student.prototype增加constructor属性,在window打印stu是Student类型是没有问题的;但是在node打印的话,却是Person类型,这是为什么?

答:先说一个结论:Person是stu.constructor.name的值。 Student的原型是新对象,它是没有constructor属性,但他会帮我们去找stu的constructor,自身没有,就去原型上找,它的原型是Person的原型,Person是有constructor,但它是指向自己Person函数,所以最终的stu.constructor.name打印是Person

image-20220227112741544.png

疑惑: 为什么要建一个新对象,为什么不直接让子类对象的原型的proto指向父类的显示原型? 即Student.prototype.__ proto__ = Person.prototype

  • 缺点一:学生a有write:function(){}属性,但学生b没有这个属性,而函数放在原型才不会重复创建函数,write没有合适地方放置,应该每个学生实例的原型都是唯一,不会相互影响
  • 缺点二:所有Student实例的对象的原型都是一样,修改了原型上的属性,会相互影响。(这也是借用构造函数+继承方案的缺点)

9.12. 原型方法补充

  • create()

  • hasOwnProperty() 返回值是布尔值

    • 判断某个属性是否是 自己对象上的属性(不是在原型上的属性),自己对象上的属性才会返回true
     let obj = { name: "why" }
     let info = Object.create(obj, {
         // create第二个参数市对象,用于给新创建的对象添加属性,
         // 注意属性里面也是对象,里面只能放属性操作符
         address: {
             value: "成都市",
             enumerable: true
         }
     })
     console.log(info.hasOwnProperty("name")); // fasle
     console.log(info.hasOwnProperty("address")); // true
    
  • in / for in

    • 判断某个属性是否在某个对象或者对象的原型上,不管属性在自身对象还是原型对象上都返回true
     let obj = { name: "why" }
     let info = Object.create(obj, {
         address: {
             value: "成都市",
             enumerable: true
         }
     })
     console.log("name" in info); // true
     console.log("address" in info); // true
     // for in 遍历时,不管是自身还是原型上的属性,都会遍历出来;和in是一样的
     for(let key in info){
         console.log(key); // address name
     }
    
  • obj instanceof fn()

    • 用于检测 构造函数的pototype,是否出现在 某个实例对象的原型链上
    • 也可以理解为判断 某个对象是不是构造函数的实例,如下面:判断stu是不是Person的实例对象
     function inheritPrototype(subType, superType) {
         subType.prototype = Object.create(superType.prototype)
         Object.defineProperty(subType.prototype, "constructor", {
             enumerable: false,
             configurable: true,
             writable: true,
             value: subType
         })
     }
     function Person() {}
     function Student() {}
     inheritPrototype(Student, Person)
     ​
     let stu = new Student()
     // 判断stu是不是Student类型,如果Student.prototype有出现在stu的原型链上面,就返回true,反之返回false
     console.log(stu instanceof Student); // true
     // 判断stu是不是Person类型,如果Person.prototype有出现在stu的原型链上面,就返回true,反之返回false
     console.log(stu instanceof Person); // true
     // 判断stu是不是Object类型,如果Object.prototype有出现在stu的原型链上面,就返回true,反之返回false
     console.log(stu instanceof Object); // true
     ​
     // 注:判断stu是不是Student类型,也可以这样实现
     console.log(Student.prototype.isPrototypeOf(stu)); // true
    
  • obj isPrototypeOf obj

    • 用于判断 某个对象,是否出现在某个实例对象的原型链上
    • 那么isPrototypeOf 和 instanceof到底有什么区别呢? 前者传入的是函数,后者传入是对象,
     let obj={}
     let info= Object.create(obj)
     console.log(obj.isPrototypeOf(info)); //true
    

9.13. 原型继承关系图

该图实际就是对象- 函数- 原型三者之间的关系

image-20220228213305206.png