深入浅出javascript的继承(基础篇)

458 阅读8分钟

一. 前言

继承是面向对象非常重要的一特性,几乎所有编程语言都拥有各自的实现方法,也成了面试必问题。下面我从实际代码出发来简述JavaScript中的继承的实现方式,分别从es5和es6两方面说明,从浅至深,希望能帮助您加深理解。

二. ES5实现方式

其核心就是明白构造函数,实例,和原型对象之间的关系。先明白以下名词和以下规则。

  • (1)protype: 原型对象.
  • (2)__proto__: 原型属性
  • (3)每一个构造函数都拥有一个prototype对象。
  • (4) 每一个对象都拥有一个原型属性

公用父类文件:

    // Person.js
    function Person(name,age) {
    this.name = name || 'Person';
    this.age = age || 12;
    console.log('Person constructed');
}
Person.prototype = {
    sayName : function() {
        console.log('this Name:', this.name);
    },
    array: [1,2,3,4,5]
}

module.exports = Person;

(1) 原型链

核心思想:子类的原型对象(prototype)等于父类的实例。
    // student.js
const Person = require("./Person");

function Student(name,age) {
  this.name = name || "student";
  this.age = age || 16;
}
Student.prototype = new Person();

let s = new Student('student',16);
console.log(s); // { name: 'student', age: 16 }
console.log(s.sayName()); // this name: student;
// 相当于s.__proto_ === Student.prototype; s.__proto__.__proto__ === Student.prototyp.__proto === Person.prototype
console.log(s.__proto__.__proto__); // { sayName: [Function: sayName], array: [ 1, 2, 3, 4, 5 ] } 

// 问题1对应代码
let s2 = new Student('s2', 22);
let s3 = new Student('s3', 25);
s2.array.push(5)
console.log('s2.array.push[5],result is:',s2.array) // s2.array.push[5],result is: [ 1, 2, 3, 4, 5, 5 ]
console.log('s3.array is:',s3.array); // s3.array is: [ 1, 2, 3, 4, 5, 5 ]

执行结果如图:

image.png

问题1:原型链继承的问题或者缺点?

答:很明显的一点是:我们无法向父类构造参数传递参数,第二点是:实例将会共享原型上的引用对象(上例为array);

(2) 借用构造函数

核心思想:在子类的构造函数调用父类的构造函数;通过apply或bind在未来创建的新对象中执行构造函数。

/** 借用构造函数完成继承 */

const Person = require('./Person');

function Teacher(name,age) {
    Person.call(this,name,age);
    console.log('Teacher constructed');
}
const teacher = new Teacher('萧凯', 22);
console.log(teacher); //

// 以下会报错,问题2处对应代码
try {
    teacher.sayName();
} catch (error) {
    console.log(error);
}

代码运行结果如图所示:

image.png

问题2:借用构造函数存在的问题,优缺点?

答:借用构造函数解决了像父类传参的问题;缺点是无法实现函数的复用(父类型中定义的函数子类型不可见,父类型原型上定义的函数子类型不可见),参见运行图标红处。

(3)组合继承方式

核心思想:组合了原型链和借用构造函数的优点;通过原型链达到了定义到原型对象上属性和方法的复用,用借用构造函数完成了实例属性的复用。即通过原型上定义方法完成了方法的复用,又能保证每个实例拥有自己的属性

const Person =  require('./Person');

function Student (name ,age) {
    Person.call(this,name,age);
    this.age = 24;
    console.log('Student constructed');
} 

Student.prototype = new Person();
Student.prototype.constructor = Student;

const s = new Student('student1',23);
console.log(s);
console.log(s.sayName());

// 缺点:无论在什么情况下,都会调用两次超类型构造函数,一次是在创建子类型原型的时候,一次是在子类型构造函数的内部

// 修改1, 实际是浅拷贝 
console.log('--------修改1----------');
Student.prototype = Object.create(Person.prototype); // 相当于 Student.prototype.__proto__ = Person.prototype
const s2 = new Student('萧红','222');
console.log('------------------');
console.log(s2);
console.log(s2.sayName());
console.log(Student.prototype.__proto__ === Person.prototype,12345)
console.log('s2.__proto__ === Student.prototype:',s2.__proto__ === Student.prototype); // true
console.log('Student.prototype === Person.prototype',Student.prototype === Person.prototype) // false
console.log('Student.prototype.__proto__ === Person.prototype',Student.prototype.__proto__ === Person.prototype) // true

