5月28日 03:22

JavaScript 继承方式有哪几种?各自的优缺点是什么?

JavaScript 的继承方式有以下七种,按演进顺序理解更容易记住:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承、ES6 class extends。面试中重点掌握组合继承的问题和寄生组合继承的优化思路。

原型链继承

将子类的原型指向父类的实例:

js
function Parent() { this.name = 'parent'; this.colors = ['red', 'blue']; } Parent.prototype.getName = function() { return this.name; }; function Child() {} Child.prototype = new Parent(); const child1 = new Child(); const child2 = new Child(); child1.colors.push('green'); console.log(child2.colors); // ['red', 'blue', 'green'] — 共享了!

核心问题:所有子实例共享父实例的引用类型属性,一个实例修改了 colors,其他实例也受影响。

构造函数继承

在子类构造函数中调用父类构造函数:

js
function Parent() { this.colors = ['red', 'blue']; } function Child() { Parent.call(this); // 借用构造函数 } const child1 = new Child(); child1.colors.push('green'); console.log(new Child().colors); // ['red', 'blue'] — 不共享了

解决了引用属性共享的问题,但新的问题出现了:方法只能定义在构造函数里,每次创建实例都会重新创建方法,无法复用。而且根本访问不到父类原型上的方法。

组合继承

把上面两种方式组合起来——属性用构造函数继承,方法用原型链继承:

js
function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } Parent.prototype.getName = function() { return this.name; }; function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age = age; } Child.prototype = new Parent(); // 第一次调用 Parent Child.prototype.constructor = Child;

这是最常用的经典方案,但有一个效率问题:Parent 被调用了两次。第一次 new Parent() 在子类原型上创建了 namecolors,第二次 Parent.call(this) 又在实例上创建了同名的属性,原型上的那份其实是多余的。

原型式继承

不定义构造函数,直接基于已有对象创建新对象:

js
function createObj(o) { function F() {} F.prototype = o; return new F(); } // ES5 标准化了这个模式 Object.create(proto, propertiesObject);

和原型链继承有同样的共享问题——引用类型的属性会被所有派生对象共享。适用于不需要单独创建构造函数、只想让一个对象类似于另一个对象的场景。

寄生式继承

在原型式继承的基础上,增强对象后返回:

js
function createChild(original) { const clone = Object.create(original); clone.sayHi = function() { console.log('hi'); }; return clone; }

通过封装函数给对象添加能力,但和构造函数继承一样——每个实例都会重新创建方法,无法复用。

寄生组合继承

组合继承的优化版,也是目前公认的最优继承方案:

js
function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } Parent.prototype.getName = function() { return this.name; }; function Child(name, age) { Parent.call(this, name); // 只调用一次 Parent this.age = age; } // 关键:用 Object.create 代替 new Parent() Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child;

和组合继承相比,只改了一行:把 Child.prototype = new Parent() 换成了 Child.prototype = Object.create(Parent.prototype)。这一改做到了两件事:只调用一次父类构造函数,子类原型上不会出现多余的实例属性。ES6 的 class extends 经 Babel 编译后,底层就是寄生组合继承。

ES6 class extends

js
class Parent { constructor(name) { this.name = name; this.colors = ['red', 'blue']; } getName() { return this.name; } } class Child extends Parent { constructor(name, age) { super(name); // 必须在 this 之前调用 this.age = age; } }

语法糖,底层仍然是原型链 + 寄生组合继承,但做了额外约束:

  • 不用 new 调用会报错(内置 new.target 检查)
  • 类方法不可枚举
  • extends 同时继承静态属性和原型方法
  • super 作为关键字比 Parent.call(this) 更安全,确保父类构造函数在访问 this 之前执行

七种方式对比

方式引用属性共享方法复用父类调用次数适用场景
原型链继承共享可以1不含引用属性的简单继承
构造函数继承不共享不可以1只需继承属性不需方法
组合继承不共享可以2传统项目,兼容性要求高
原型式继承共享可以0基于对象快速派生
寄生式继承共享不可以0给对象添加增强能力
寄生组合继承不共享可以1ES5 环境下最优选择
ES6 class extends不共享可以1现代开发首选

追问

寄生组合继承为什么是最优方案?

只调用一次父类构造函数,避免了组合继承中子类原型上出现冗余实例属性的问题。原型链保持干净——子类原型是通过 Object.create(Parent.prototype) 创建的空对象,只包含指向父类原型的方法,不包含父类实例属性。这是 ES5 环境下兼顾效率和正确性的最佳方案。

ES6 class 和寄生组合继承有什么本质区别?

class 不只是语法糖,它在语义层面做了额外约束:1) 不用 new 调用直接报错(new.target 检查);2) 类方法默认不可枚举;3) extends 同时继承静态属性,寄生组合继承做不到这点需要手动处理;4) super 是关键字而非函数调用,引擎保证父类构造函数在子类访问 this 之前执行,比 Parent.call(this) 更安全。

Object.create 和 new 有什么区别?

Object.create(proto) 创建一个新对象并将其 __proto__ 指向 proto,不执行任何构造函数逻辑。new Constructor() 会执行构造函数,将函数体内的 this 绑定到新对象,执行初始化逻辑后返回该对象。寄生组合继承用 Object.create 替代 new Parent(),目的就是避免多执行一次父类构造函数中的初始化逻辑。

为什么组合继承中父类被调用了两次?

第一次是 Child.prototype = new Parent(),这一步在子类原型上创建了 namecolors 等实例属性。第二次是 Parent.call(this, name),在子类实例自身上又创建了同名属性。实例访问属性时先找自身再找原型,所以原型上那些属性虽然存在但永远不会被访问到,属于冗余创建。寄生组合继承用 Object.create 绕过了第一次调用,只保留实例属性的正确初始化。

标签:JavaScript前端