JS 实现继承的几种方式

359 阅读11分钟
  1. JS 的继承到底有多少种实现方式呢?
  2. ES6 的 extends 关键字是用哪种继承方式实现的呢?

JS 实现继承的几种方式

第一种:原型链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

  function Parent1() {
    this.name = 'parent1';
    this.play = [1, 2, 3]
  }

  function Child1() {
    this.type = 'child2';
  }

  Child1.prototype = new Parent1();
  console.log(new Child1());   // Parent1 { type: 'child2' }

上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,举例来说明。

var s1 = new Child1();
var s2 = new Child1();
s1.play.push(4);
console.log(s1.play);    // [ 1, 2, 3, 4 ]
console.log(s2.play);    // [ 1, 2, 3, 4 ]

只改变了 s1 的 play 属性,但 s2 也跟着变了。因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点(原型属性共享问题)。还有一个问题就是,子类型在实例化时不能给父类型的构造函数传参。

const obj1 = {
  type: 'hello'
}
const obj2 = {
  type: obj1
}

console.log(obj2.type.type);  // hello
obj2.type.type += " world"
console.log(obj2.type.type);   // hello world

// 在控制台查看时都打印 hello world
console.log(obj2);
obj2.type.type += " world"
console.log(obj2);  
  1. 再来看另外一个例子(红宝书)
function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
}

function SubType(){
    this.subproperty = false;
}

//继承Supertype
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function(){
    return this.subproperty;
}

let instance = new SubType();
console.log(instance.getSuperValue());   // true
console.log(instance);   // SuperType { subproperty: false }

实现继承的关键是 SubType 没有使用默认原型,而是将其替换成了一个新的对象。这个新的对象恰好是 SuperType 的实例。这样操作后,SubType 的实例不仅能从 SuperType 的实例中继承属性和方法,而且与 SuperType 的原型挂上了钩。

注意:getSuperValue() 方法还在 SuperType.prototype 对象上,而 property 属性则在 SubType.prototype 上。因为 getSuperValue() 是一个原型方法,而 property 是一个实例属性。SubType.prototype 现在是 SuperType 的一个实例,所以 property 才会存储在它上面。另外,由于 SubType.prototype 的 constructor 属性被重写为指向 SuperType,所以 instance.constructor 也指向 SuperType。

1. 默认原型

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype。这也是自定义函数能继承包括 toString()、valueOf() 在内的所有默认方法的原因。

2. 原型与继承关系

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

  1. 使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则返回true;
console.log(instance instanceof Object);     // true
console.log(instance instanceof SuperType);  // true
console.log(instance instanceof SubType);    // true
  1. 使用 isPrototypeOf() 方法。原型链中的每个原型都可以调用这个方法
console.log( Object.prototype isPrototypeOf(instance) );     // true
console.log( SuperType.prototype isPrototypeOf(instance) );  // true
console.log( SubType.prototype isPrototypeOf(instance) );    // true
3. 关于方法
function SuperType(){
    this.property = true;
}
SuperType.prototype.getSuperValue = function(){
    return this.property;
}
function SubType(){
    this.subproperty = false;
}

// 继承Supertype
SubType.prototype = new SuperType();
// 新方法
SubType.prototype.getSubValue = function(){
    return this.subproperty;
}
// 覆盖已有的方法
SubType.prototype.getSuperValue = function(){
    return false;
}
let instance = new SubType();
console.log(instance.getSuperValue());   // false

注意:后面在 SubType 实例上调用 getSuperValue() 时调用的是第二个原型链上已经存在但在这里被遮蔽的方法。而 SuperType 的实例仍会调用最初的方法。重点在于上述两个方法都是在把原型赋值为 SuperType 的实例之后定义的。

另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

……
// 继承Supertype
SubType.prototype = new SuperType();
// 通过对象字面量添加新方法,这会导致上一行无效
SubType.prototype = {
    getSubValue = function(){
        return this.subproperty;
    },
    someOtherMethod(){
        return false;
    }
}
let instance = new SubType();
console.log(instance.getSuperValue());  // 出错