// 修改2
console.log('--------修改2----------');
Object.setPrototypeOf(Student,Person.prototype)
const s3 = new Student('萧红','222');
console.log('------------------');

const p2 = new Person('Person',11);
console.log(s3);
console.log(s3.sayName());
console.log('s2.__proto__ === Student.prototype:',s2.__proto__ === Student.prototype); // true
console.log('Student.prototype === Person.prototype',Student.prototype === Person.prototype) // false
console.log('Student.prototype.__proto__ === Person.prototype',Student.prototype.__proto__ === Person.prototype) // true

运行结果如图所示:

image.png

问题3:请问组合继承方式最大的缺点是什么?有什么优化的方法?您会怎么优化?

答:最大的缺点就是会执行两次父类的构造函数;一次是在父类构造的时候;另外一次是在构造子类原型对象的时候(参加运行结果中标红框处)。 改进方法:可以减少构造函数的调用一次,.在子类原型对象构造的时候,可以使用Object.create()和Object.setPrototypeOf()方法代替,参见代码中的修改1和修改2。

Object.create()方法,创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。 ,用于实现继承。 如 a = Object.create(c), 相当于 a.__proto__ = c; 修改1中我们使用了此方法。

Object.setPrototypeOf()方法设置一个指定的对象的原型 ( 即, 内部[[Prototype]]属性)到另一个对象或 null。

语法:Object.setPrototypeOf(obj, prototype); obj为目标对象, prototype表示要设置的原型

如 Object.setPrototypeOf(a,B),相当于a.__proto__ === B; 修改2中我们使用了该方法。看图:

image.png

修改1,2代码运行图如下

image.png image.png

问题4:观察以下下代码,请说出运行结果?

const s2 = new Student('萧红','222');
console.log('s2.__proto__ === Student.prototype:',s2.__proto__ === Student.prototype); 
console.log('Student.prototype === Person.prototype',Student.prototype === Person.prototype) 
console.log('Student.prototype.__proto__ === Person.prototype',Student.prototype.__proto__ === Person.prototype)

我们直接观察运行结果:

image.png

由上得出以下结论,请谨记以下结论:

  1. 实例的原型属性指向其构造函数的原型对象。 即 s2.__proto__ === Student.prototype -> true;
  2. 子类的原型对象的原型属性指向父类的原型对象。 即 Student.prototype.proto === Person.prototype -> true

深入理解以上,相信您对原型链一定有了自己的理解,那么这儿再给出一个思考:

s2.__proto___.__proto__的结果是什么呢?

显而易见:答案是 Person.prototype,等式互换即可得到。

因为: s2.__proto__ === Student.prototype

Student.prototype.__proto__ === Person.prototype

那么 s2.__proto___.__proto__ === Person.prototype

(4)原型式继承

核心思想:不用严格意义上的构造函数。借用一个函数完成将现有的对象挂载到当前函数的原型对象上,然后返回该函数的实例完成继承

代码如下:

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

let person = {
  name: "xk1",
  age: 22,
  hobby: ['swimming', 'eating', 'singing'],
  sayName: function(){
    console.log("this name is", this.name);
  }
};

let student = object(person);
console.log(student.__proto__);

// 缺点如下
console.log('----缺点如下---')
student.hobby.push['Play'];
console.log("student's hobby",student.hobby);
console.log('student add hobby:play');
console.log("student's hobby",student.hobby);
console.log("person's hobby",person.hobby);

代码运行结果如下:

image.png

问题5:原型式继承有什么问题?这个obect函数可用Object类中的API替换么?

答:最明显的问题就是引用类型公用问题。参见代码标红处。可以使用Object.create()替代,其实就是Object.create完成了咱们这个object函数的功能。代码中的 student.__proto__ === person

(5) 寄生式继承

核心思想: 通过原型式得到新的对象实例,在将这个实例进行扩展。(添加函数,属性等),将这一过程封装成一个函数,然后返回这个扩展之后的实例。

我们直接看代码:

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

function createAnother(origin) {
    // 通过object函数得到实例
    const o = object(origin);
    // 在实例上进行扩展
    o.sex = 'male';
    o.getSex = function() {
        console.log('this name is:',this.sex);
    }
    return o;
}

const person = {
    name: '萧红',
    age: '22',
    getName() {
        console.log(this.name);
    }
}
const student = createAnother(person);
console.log('student is:',student);
console.log('student__proto__ is:',student.__proto__);

