MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript继承的几种实现方式比较

2022-03-112.6k 阅读

原型链继承

原型链继承的原理

在JavaScript中,每个函数都有一个prototype属性,这个属性是一个对象,它包含了函数被实例化时会共享的属性和方法。当我们创建一个新的对象实例时,该实例的内部会有一个[[Prototype]](在ES6之前无法直接访问,ES6之后可以通过__proto__属性访问,但不推荐使用,推荐使用Object.getPrototypeOf方法)指向构造函数的prototype对象。

原型链继承正是基于这种原型关系来实现的。假设我们有一个父构造函数Parent和一个子构造函数Child,通过将Child.prototype设置为new Parent(),这样Child的实例就可以通过原型链访问到Parent的属性和方法。

代码示例

function Parent() {
    this.name = 'parent';
    this.sayName = function() {
        console.log(this.name);
    };
}

function Child() {
    this.childName = 'child';
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

let child = new Child();
child.sayName(); // 输出: parent
console.log(child.name); // 输出: parent
console.log(child.childName); // 输出: child

原型链继承的优缺点

优点

  1. 实现简单,通过原型链实现了对象之间的继承关系,使得子对象可以访问父对象的属性和方法。
  2. 父对象的属性和方法可以被多个子对象实例共享,节省内存空间。

缺点

  1. 所有子对象实例共享父对象的引用类型属性。例如,如果在Parent构造函数中有一个数组属性,当一个子对象实例修改了这个数组,其他子对象实例也会受到影响。
function Parent() {
    this.arr = [1, 2, 3];
}

function Child() {}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

let child1 = new Child();
let child2 = new Child();

child1.arr.push(4);
console.log(child2.arr); // 输出: [1, 2, 3, 4]
  1. 在创建子对象实例时,无法向父构造函数传递参数。因为Child.prototype = new Parent()这一步是在定义Child构造函数时就执行了,而不是在创建Child实例时执行,所以无法根据不同的实例传递不同的参数给Parent构造函数。

构造函数继承

构造函数继承的原理

构造函数继承的核心思想是在子构造函数内部通过callapply方法调用父构造函数,这样就可以在子对象的作用域内执行父构造函数,从而使得子对象拥有父对象的属性。

代码示例

function Parent(name) {
    this.name = name;
    this.sayName = function() {
        console.log(this.name);
    };
}

function Child(name, childName) {
    Parent.call(this, name);
    this.childName = childName;
}

let child = new Child('parentName', 'childName');
child.sayName(); // 输出: parentName
console.log(child.name); // 输出: parentName
console.log(child.childName); // 输出: childName

构造函数继承的优缺点

优点

  1. 解决了原型链继承中共享引用类型属性的问题,每个子对象实例都有自己独立的从父构造函数继承来的属性。
  2. 可以在创建子对象实例时向父构造函数传递参数,增强了灵活性。

缺点

  1. 父对象的方法无法被共享,每个子对象实例都有自己独立的方法副本,这会占用较多的内存空间。例如,每个Child实例都有自己独立的sayName方法,而不是像原型链继承那样共享同一个方法。
  2. 只能继承父构造函数的属性和方法,无法继承父构造函数原型上的属性和方法。因为callapply方法只是在子构造函数内部执行父构造函数,并没有建立原型链关系。

组合继承

组合继承的原理

组合继承结合了原型链继承和构造函数继承的优点。它通过在子构造函数内部使用callapply方法调用父构造函数,来继承父构造函数的属性,同时通过将子构造函数的prototype设置为父构造函数的实例,来继承父构造函数原型上的属性和方法。

代码示例

function Parent(name) {
    this.name = name;
    this.sayName = function() {
        console.log(this.name);
    };
}

function Child(name, childName) {
    Parent.call(this, name);
    this.childName = childName;
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

let child = new Child('parentName', 'childName');
child.sayName(); // 输出: parentName
console.log(child.name); // 输出: parentName
console.log(child.childName); // 输出: childName

组合继承的优缺点

优点

  1. 既解决了原型链继承中共享引用类型属性的问题,又解决了构造函数继承中无法继承父构造函数原型上属性和方法的问题。
  2. 可以在创建子对象实例时向父构造函数传递参数,同时父对象原型上的方法可以被多个子对象实例共享,节省内存空间。

缺点

  1. 父构造函数会被调用两次。一次是在Child.prototype = new Parent()时,另一次是在Parent.call(this, name)时。这会导致不必要的性能开销,特别是当父构造函数执行一些复杂的初始化操作时。
  2. 虽然子对象实例可以正常访问父对象原型上的方法,但是在创建子对象实例时,父对象的一些属性(例如在Parent构造函数中定义的非函数属性)会在子对象实例和子对象原型上各创建一份,浪费了一定的内存空间。

原型式继承

原型式继承的原理

原型式继承是基于Object.create方法实现的。Object.create方法接受一个对象作为参数,并创建一个新的对象,新对象的[[Prototype]]指向传入的对象。这样新对象就可以通过原型链访问到传入对象的属性和方法。

代码示例

let parent = {
    name: 'parent',
    sayName: function() {
        console.log(this.name);
    }
};

let child = Object.create(parent);
child.childName = 'child';

child.sayName(); // 输出: parent
console.log(child.name); // 输出: parent
console.log(child.childName); // 输出: child

原型式继承的优缺点

优点

  1. 简单灵活,不需要定义构造函数,直接基于现有对象创建新对象,并实现继承关系。
  2. 可以在创建新对象时,通过传入第二个参数来扩展新对象的属性。例如:
let parent = {
    name: 'parent'
};

let child = Object.create(parent, {
    childName: {
        value: 'child',
        writable: true,
        enumerable: true,
        configurable: true
    }
});

console.log(child.childName); // 输出: child

缺点

  1. 所有新对象实例共享原型对象的引用类型属性,这与原型链继承的问题类似。
  2. 没有构造函数,不利于代码的封装和复用,如果需要对新对象进行一些复杂的初始化操作,不太方便。

寄生式继承

寄生式继承的原理

寄生式继承是在原型式继承的基础上进行扩展。它通过创建一个函数,在函数内部使用Object.create方法创建一个新对象,然后对这个新对象进行增强(添加属性或方法),最后返回这个增强后的新对象。

代码示例

function createEnhancedObject(original) {
    let clone = Object.create(original);
    clone.sayHi = function() {
        console.log('Hi');
    };
    return clone;
}

let parent = {
    name: 'parent'
};

let child = createEnhancedObject(parent);
child.sayHi(); // 输出: Hi
console.log(child.name); // 输出: parent

寄生式继承的优缺点

优点

  1. 可以在基于现有对象创建新对象的同时,对新对象进行定制化增强,增加了灵活性。
  2. 结合了原型式继承的简单性,不需要定义复杂的构造函数体系。

缺点

  1. 同样存在所有新对象实例共享原型对象引用类型属性的问题。
  2. 由于每次调用寄生式继承的函数都会返回一个新的对象,这些对象之间没有共同的构造函数,不利于代码的维护和扩展,例如无法通过构造函数来统一管理对象的行为。

寄生组合式继承

寄生组合式继承的原理

寄生组合式继承是为了解决组合继承中父构造函数被调用两次的问题。它的核心思想是通过Object.create方法创建一个新的对象,这个对象的[[Prototype]]指向父构造函数的prototype,然后将子构造函数的prototype设置为这个新对象,同时修正子构造函数prototypeconstructor属性。在子构造函数内部仍然使用callapply方法调用父构造函数来继承父构造函数的属性。

代码示例

function inheritPrototype(Child, Parent) {
    let prototype = Object.create(Parent.prototype);
    prototype.constructor = Child;
    Child.prototype = prototype;
}

function Parent(name) {
    this.name = name;
    this.sayName = function() {
        console.log(this.name);
    };
}

function Child(name, childName) {
    Parent.call(this, name);
    this.childName = childName;
}

inheritPrototype(Child, Parent);

let child = new Child('parentName', 'childName');
child.sayName(); // 输出: parentName
console.log(child.name); // 输出: parentName
console.log(child.childName); // 输出: childName

寄生组合式继承的优缺点

优点

  1. 解决了组合继承中父构造函数被调用两次的问题,提高了性能。
  2. 既继承了父构造函数的属性,又继承了父构造函数原型上的属性和方法,同时避免了原型链继承和构造函数继承的一些缺点。
  3. 保持了原型链的完整性,使得instanceof操作符和isPrototypeOf方法能够正常工作。

缺点

  1. 实现相对复杂,需要理解Object.create方法以及原型链的原理,对于初学者来说可能不太容易掌握。
  2. 虽然解决了父构造函数被调用两次的问题,但在一些极端情况下,仍然可能存在性能问题,例如在创建大量子对象实例时,Object.create方法的开销可能会变得明显。不过在大多数实际应用场景中,这种性能问题可以忽略不计。

ES6 类继承

ES6 类继承的原理

ES6引入了class关键字,使得JavaScript的面向对象编程更加直观和符合传统面向对象语言的语法习惯。在ES6类继承中,通过extends关键字来实现继承关系。子类会继承父类的属性和方法,同时可以重写父类的方法或添加新的方法。

本质上,ES6类继承仍然是基于原型链的,class只是一个语法糖,它背后的实现机制与之前的原型链继承等方式有相似之处。当使用extends关键字时,JavaScript会自动处理原型链的设置,使得子类的[[Prototype]]指向父类的prototype

代码示例

class Parent {
    constructor(name) {
        this.name = name;
    }
    sayName() {
        console.log(this.name);
    }
}

class Child extends Parent {
    constructor(name, childName) {
        super(name);
        this.childName = childName;
    }
}

let child = new Child('parentName', 'childName');
child.sayName(); // 输出: parentName
console.log(child.name); // 输出: parentName
console.log(child.childName); // 输出: childName

ES6 类继承的优缺点

优点

  1. 语法更加简洁明了,符合传统面向对象编程的习惯,提高了代码的可读性和可维护性。
  2. 明确的classextends语法使得继承关系更加清晰,易于理解和管理。
  3. 支持super关键字,方便在子类中调用父类的构造函数和方法,使得代码的逻辑更加清晰。

缺点

  1. 虽然语法上更简洁,但底层仍然基于原型链,对于不熟悉原型链原理的开发者来说,可能在调试和理解一些复杂的继承问题时会遇到困难。
  2. 由于是ES6新特性,在一些旧版本的浏览器中可能不支持,需要进行兼容性处理,例如使用Babel等工具进行转码。

通过对以上几种JavaScript继承实现方式的比较,开发者可以根据具体的应用场景和需求选择最合适的继承方式,以实现高效、可维护的代码。在现代JavaScript开发中,ES6类继承因其简洁的语法和良好的可读性,成为了主流的继承方式,但了解其他继承方式对于深入理解JavaScript的原型链机制和面向对象编程原理仍然具有重要意义。