子类的原型在被赋值为 Supertype 的实例后,又被一个对象字面量覆盖了。覆盖后的原型是一个 Object 的实例,因此之前的原型链就断了。

第二种:构造函数继承(借助 call或apply)

实现继承:在子类构造函数中调用父类构造函数,可以使用 apply() 和 call() 方法以新创建的对象为上下文执行构造函数。

function Parent1() {
    this.name = 'parent1';
}

Parent1.prototype.getName = function () {
    return this.name;
}

function Child1() {
    Parent1.call(this);
    this.type = 'child1'
}

let child = new Child1();
console.log(child);  // Child1 { name: 'parent1', type: 'child1' }
console.log(child.getName());  // 会报错

打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。这种情况的控制台执行结果为:Uncaught TypeError:child.getName is not a function。

构造函数实现继承的优缺点:

  • 优点:使父类的引用属性不会被共享,优化了第一种继承方式的弊端;
  • 可以在子类构造函数中向父类构造函数传参;
  • 缺点:只能继承父类的实例属性和方法,不能继承原型属性或者方法。
  • 必须在构造函数中定义方法,因此函数不能重用
1. 传递参数
function SuperType(name) {
  this.name = name;
}

function SubType() {
  // 继承 SuperType 并传参
  SuperType.call(this,"Jackson Yee")
  // 实例属性
  this.age = 21;
}

let instance = new SubType();
console.log(instance.name);   // Jackson Yee
console.log(instance.age);    // 21

SuperType 构造函数接受一个参数name并把它赋值给一个属性。在 SubType 构造函数中调用 SuperType 构造函数时传入这个参数,实际上会在 SubType 的实例上定义 name 属性。

第三种:组合继承(前两种组合)

组合继承:是综合 原型链继承可以继承原型对象的优点 + 构造函数call 的方式,执行父类把父类的属性写到子类上,这样在新的对象查找属性跟方法的时候,就不会沿着__proto__来查找属性跟方法。 所以组合继承解决了构造函数call无法继承原型对象的缺点 跟原型链继承 继承父类的引用属性造成的多实例之间共享引用属性的问题。
这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。

function Parent3(name) {
    this.name = name;
    this.play = [1, 2, 3];
}
Parent3.prototype.getName = function () {
    console.log(this.name);
}

function Child3(name, age) {
    // 第二次调用 Parent3()
    Parent3.call(this, name);
    this.type = 'child3';
    this.age = age;
}
// 第一次调用 Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;

Child3.prototype.getAge = function () {
    console.log(this.age);
}

let s3 = new Child3("Jackson", 21);
s3.play.push(4);
s3.getName();   // Jackson
s3.getAge();    // 21

let s4 = new Child3("Yee", 22);
s4.getName();  // Yee
s4.getAge();   // 22

console.log(s3.play, s4.play);  // 不互相影响 [ 1, 2, 3, 4 ] [ 1, 2, 3 ]

这相当于新的 Child3 对象上运行了 Parent3() 函数中的所有初始化代码。结果就是每个实例都会有自己的 play 属性。

但这里又增加了一个新问题:通过注释可以看到 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,Parent3 多构造一次就多进行了一次性能开销。下面的第六种继承方式可以更好地解决这里的问题。

上面介绍的更多是围绕着构造函数的方式,那么对于 JavaScript 的普通对象,怎么实现继承呢?

第四种:原型式继承

这里需要提到 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

