☰
JavaScript七种继承方式详解:从原型链到ES6 class
2026/10/21 17:16:27 网站建设 项目流程

继承这件事,写 JavaScript 的人早晚都要面对。不管你是刚看完原型链概念的新手,还是在准备面试的求职者,或是已经在项目里被各种继承方案坑过的老手,七种继承方式这个主题,基本是绕不开的一座山。这篇文章我想用一种相对直白的方式,把这七种方案从头到尾梳理一遍,不仅说清楚每种写法怎么写、原理是什么,更重要的是结合我实际写代码、review 别人代码的经验,聊聊每种方案的坑、适用场景,以及到底哪种才是项目里真正值得用的最优解。

先说下这篇文章适合谁看。如果你刚开始接触 JavaScript 面向对象,对 prototype、constructor 这些概念还有点模糊,这篇文章能帮你把整个继承体系的演进脉络捋清楚;如果你已经有了一定基础,想弄明白为什么 ES6 的 class 底层是寄生组合式继承,以及各种继承写法之间的本质差异,这篇文章也能提供一些面试里不常讲透的视角。无论哪种情况,我都建议你打开浏览器控制台,跟着代码敲一遍。只看不练,继承这种东西永远都是别人的。

1. 内容整体设计与思路拆解

1.1 为什么 JavaScript 的继承和传统面向对象语言不一样

很多从 Java、C++ 转过来的开发者,第一次接触 JavaScript 继承的时候会觉得很别扭。Java 里继承就是 class A extends B,语义清晰,结构固定;JavaScript 里却是一堆 prototype、constructor、call、apply 的组合技巧,甚至同一个效果能写出好几种完全不同的写法。

根源在于 JavaScript 的继承模型是基于原型链的,而不是基于类的。在 ES6 之前,JavaScript 压根没有 class 这个关键字,实现继承靠的是对象与对象之间的关联——每个对象都有一个内部指针指向它的原型对象,当访问某个属性时,如果对象自身没有,就会沿着这条链一直往上找。这种设计非常灵活,但也让很多习惯传统 OOP 的人感到不适。

我们要讲的七种继承方式,本质上都是在这条原型链的基础上做文章。它们之间的差异主要体现在三个维度:父类属性是否被共享、子类实例是否能访问父类原型上的方法、以及父类构造函数被调用了多少次。理解这三个维度,你就抓住了所有继承方式的核心。

1.2 七种继承方式的分类逻辑与学习路径

七种继承方式听起来很多,但其实它们之间是有清晰的演进脉络的,不是七个互不相关的孤立方案。

我的建议是把它们分成三个梯队来学。第一梯队是基础原型方案,包括原型链继承和借用构造函数继承。这两种方案各自解决了部分问题,但都有明显缺陷,正好用对比的方式理解继承的两个核心诉求:属性独立和方法复用。第二梯队是组合改良方案,包括组合继承、原型式继承、寄生式继承。这些方案试图在前两种基础上取长补短,虽然还不完美,但每种都提供了一个独特的设计思路。第三梯队是现代工程方案,包括寄生组合式继承和 ES6 class 继承。这两种是目前实际项目中真正在用的,尤其 class 继承是现代开发的标配。

这样分类的好处是,你不需要死记硬背七种写法和它们的优缺点,而是可以顺着一条逻辑线推导:原型链继承解决了方法复用但属性被共享 → 借用构造函数解决了属性独立但方法无法复用 → 组合继承想两者兼得但调了两次父构造函数 → 寄生组合式继承在组合继承基础上补上了缺点 → ES6 class 把这个最优方案做成了语法糖。每一代的演进,都是在修复上一代的缺陷,这条逻辑线想通了,所有写法都变成了自然推导的结果。

2. 核心细节解析与实操要点

2.1 原型链继承:最朴素的思路,也是最容易踩坑的思路

原型链继承的核心写法非常简洁,就是让子类的原型对象指向父类的一个实例。

function Parent(name) { this.name = name; this.hobbies = ['reading', 'coding']; } Parent.prototype.sayName = function() { console.log(`My name is ${this.name}`); }; function Child(name, age) { this.name = name; this.age = age; } // 核心代码:将子类的原型指向父类的实例 Child.prototype = new Parent(); const child1 = new Child('张三', 18); const child2 = new Child('李四', 20); child1.sayName(); // 张三 child1.hobbies.push('swimming'); console.log(child2.hobbies); // ['reading', 'coding', 'swimming']

