JavaScript 继承方式有哪几种?各自的优缺点是什么?
JavaScript 的继承方式有以下七种,按演进顺序理解更容易记住:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承、ES6 class extends。面试中重点掌握组合继承的问题和寄生组合继承的优化思路。
原型链继承
将子类的原型指向父类的实例:
jsfunction 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,其他实例也受影响。
构造函数继承
在子类构造函数中调用父类构造函数:
jsfunction 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'] — 不共享了
解决了引用属性共享的问题,但新的问题出现了:方法只能定义在构造函数里,每次创建实例都会重新创建方法,无法复用。而且根本访问不到父类原型上的方法。
组合继承
把上面两种方式组合起来——属性用构造函数继承,方法用原型链继承:
jsfunction 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() 在子类原型上创建了 name 和 colors,第二次 Parent.call(this) 又在实例上创建了同名的属性,原型上的那份其实是多余的。
原型式继承
不定义构造函数,直接基于已有对象创建新对象:
jsfunction createObj(o) { function F() {} F.prototype = o; return new F(); } // ES5 标准化了这个模式 Object.create(proto, propertiesObject);
和原型链继承有同样的共享问题——引用类型的属性会被所有派生对象共享。适用于不需要单独创建构造函数、只想让一个对象类似于另一个对象的场景。
寄生式继承
在原型式继承的基础上,增强对象后返回:
jsfunction createChild(original) { const clone = Object.create(original); clone.sayHi = function() { console.log('hi'); }; return clone; }
通过封装函数给对象添加能力,但和构造函数继承一样——每个实例都会重新创建方法,无法复用。
寄生组合继承
组合继承的优化版,也是目前公认的最优继承方案:
jsfunction 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
jsclass 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 | 给对象添加增强能力 |
| 寄生组合继承 | 不共享 | 可以 | 1 | ES5 环境下最优选择 |
| 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(),这一步在子类原型上创建了 name、colors 等实例属性。第二次是 Parent.call(this, name),在子类实例自身上又创建了同名属性。实例访问属性时先找自身再找原型,所以原型上那些属性虽然存在但永远不会被访问到,属于冗余创建。寄生组合继承用 Object.create 绕过了第一次调用,只保留实例属性的正确初始化。