let parent4 = {
    name: "parent4",
    friends: ["p1", "p2", "p3"],
    getName: function () {
        return this.name;
    }
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");

let person5 = Object.create(parent4);
person5.friends.push("lucy");

console.log(person4.name);   // tom
console.log(person4.name === person4.getName());  //true
console.log(person5.name);    // parent4
console.log(person4.friends);  // [ 'p1', 'p2', 'p3', 'jerry', 'lucy' ]
console.log(person5.friends);  // [ 'p1', 'p2', 'p3', 'jerry', 'lucy' ]

从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法。

  • 第一个结果“tom”,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。
  • 第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。
  • 第三个结果“parent4”,person5 继承了 parent4 的 name 属性,没有进行覆盖,因此输出父对象的属性。
  • 最后两个输出结果一样,可以联想到浅拷贝的知识点,关于引用数据类型“共享”的问题,其实 Object.create 方法是可以为一些对象实现浅拷贝的。
Object.create()

Object.create() 的第二个参数与 Object.defineProperties() 的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

let Person = {
    name: "Jackson"
}

let anotherPerson = Object.create(Person,{
    name:{
        value:"Yee"
    }
})

console.log(anotherPerson.name);  // Yee

补充: 原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。

var obj = { foo:  5 };
obj ——> foo ——> [[value]]: 5
                [[writable]]: true
                [[enumerable]]: true
                [[configurable]]: true

总结:原型式继承的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能,接下来看一下在这个继承基础上进行优化之后的另一种继承方式——寄生式继承。

第五种:寄生式继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。

let parent5 = {
    name: "parent5",
    friends: ["p1", "p2", "p3"],
    getName: function () {
        return this.name;
    }
};

function clone(original) {
    // 创建一个新对象
    let clone = Object.create(original);
    // 增强这个对象
    clone.name = "tom"
    clone.getFriends = function () {
        return this.friends;
    };
    return clone;
}

let person5 = clone(parent5);

console.log(person5.getName());   // tom
console.log(person5.getFriends());   // [ 'p1', 'p2', 'p3' ]

从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。

第六种:寄生组合式继承

结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式。

前面提到的组合继承也存在效率问题,最主要的是问题是父类构造函数始终会被调用两次:一次是在创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行。

function clone(parent, child) {
    // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
    child.prototype = Object.create(parent.prototype);  //创建父类原型的一个副本并赋值给子类的原型
    child.prototype.constructor = child;  //解决重写原型导致默认constructor丢失的问题
}

function Parent6() {
    this.name = 'parent6';
    this.play = [1, 2, 3];
}

Parent6.prototype.getName = function () {
    return this.name;
}

function Child6() {
    Parent6.call(this);
    this.friends = 'child5';
}

clone(Parent6, Child6);
Child6.prototype.getFriends = function () {
    return this.friends;
}

let person6 = new Child6();

console.log(person6);    // Child6 { name: 'parent6', play: [ 1, 2, 3 ], friends: 'child5' }
console.log(person6.getName());   // parent6
console.log(person6.getFriends());  // child5

通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。

整体看下来,这六种继承方式中,寄生组合式继承是这六种里面最优的继承方式。另外,ES6 还提供了继承的关键字 extends,我们再看下 extends 的底层实现继承的逻辑。

ES6 的 extends 关键字实现逻辑

可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承,但是如果想深入了解 extends 语法糖是怎么实现的,就得深入研究 extends 的底层逻辑。

利用 extends 如何直接实现继承。

class Person {
    constructor(name) {
        this.name = name
    }

    // 原型方法, 即 Person.prototype.getName = function() { }
    // 下面可以简写为 getName() {...}
    getName = function () {
        console.log('Person:', this.name)
    }
}

class Gamer extends Person {
    constructor(name, age) {

        // 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
        super(name)
        this.age = age
    }
}

const asuna = new Gamer('Asuna', 20)
asuna.getName()   // 成功访问到父类的方法  Person: Asuna

看一下 extends 转译之后的代码片段。( 因为浏览器的兼容性问题,如果遇到不支持 ES6 的浏览器,可以利用 babel 这个编译工具,将 ES6 的代码编译成 ES5 )

function _possibleConstructorReturn(self, call) {
    // ...
    return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}
function _inherits(subClass, superClass) {
    // 这里可以看到
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

var Parent = function Parent() {
    // 验证是否是 Parent 构造出来的 this
    _classCallCheck(this, Parent);
};
var Child = (function (_Parent) {
    _inherits(Child, _Parent);
    function Child() {
        _classCallCheck(this, Child);
        return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
    }
    return Child;
}(Parent));

从上面编译完成的源码中可以看到,它采用的也是寄生组合继承方式,也表明这种方式是较优的解决继承的方式。

总结:

image.png