代码跑完你就会发现,child1 操作 hobbies 数组,child2 的 hobbies 也变了。因为 child1 和 child2 的原型都指向同一个 Parent 实例,而 hobbies 数组存在于这个共享的原型对象上,所以任何一个实例对它做的修改,都会被所有实例看到。

这是原型链继承最核心的缺陷,也是面试里考得最多的一点。除此之外,还有一个问题:Child.prototype 被重新赋值之后,它的 constructor 属性也不再指向 Child 了,这会导致 instanceof 判断出现意料之外的结果。我一般会在 reset 原型之后手动把 constructor 指回来,虽然这在原型链继承方案中不是必须的,但对于后续调试和维护很有帮助。

原型链继承并非一无是处。它最大的价值在于——父类原型上的方法可以被所有子类实例复用,而且访问效率高。在只需要继承方法、不需要独立属性的场景下,这个方案足够简单有效。

2.2 借用构造函数继承:解决了属性共享,但损失了什么

既然原型链继承的问题在于属性被共享,那我们换个思路:不在原型上做文章了,让每个子类实例在创建时,直接调用父类的构造函数,把父类的属性复制到子类实例上。

function Parent(name) { this.name = name; this.hobbies = ['reading', 'coding']; } Parent.prototype.sayName = function() { console.log(`My name is ${this.name}`); }; function Child(name, age) { // 核心代码:借用父类构造函数 Parent.call(this, name); this.age = age; } const child1 = new Child('张三', 18); const child2 = new Child('李四', 20); child1.hobbies.push('swimming'); console.log(child2.hobbies); // ['reading', 'coding'] console.log(child1.name); // 张三 console.log(child1.age); // 18

这次属性独立了,child1 和 child2 各自拥有自己的 hobbies 数组,互不干扰。构造函数继承的核心是 Parent.call(this, ...),这行代码的意图是把 Parent 内部的 this 指向当前新创建的对象,这样 Parent 里的赋值操作就作用在子类实例上了。

但这个方案有另一个问题:子类实例拿不到父类原型上的方法。上面代码中 child1.sayName 会直接报错,因为 Parent.prototype 上面定义的方法和 Child 的实例没有任何关联。所以这种继承方式无法实现方法复用,每个实例如果需要方法,都得自己定义或者从外部复制,完全没有发挥原型链的共享优势。

而且方法定义在构造函数内部的话,每次 new 一个实例,方法都会被重新创建一遍,内存开销远比原型上挂一个方法要大。构造函数继承通常在面试中用来和原型链继承做对比,单独使用的情况极少。

2.3 组合继承:经典方案,但隐藏着一个小瑕疵

既然原型链继承擅长方法复用,构造函数继承擅长属性独立,那把它们合在一起不就行了?这就是组合继承,也叫伪经典继承。

function Parent(name) { this.name = name; this.hobbies = ['reading', 'coding']; } Parent.prototype.sayName = function() { console.log(`My name is ${this.name}`); }; function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age = age; } Child.prototype = new Parent(); // 第一次调用 Parent Child.prototype.constructor = Child; const child1 = new Child('张三', 18); child1.hobbies.push('swimming'); const child2 = new Child('李四', 20); console.log(child1.hobbies); // ['reading', 'coding', 'swimming'] console.log(child2.hobbies); // ['reading', 'coding'] child1.sayName(); // 张三

组合继承在很长一段时间里都是 JavaScript 继承的标准答案。属性、方法、原型链关系全都正常,child instanceof Child 和 child instanceof Parent 都返回 true,表现和传统面向对象语言的继承非常接近。

但你注意上面的注释里我标了两次 Parent 调用。这两次调用的结果是:子类实例自身有一份属性,子类原型上还挂着一份冗余的属性。这些冗余属性平时被实例自身的属性遮住了,你看不见,但它们确实存在,可以通过 hasOwnProperty 验证。两个实例的时候还算不上什么大问题,但如果创建了几百个实例,每份冗余属性都在浪费内存,原型上有数组或者对象类型属性时,还会产生一个严重的隐患:如果某人忘记写 Parent.call(this, name),实例上就没有自己的 hobbies,它会顺着原型链取到原型上那份 hobbies,然后所有实例又变成了共享状态。

我在 review 别人代码的时候就遇过这种问题,写继承方案的人用了组合继承但漏掉了一处调用,排查了半天才发现是原型上的冗余属性在被错误访问。

3. 实操过程与核心环节实现