代码运行结果:

image.png

问题6:寄生式继承存在的问题?

答:在对象上扩展方法时无法达到函数的复用;同时原型对象上的引用类型数据也会被共享。

添加以下代码:

    const miniStudent = createAnother(student);
    console.log(miniStudent);
    console.log(miniStudent.__proto__);

运行结果是:

image.png

可以发现:miniStudent中的getSex是新扩展的;并没有复用student中的getSex方法。

(6)寄生组合式

核心思想:借用构造函数完成属性的继承,然后通过原型链的混成达到方法的继承;避免了构建子类型原型对象时调用超类的构造函数,只需要超类型的一个副本。本质上通过寄生式完成方法父类原型对象的继承,再将结果赋给子类型

看代码

const Person = require('./Person');

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

function inheritPrototype(subType,superType) {
    /** 可以理解为得到父类型的原型对象 */
    const prototype = object(superType.prototype); // prototype.__proto__ = superType.prototype,可以用Objec.create(superType.prototype)替换;
    prototype.constructor = subType;
    /** 赋给子类型的原型对象 */
    subType.prototype = prototype;
}

function Student(name,age) {
    // 借用构造函数完成实例属性和实例方法的继承
    Person.call(this,name,age);
    console.log("Student constructed");

}

// 继承方法
inheritPrototype(Student, Person);

let s = new Student('student1', '22');
console.log(s);
console.log('s.__proto__:', s.__proto__);
console.log('s.__proto__..__proto__:', s.__proto__.__proto__);

代码运行结果如图:

image.png

从图我们可以看出:寄生组合方式是es5中最合适的继承方式。

三. Es6实现继承

Es6通过class定义类,通过extends后接父类完成继承;必须在子类构造函数中调用super()函数。 代码如下:

我们必须知道以下规则:class继承拥有两条继承链,prototype和__proto__。假设类B继承A

  1. 子类的原型属性等于A,即B.__proto__ === A, 用于继承实例属性。
  2. 子类的原型对象的原型属性等于A的原型对象,即B.prototype.__proto__ === A.prototype, 用于继承方法。
class Person  {
    constructor (name,age) {
        this.name = name || '??';
        this.age = age || 0;
        console.log('Person constructed');
    }
    setName (name) {
        this.name = name;
    }
    getName () {
        return this.name;
    }
}

class Student extends Person {
    constructor(name,age) {
        super(name,age);
        console.log('Student constructed');
    }
}

class Teacher extends Person {
    constructor(name,age) {
        // 不调用super函数
        // super(name,age)
        this.name = name;
        this.age = age ;
    }
}

let student = new Student('萧红',22);
console.log(student);

try {
    let teacher = new Teacher('王老师', 44);
} catch (error) {
console.log(error);   
}

运行结果如下:

image.png

问题7:为什么必须在子类构造函数中调用super函数(),没有会怎么样?

答:如果不调用,新建实例时会报错。因为子类没有自己的this对象,而是继承父类的this对象,然后对其加工,如果不调用,就无法使用this,只有调用了super()之后,后面才能使用this关键字;如图标红处报错。

问题8: 说说怎么实现抽象类?

答:采用new.target属性;new.target属性表示如果通过new 创建实例时会返回类名的引用,否则返回undefined;在构造函数中加判断逻辑即可,如下代码。

class C {
    constructor(name,age) {
    if (new.target === C) {
        throw new Error('本类不能实例化');
    }
      this.name = name;
      this.age = age;
    }
  }
  
  let c = new C('c',22);
  console.log(c,12345); // 未能被输出

运行结果如下:

image.png

四. 总结

  1. 实例的原型属性指向其构造函数的原型对象
  2. 子类的原型对象的原型属性指向父类的原型对象
  3. class继承拥有两条继承链,__proto__用来继承属性,prototype用来继承方法。
  4. new.target通过new方法调用时指向类名引用,否则返回undefined
  5. 设置__proto__属性可以使用Object.create()和Object.setPrototypeOf()方法代替。

构造函数,prototype, __proto__,实例之间的关系如图所示

es5如图所示: image.png

es6如图所示

image.png

五. 片尾思考

大家思考一下怎么完成多继承?欢迎在评论区进行回答,提示:Object.assing(),作者将在下一篇文章进行解答。

参考方法

MDN Oject相关技术 ECMAScript6 阮一峰 第三版