3.1 原型式继承:从一个对象克隆出另一个对象

接下来要讲的几种方式,在正统的面试题里有时会被归为一类,因为它们关注的不再是构造函数之间的关系,而是对象本身。原型式继承的核心思想是:不创建构造函数,直接让一个对象作为另一个对象的原型。

在 ES5 之前,开发者常用一个临时构造函数来实现这个效果,业内一般叫 object 方法:

function object(o) { function F() {} F.prototype = o; return new F(); } const parent = { name: '默认名字', hobbies: ['reading', 'coding'], sayName: function() { console.log(`My name is ${this.name}`); } }; const child1 = object(parent); child1.name = '张三'; const child2 = object(parent); child2.name = '李四'; child1.hobbies.push('swimming'); console.log(child2.hobbies); // ['reading', 'coding', 'swimming']

ES5 把这个场景直接标准化了,也就是 Object.create 方法。用 Object.create(parent) 等价于上面 object 函数的实现,它还接受第二个参数来定义新增属性。

原型式继承适合什么场景呢?简单说,就是当我们有一个现成的对象,想基于它创建更多相似对象的时候,比如数据模型的结构复制。它的缺陷和原型链继承类似,引用类型属性会被所有克隆对象共享。所以用 Object.create 做业务数据克隆的时候,需要特别注意数组、对象这类属性——你改了一个,其他对象跟着变。

3.2 寄生式继承:给克隆对象做增强

寄生式继承的思路比较刁钻,它在前一种方法的基础上,不直接返回克隆对象,而是把克隆对象拿来做一番增强,再返回增强后的新对象。这里的“增强”,通常就是给对象添加新的方法或属性。

function createEnhancedObject(original) { const clone = Object.create(original); clone.sayAge = function() { console.log(`${this.name} is ${this.age} years old`); }; return clone; } const basePerson = { name: '张三', age: 18, hobbies: ['reading'] }; const enhancedPerson = createEnhancedObject(basePerson); enhancedPerson.sayAge(); // 张三 is 18 years old

寄生式继承给我们的启示是:继承不一定要在构造原型链上下苦功,你可以在创建对象的阶段就把需要的能力直接附加到对象身上。这在写一些辅助工具对象、混入少量行为时很好用。

但要注意,它仍然没有解决引用类型共享的问题。同时因为增强是每个对象各做一份,方法的复用性没有改善。寄生式继承通常不是单独存在的方案,而是作为寄生组合式继承的中间环节,这一点在下面会看到。

3.3 寄生组合式继承:ES5 时代的最优解

到这里,我们把问题的逻辑推到极致。组合继承的缺点是父构造函数的双重调用。能不能保留组合继承的完整能力,同时把第二次调用省掉?

答案是肯定的,思路是:不通过 new Parent() 来获得父类的原型副本,而是通过 Object.create(Parent.prototype) 直接拿一个以父类原型为原型的干净对象来充当子类的原型。这样父类构造函数一次都没多调,子类实例依然可以通过原型链访问父类原型上的方法。

function inheritPrototype(Child, Parent) { const prototype = Object.create(Parent.prototype); prototype.constructor = Child; Child.prototype = prototype; } function Parent(name) { this.name = name; this.hobbies = ['reading', 'coding']; } Parent.prototype.sayName = function() { console.log(`My name is ${this.name}`); }; function Child(name, age) { Parent.call(this, name); this.age = age; } inheritPrototype(Child, Parent); Child.prototype.sayAge = function() { console.log(`${this.name} is ${this.age} years old`); }; const child1 = new Child('张三', 18); const child2 = new Child('李四', 20); child1.hobbies.push('swimming'); console.log(child2.hobbies); // ['reading', 'coding'] child1.sayName(); // 张三 child1.sayAge(); // 张三 is 18 years old console.log(child1 instanceof Child); // true console.log(child1 instanceof Parent); // true

仔细看这段代码,你会发现它做到了一件事:父类构造函数只调用一次,子类属性完全独立,方法完全复用,instanceof 关系正常。ES5 条件下能做到的继承能力,它都做到了,所以被公认为 ES5 时代的最优解。很多知名框架在转向 ES6 class 之前,内部就是用的这种继承方式。

如果你在看一些老架构的源码,看到类似于 inheritPrototype 这样的辅助函数,千万不要觉得陌生,这就是当年最标准、最规范的继承封装。

3.4 ES6 class 继承:现代语法,但底层没有脱离经典

ES6 引入了 class 语法,继承写法一下子变得清爽起来:

class Parent { constructor(name) { this.name = name; this.hobbies = ['reading', 'coding']; } sayName() { console.log(`My name is ${this.name}`); } } class Child extends Parent { constructor(name, age) { super(name); // 相当于 Parent.call(this, name) this.age = age; } sayAge() { console.log(`${this.name} is ${this.age} years old`); } } const child = new Child('张三', 18); child.sayName(); // My name is 张三 child.sayAge(); // 张三 is 18 years old console.log(child instanceof Parent); // true

很多人以为 class 是一种全新的继承机制,这个理解不完全准确。class 本质上是语法糖,它的 extends 关键字在编译之后,大多数 JavaScript 引擎的内部实现仍然沿用了寄生组合式继承的思路。super() 的作用和 Parent.call(this, name) 类似,都是在子类实例上执行父类构造函数。所以建议学习的时候,别只停留在会用 class,而是要去想想 class 背后到底发生了什么。把寄生组合式继承吃透,你会发现 class 真的没那么神秘。

使用 class 继承时有一个硬性规则:在子类的构造函数里,super() 必须在访问 this 之前调用。因为 ES6 的 class 在创建实例时,this 的初始化顺序和 ES5 不同,子类构造函数里如果没有先调用 super,this 是没有被初始化的,你访问或者修改 this 就会报错。这是 ES6 class 继承最容易踩的坑,新手经常会碰到 ReferenceError: Must call super constructor in derived class before accessing 'this' 这类报错。

4. 常见问题与排查技巧实录

4.1 为什么总会遇到共享属性问题

上面七种方式看下来你会发现,“属性共享”是出现频率最高的坑。根因在于,JavaScript 的属性查找是沿着原型链进行的:实例上没有的属性,会去原型对象上找。如果你把可变属性放在了原型对象上,任何实例的修改都会体现在原型对象上,因此影响所有实例。

排查思路很简单:不确定某个属性是不是被共享了,就打印 hasOwnProperty 的返回结果。如果返回 true,说明属性属于当前实例;如果返回 false,说明属性来自原型链。

const child = new Child('张三', 18); console.log(child.hasOwnProperty('name')); // true console.log(child.hasOwnProperty('hobbies')); // true(组合继承/寄生组合式继承下)

如果发现某个对象的属性在 hasOwnProperty 的检查下一直返回 false,但你明明给它赋过值,就要检查是不是忘了调用 super 或者 Parent.call。

4.2 constructor 指向错乱问题

JavaScript 里的继承代码中,constructor 指向是一个很微妙的东西。当你手动重写了子类的 prototype 之后,这个新对象的 constructor 默认指向的是它原型链上的某个构造函数,如果不手动修正,instanceof 的判断结果在某些场景下会完全出乎意料。

一个稳妥的做法是,每次重置 prototype 后都手动补一行:

Child.prototype.constructor = Child;

这在很多老代码里会被省略,导致的问题是调试代码时层级关系很困惑,一些依赖 constructor 做判断逻辑的库也会行为异常。这个坑出现的频率比想象中高,特别是团队协作时,每个人写继承的方式都不太一样。

4.3 实现继承时的常见语法错误与调用时机问题

在实际写代码中,除了概念理解上的坑,语法层面的细节也很容易翻车。借用构造函数时忘记传参,导致父类属性全部变成 undefined;继承之后子类原型上的方法定义覆盖了父类同名的原型方法,导致预期之外的多态效果;还有构造函数返回对象时,new 出来的是那个返回对象,而不是 this 所指的实例——这些都是实际编码中常见的错误。

调用时机问题也值得单独说。在 extends 的 class 里,super 的调用位置有严格限制,必须在 this 之前。这一条规则在代码里报错极其直观,但如果你用的是编译工具,编译之后的错误信息可能不太直观,排查时建议先看编译产物,再回到源码查 super 的位置。

我还碰到过一个比较隐蔽的情况:子类构造函数里忘记写 super,然后又在子类构造函数里使用了 this。报错信息给了,但团队里有人直接删掉了整个构造函数来“解决”这个错误。结果是没有报错了,但父类的属性也全都没有了,程序跑起来全是 undefined。这种问题靠报错信息很难定位到根本原因,需要对继承机制有完整的理解。

4.4 面试中常问的三种判断方式

面试官最爱问的一个问题是:怎么判断一个对象是否属于某个类?这个问题通常会引出三种方式,它们的语义并不完全相同。

instanceof 判断对象是否出现在某个构造函数的原型链上,它关心的是原型链。isPrototypeOf 判断某个对象是否出现在另一个对象的原型链上,它关心的是原型对象之间的关系。而 constructor 属性则直接告诉你对象是通过哪个构造函数创建的。

const child = new Child('张三', 18); console.log(child instanceof Child); // true console.log(child instanceof Parent); // true console.log(Child.prototype.isPrototypeOf(child)); // true console.log(Parent.prototype.isPrototypeOf(child)); // true console.log(child.constructor === Child); // true(前提是 constructor 被正确处理)

我面试别人的时候,遇到能把 isPrototypeOf 和 instanceof 的区别说清楚的人,通常对原型链的理解都不差。因为这两个方法一个看原型对象,一个看构造函数,能区分它们,说明你真的理解了原型链的结构。

5. 七种继承方式对比总结(含选型建议与扩展思考)

5.1 核心维度对照表

看完上面七种方式的原理和细节,我整理了一张对照表,把它们的核心差异放在一起,这张表无论是在项目选型时翻阅,还是在面试前查阅,都比较直观:

继承方式核心写法属性是否独立方法是否复用父构造函数调用次数适用场景
原型链继承子类原型指向父类实例否,引用类型共享是1 次仅继承方法、属性极少的场景,不推荐用于业务
借用构造函数子类中调用父类构造是否,方法各自创建1 次仅需要父类属性的场景,不推荐单独使用
组合继承原型链 + 借用构造是是2 次ES5 时代的经典方案,有冗余属性问题
原型式继承Object.create 克隆对象否,引用类型共享不涉及0 次基于对象创建相似对象、轻量克隆场景
寄生式继承增强 Object.create 结果否,引用类型共享部分复用0 次给克隆对象附加行为的轻量场景
寄生组合式继承Object.create 父原型 + 借用构造是是1 次ES5 时代的最优解,框架内部常见
ES6 class 继承extends + super是是1 次现代项目首选,工程化标准写法

组合继承在 ES5 时代确实经典,但它有一个我反复提到的隐患:如果子类原型上存在引用类型的属性,且某个实例通过原型链访问到了它并做了修改,所有实例都会受影响。这一个问题在项目里解释起来特别麻烦,因为看起来每个实例都有自己的属性,实际上原型链上有隐藏共享关系。寄生组合式继承正是为了让这个隐患彻底消失,才把第二次父构造函数的调用省掉了。

我自己的项目经验里,一旦代码库可以放心使用 ES6 class,就尽量用 class 继承,可读性、可维护性、工具链支持都更好。只有在维护老代码环境受限的情况下,才需要退回寄生组合式继承的写法。

5.2 这个主题后续还可以延伸哪些方向

七种继承方式学完之后,下一步值得探索的方向有很多。比如我在上面多次提到但没展开的原型链底层机制,现代 JavaScript 引擎里的属性查找与隐藏类优化,以及 Object.create、Object.setPrototypeOf、Reflect 等底层 API 的细节差异。

继承和组合的关系也值得深入思考。传统面向对象强调通过继承实现代码复用,但现代前端工程越来越倾向于组合优于继承,也就是用对象组合、函数式编程中的高阶函数、以及 ES6 之后更灵活的模块机制,来替代复杂的继承树。我的建议是,继承知识要学扎实,尤其是它的原理和坑,但在实际项目里,不要一上来就建继承树,优先考虑组合和接口抽象,真的需要层次关系时再用继承。

还有一个关联方向是 ES5 时代的继承封装究竟在大体量源码里怎么落地。如果有兴趣,可以直接去翻一些老框架的源码,看到 Simple inheritance 这样的模块,说明底层用到了寄生组合式继承,这种阅读经验对理解整条脉络非常有帮助。你会看到语言的设计在一步一步向更可靠、更易用的方向演进,而七种继承方式正是这个演进过程最完整的记录。

最后分享一个我的个人体会。JavaScript 继承七种方式,真正在实际项目里高频使用的其实就两个:ES6 class 和寄生组合式继承。但为什么还要把其他五种搞明白?因为每一种方式都对应着一个特定的思考角度和一组历史包袱,理解它们,你才能在看老代码时不困惑,在面试时不被问倒,更重要的是,在面对一个具体场景时,你能瞬间判断出该用继承还是该用组合,该用 class 还是该用手动原型链。这种判断力,才是掌握这七种方式最大的回报。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询