☰
JavaScript类继承深度解析:原型链、super与组合模式取舍
2026/10/14 3:57:04 网站建设 项目流程

JavaScript里的类继承,说简单也简单,无非就是class A extends B一行代码的事;说复杂也复杂,super、原型链、构造器调用顺序、静态成员继承这些细节,随便拎一个出来都能绊倒一大片人。尤其是项目里多人协作的时候,继承用得不好,后面改起 bug 来恨不得把键盘吞了。

这篇东西我憋了很久,把平时在实际项目里用到过的继承玩法、踩过的坑、以及与组合模式对比后的取舍,一次性整理出来。不管你是刚学完 ES6 class 语法的新手,还是写了几年业务代码但没深究过继承原理的老手,这篇文章都值得花十分钟过一遍。后面所有代码示例我都用 Node.js 环境实测过,你直接抄去用即可。

1. 先搞懂 class 到底是个什么东西

很多人用class用得很熟练,却说不清它跟构造函数、原型对象之间的关系。这其实很要命,因为继承的很多诡异行为,根源都在“class 只是语法糖”这个事实上。

1.1 ES6 class 的本质是原型链

我们先看一段最普通的代码:

class Animal { constructor(name) { this.name = name; } eat() { console.log(`${this.name} is eating`); } } const dog = new Animal('旺财'); dog.eat(); // 旺财 is eating

表面上看,Animal是一个类型,dog是它创建出来的实例。但在 JavaScript 引擎眼里,发生的事情其实是这样的:

  • Animal本身是一个函数(构造函数),class语法只是让它必须用new来调用。
  • eat方法并没有被复制到每个实例上,而是挂在了Animal.prototype这个对象上。
  • dog实例内部有一个隐藏指针[[Prototype]](也就是__proto__)指向Animal.prototype。
  • 当你调用dog.eat()时,先看dog自己有没有eat,没有,就顺着原型链往上找。

关键结论:class 定义的方法天然在原型上,而不是在实例上。所以如果你用Object.keys(dog)去看,只能看到["name"],看不到eat。

这个设计的经济性很明显:无论你new出一万个实例,eat函数在内存里始终只有一份。那些把方法写在构造函数里的写法(this.eat = function...),每创建一个实例就复制一份函数,纯属浪费。

1.2 实例属性与方法属性必须分开放

这一点是新手最容易写错的。看下面这个反面教材:

class BadPractice { constructor() { this.count = 0; this.increment = function() { this.count++; }; } }

问题在哪?每次new BadPractice()都会创建一个全新的increment函数对象,占用额外内存,而且这个函数失去了上面说的“原型共享”机制。正确的做法是:

class GoodPractice { constructor() { this.count = 0; // 实例属性,每个实例独立 } increment() { // 原型方法,所有实例共享 this.count++; } }

实例属性放constructor里,方法放在 class 体里,这是 ES6 class 的标准用法。另外从 ES2022 开始,你还可以直接在类体里声明实例字段,连constructor都省了:

class Counter { count = 0; increment() { this.count++; } }

这种“类字段”写法会在构造函数执行前完成属性初始化,配合strict mode还能有效防止手滑把属性名写错——如果字段名和现有属性冲突,直接报错。

1.3 原型的原型还是原型

既然 class 本质是原型链,那继承就是“把原型链再加长一层”。class Dog extends Animal做的事情有两件:

  • Dog.prototype的内部原型指向Animal.prototype。
  • Dog这个函数对象的内部原型指向Animal(用于静态方法继承)。

所以当你const d = new Dog()后,d的属性查找顺序是:d自身 →Dog.prototype→Animal.prototype→Object.prototype→null。

理解这条链非常重要。后面讲到super的怪异行为、方法覆写(override)时,你会反复回到这个模型上。

2. extends 继承的完整拆解

现在正式进入继承这块硬骨头。extends关键字是 ES6 引入的语法,底层仍然是在摆弄原型链,但它帮我们处理了不少手动设置原型链时容易漏掉的细节。

2.1 最简单的 extends 继承长什么样

class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} makes a sound`); } } class Dog extends Animal { constructor(name, breed) { super(name); // 先调用父类构造函数 this.breed = breed; } speak() { super.speak(); // 调用父类同名方法 console.log(`${this.name} barks`); } } const d = new Dog('旺财', '柴犬'); d.speak(); // 旺财 makes a sound // 旺财 barks

继承的模板就是这样的套路:子类构造函数里第一行必须调用super(...),否则会报Must call super constructor in derived class before accessing 'this'的错误。

为什么必须调?因为在继承场景下,this对象的创建是由父类构造函数完成的,子类只是在这个基础上“追加”自己的属性。你不先让父类把这个对象初始化好,子类就没有一个合法的this可用,这是 JavaScript 和 Java(在构造器调用顺序上)的一个显著差异。

2.2 super 的两个身份,别搞混

super在 JavaScript 里其实有两种完全不同的用法:

用法一:super(...)作为函数调用,只能在构造函数里用,且必须出现在this被访问之前。它的作用是调用父类构造函数来初始化this。

用法二:super.xxx作为属性访问,可以在任意方法里用。它访问的是父类原型上的属性,但this绑定仍然是当前实例。

看这个例子感受一下绑定差异:

class Parent { greet() { console.log('Parent greet, my name is', this.name); } } class Child extends Parent { constructor(name) { super(); this.name = name; } hello() { super.greet(); } } const c = new Child('小A'); c.hello(); // Parent greet, my name is 小A

注意:super.greet()执行的是Parent.prototype.greet,但方法内部的this指向的是c,所以能读到c.name。

很多人在这一步开始懵。记住一句话:super.xxx定位方法,this决定上下文。这跟Object.getPrototypeOf(Child.prototype).greet.call(this)是等价的。

2.3 子类方法覆写与“链式调用”

方法覆写(override)在继承里太常见了。子类和父类有同名方法,子类覆盖父类实现,但很多时候我们不想完全重写,而是想“在父类的基础上加点料”。

这时候super.xxx()就是你的工具。经典场景是生命周期函数、初始化函数、销毁函数等模板方法模式:

class BaseComponent { init() { this.loadConfig(); this.setupEvents(); } loadConfig() { // 父类默认实现 console.log('loading base config'); } setupEvents() { // 父类默认实现 console.log('binding base events'); } } class UserComponent extends BaseComponent { loadConfig() { super.loadConfig(); // 保留父类逻辑 console.log('loading user-specific config'); } setupEvents() { super.setupEvents(); console.log('binding user-specific events'); } }

这种写法的好处是骨架固定,子类只需要覆写个别环节,系统整体行为可控。很多框架的插件机制、中间件机制本质上都是这个思路的变体。

不过我不建议为了“覆写而覆写”把方法链搞得太深。三层以上的super.xxx()调用链,调试的时候你会在脑子里画半天图。一般来说,继承层次控制在两层以内是底线。

2.4 关于 new.target 和抽象类的模拟

JavaScript 没有真正的抽象类,但你可以用new.target来模拟“不能实例化”的限制:

class AbstractModel { constructor() { if (new.target === AbstractModel) { throw new TypeError('AbstractModel 是抽象类,不能直接实例化'); } this.createdAt = Date.now(); } } class UserModel extends AbstractModel { static find(id) { // 模拟查询 return new this(id); } }

new.target在构造函数里指向“实际被new的那个类”。如果你直接new AbstractModel(),它等于AbstractModel本身,于是抛错;如果你new UserModel(),new.target指向UserModel,正常通过。

这是实现“抽象基类+具体子类”结构的轻量方案。虽然 JavaScript 没有 interface,但通过这种手法配合文档约束,也能把设计意图传达到位。

3. 静态成员继承与内置类的坑

继承不只是实例方法和属性的事,静态成员也在继承范围内,但这里面的细节和内置类的坑很容易让人怀疑人生。

3.1 静态方法到底怎么继承的

ES6 的extends会同时把父类的静态方法继承给子类,所以你可以这样:

class Parent { static create() { return new this(); } static whoAmI() { return 'Parent'; } } class Child extends Parent {} console.log(Child.whoAmI()); // Parent console.log(Child.create() instanceof Child); // true

这里有个很微妙的点:Parent.create()函数体里的this,在通过Child.create()调用时指向的是Child,所以new this()创建的是Child实例。这种“静态方法多态”在工厂模式里极其常用。

原理很简单:class Child extends Parent {}会把Child这个函数对象的内部原型([[Prototype]])指向Parent。所以Child.whoAmI实际上是顺着函数对象的原型链从Parent上找到的。

3.2 内置类(Array、Error)继承的经典翻车现场

内置类的继承有历史包袱。ES5 时代你没法真正继承Array,因为你不能拿到一个真正的数组实例来初始化this。ES6 修复了大部分问题,但还得注意Symbol.species。

看个例子:

class MyArray extends Array { first() { return this[0]; } } const arr = new MyArray(1, 2, 3); console.log(arr.first()); // 1 const mapped = arr.map(x => x * 2); console.log(mapped instanceof MyArray); // ???

在支持 ES6 的现代引擎里,map返回的数组仍然是MyArray的实例。原因就是Symbol.species:当Array.prototype.map需要创建新数组时,它会读取this.constructor[Symbol.species]来决定用哪个构造函数。

默认情况下,MyArray[Symbol.species]返回MyArray本身,所以结果还是MyArray。如果你想强制返回普通数组,可以这样覆盖:

class MyArray extends Array { static get [Symbol.species]() { return Array; } }

相应地,继承Error时也要注意:默认情况下,instanceof行为在现代引擎上是正常的,但如果你用了 ES5 风格的类库(比如util.inherits),需要记得Error的prototype上相关属性可能没被正确链接。用 ES6 class 直接 extends 就行。

3.3 内置类继承后 this 创建顺序的差异

自定义类继承时,我们必须先super()才能用this,但内置类(Array、Error、Map等)的继承有一个微妙的差别:内置类的实例对象是由内置逻辑直接创建的,子类构造函数中的super()调用不会像普通类那样重建对象。

什么意思?看代码就懂了:

class NewArray extends Array { constructor(...args) { super(...args); this.tag = 'special'; // 可以正常往实例上挂属性 } }

你很少会想手动重写内置类的构造逻辑,因为内置实例的内部槽位(比如数组元素存储区)无法通过普通属性赋值来初始化。除非你清楚自己在做什么,否则直接继承内置类时,不要重写 constructor,直接省略让默认行为跑完即可。

4. 组合优于继承:继承不是唯一答案

有一句话在工程界流传得很广:优先使用对象组合,而不是类继承。这个观点放在 JavaScript 里尤其适用,因为 JavaScript 天生是动态语言,很多东西根本不需要继承就能做到。

4.1 继承的痛点什么时候会爆发

继承最大的问题体现在三方面:

  • 脆弱的基类问题:父类的一个小改动,可能让所有子类行为变异,而且这种影响是隐性的,排查成本极高。
  • 层次僵化:业务结构是动态变化的,但继承树是静态的。你今天把Bird继承自Animal,明天发现某只鸟不会飞、某条鱼会走路,就得大改类结构。
  • 多重继承缺失:JavaScript 只有单继承,想同时拥有两个类的行为?要么拉长链条,要么引入 mixin,复杂度直线上升。

我在实际项目里见过最夸张的一次:一个业务实体类经历了七层继承,每层加一个字段或方法,最后光实例化就要执行六七个构造器,调试一个字段不见了,从上往下查了整整一天。

4.2 Mixin 模式:JavaScript 的组合利器

Mixin 的思想很简单:把一系列可复用的方法和属性打包成一个对象,然后“混入”目标类,不依赖继承链。

const SpeakerMixin = { speak() { console.log(`${this.name} says hello`); } }; const WalkerMixin = { walk() { console.log(`${this.name} is walking`); } }; class Person { constructor(name) { this.name = name; } } // 把 mixin 的方法混入 Person.prototype Object.assign(Person.prototype, SpeakerMixin, WalkerMixin); const p = new Person('小明'); p.speak(); // 小明 says hello p.walk(); // 小明 is walking

这样Person不需要继承任何基类,就获得了两种能力。后续你想让另一个类也拥有speak,直接Object.assign过去就行,完全不用动继承结构。

为了让 mixin 更工程化,可以封装成函数:

function mixin(Base, ...Mixins) { class Mixed extends Base {} Mixins.forEach(m => { Object.assign(Mixed.prototype, m); if (m.staticMethods) Object.assign(Mixed, m.staticMethods); }); return Mixed; } class Dog extends mixin(Animal, SpeakerMixin, WalkerMixin) {}

怪癖:mixin返回的是一个新的匿名类,每次调用都会生成一个新类,所以不要把它用在需要比较constructor的地方(比如instanceof链上的中间层)。

4.3 什么时候真的应该用继承

说了继承这么多不好,并不是说你不能用继承。判断标准其实很简单:

  • 子类是父类的一种“是”关系(is-a),且不会变异。
  • 子类需要复用父类的完整实现,包括私有内部逻辑(即使通过protected概念近似模拟)。
  • 你要利用多态能力,面向父类编程。

经典的例子是:PDFReport继承BaseReport,因为所有报表共享“生成头部、生成内容、生成页脚”的骨架流程,且不会有哪个报表突然不要页脚。这种情况下用继承反而让代码更紧凑。

另外一种情况是框架/库要求你继承它的基类才能接入系统。这时候你没有太多选择,直接继承即可,但也不要画蛇添足去额外叠加多层业务继承。

5. 私有字段、静态块与继承的边界问题

ES2022 之后,JavaScript 类新增了真正的私有字段和静态初始化块。这些东西和继承的组合有一些不太直观的行为,得单独拿出来聊一聊。

5.1 私有字段(#)在继承里的表现

私有字段用#前缀声明,访问受限在类内部。它跟原来的“约定俗成下划线前缀”(_private)不一样,是真的无法从外部访问:

class Person { #secret = 'classified'; getSecret() { return this.#secret; } } class Employee extends Person { reveal() { // Error: Private field '#secret' must be declared in an enclosing class // return this.#secret; } } const e = new Employee(); console.log(e.getSecret()); // 'classified',父类方法可以访问

看到没有?子类实例虽然继承了父类的私有字段,但子类代码本身不能直接访问。私有字段是“按类隔离”的,不是按实例隔离的。只有父类内部的方法才能读写它。

这意味着你用私有字段设计的类,天然有了封装边界:子类只能通过父类暴露的接口来操作内部状态。你不需要protected关键字(虽然很多语言有),因为私有字段已经把“子类能不能碰”的权限一刀切掉了。

如果你确实希望“子类可读、外部不可读”,目前的方案是:父类提供一个protectedGet()这样的方法接口,内部返回私有字段值,子类调用父类方法间接读取。

5.2 静态初始化块的执行顺序

静态块(static {})在类定义时立即执行,且按书写顺序执行。它的典型用途是初始化类的静态配置数据:

class Registry { static items = new Map(); static { Registry.items.set('base', 1); Registry.items.set('extra', 2); } }

当有继承时,父类的静态块会在子类静态块之前执行:

class Parent { static { console.log('Parent static block'); } } class Child extends Parent { static { console.log('Child static block'); } } // 输出顺序: // Parent static block // Child static block

这个执行顺序符合直觉:先初始化父类,再初始化子类。但它隐蔽的坑在“静态属性被继承但彼此独立”:

class Parent { static list = []; } class Child extends Parent {} Child.list.push('x'); console.log(Parent.list); // ['x'] ?不该如此但确实如此

惊醒一下:Child.list和Parent.list指向同一个数组引用。静态属性继承的是引用,不是副本。如果你在子类里直接操作Child.list,改的是父类的那个数组。这是好多人在静态属性里存配置时踩过的血泪坑。

解决办法:如果子类需要独立的静态数组,要么在子类 static 块里重新初始化:

class Child extends Parent { static { Child.list = []; } }

要么统一提供静态方法而不是静态可变字段。能不改就少改。

5.3 深拷贝 proto 链与构造器 identity 问题

还有一个经常出现在“继承坑”里的问题是constructor的指向。默认情况下:

class Parent {} class Child extends Parent {} console.log(Child.prototype.constructor === Child); // true

如果你继承时重写了 prototype(比如手动设置了Child.prototype = Object.create(Parent.prototype)而忘了设置constructor),那Child.prototype.constructor会指向Parent。这就导致某些依赖constructor做实例识别的库(如部分序列化、ORM)彻底出错。

用class extends语法时,引擎会自动帮你把Child.prototype.constructor修好,但如果哪一天你为了兼容老代码去手动改原型链,记得顺手补一句:

Child.prototype.constructor = Child;

这事看起来小,炸起来要命。

6. 实战:实现一个三层结构的用户体系

纸上谈兵讲了这么多原理,来一个综合性的实例。一个常见的后台系统用户体系,我们做三层设计:BaseUser→AdminUser→SuperAdminUser,顺便混入操作日志能力。

6.1 需求分析和类结构设计

需求大概是这样的:

  • 所有用户都有name、role、login()方法。
  • 管理员有manageUsers()权限,且需要记录操作日志。
  • 超级管理员拥有管理员全部能力,额外还有manageAdmins()权限。
  • 日志记录能力要求可复用,后续可能有审计员等角色也需要日志,不适合放在基础用户类里。

设计决定:

  • 基础能力用BaseUser。
  • AdminUser extends BaseUser,叠加日志能力。
  • SuperAdminUser extends AdminUser,扩展管理能力。

6.2 代码实现与细节说明

// 日志能力 Mixin const LoggerMixin = { log(action) { console.log(`[${new Date().toISOString()}] ${this.role} ${this.name} -> ${action}`); } }; // 基类 class BaseUser { constructor(name) { if (new.target === BaseUser) { throw new TypeError('BaseUser 是抽象类,请使用具体子类'); } this.name = name; this.role = 'user'; this.permissions = []; } login() { console.log(`${this.name} 已登录`); return true; } hasPermission(perm) { return this.permissions.includes(perm); } } // 管理员 class AdminUser extends BaseUser { constructor(name) { super(name); this.role = 'admin'; this.permissions = ['manage_users', 'view_reports']; } manageUsers() { if (!this.hasPermission('manage_users')) { throw new Error('无权限'); } this.log('manage_users'); console.log(`管理员 ${this.name} 正在管理用户`); } } Object.assign(AdminUser.prototype, LoggerMixin); // 超级管理员 class SuperAdminUser extends AdminUser { constructor(name) { super(name); this.role = 'super_admin'; this.permissions = ['manage_users', 'view_reports', 'manage_admins']; } manageAdmins() { if (!this.hasPermission('manage_admins')) { throw new Error('无权限'); } this.log('manage_admins'); console.log(`超级管理员 ${this.name} 正在管理管理员账号`); } }

细节说明:

  • BaseUser构造函数通过new.target === BaseUser阻止直接实例化,强制所有用户必须使用具体子类。
  • 每个子类的constructor先super(...)再改自己的role和permissions,保证继承夹层不会被跳过。
  • 日志能力通过Object.assign(AdminUser.prototype, LoggerMixin)混入。SuperAdminUser自动从AdminUser.prototype上获得log方法,不需要再次 mixin。

6.3 验证多态和权限模型

const admin = new AdminUser('老张'); const superAdmin = new SuperAdminUser('老李'); admin.login(); // 老张 已登录 admin.manageUsers(); // 管理员 老张 正在管理用户 superAdmin.manageUsers(); // 超级管理员 老李 正在管理用户 superAdmin.manageAdmins(); // 超级管理员 老李 正在管理管理员账号 admin.manageAdmins(); // TypeError: admin.manageAdmins is not a function

注意最后一行:admin没有manageAdmins方法,调用直接报类型错误。这是“继承收窄”的边界问题——父类引用无法调用子类专属方法,需要先做类型判断或能力检测:

if (typeof admin.manageAdmins === 'function') { admin.manageAdmins(); } else { console.log('该用户不是超级管理员'); }

这种“鸭子类型”检查在 JavaScript 里非常自然,也是继承体系下做权限控制时比较安全的操作姿势。

6.4 日志混入的另一个实现:工厂式类

如果你的日志能力想更无畏一点,不想污染现有类的原型,也可以用“包装类”方案:

function withLogging(BaseClass) { return class extends BaseClass { log(action) { console.log(`[LOG] ${this.role} ${this.name} -> ${action}`); } }; } class SuperAdminUser extends withLogging(AdminUser) { // ... }

用高阶函数包装类的好处是:你不改变AdminUser本身,只是生成一个带日志的新类。但这种方案每次调用withLogging(AdminUser)都会生成新类,性能上几乎可以忽略,但要注意instanceof时的身份问题:

const LoggedAdmin = withLogging(AdminUser); const loggedAdmin = new LoggedAdmin('测试'); console.log(loggedAdmin instanceof AdminUser); // true,仍然成立 // 但 loggedAdmin.constructor === AdminUser 是 false

整体来看,Object.assign直接混入原型的方式更简单直观,适合角色体系稳定、日志行为不会改变的项目。

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

讲到最后,把我在实际项目里遇到的几个高频异常整理成速查表。遇到问题先对照这个表看,能省下大量查文档的时间。

7.1 典型报错与解决方案速查

症状原因解决方案
Must call super constructor in derived class子类构造函数里没有先调用super()就访问this构造函数第一行加上super(...)
Class extends value #<Object> is not a constructorextends后面跟的不是构造函数(常见于 mmpObject、普通对象、箭头函数)检查父类声明,确保是类或构造函数
Private field must be declared in an enclosing class子类代码直接访问父类#私有字段通过父类公开方法间接获取,或改为普通字段
this在继承链上的方法里变成undefined方法被取出单独调用,未绑定this构造函数里 bind,或使用箭头函数字段
X is not a constructor但明明定义了类类表达式未赋值就使用,或类被当作没有new调用检查变量声明时机和调用方式,类必须new调用
静态属性在子类中修改影响了父类静态属性是引用类型,继承共享同一引用子类 static 块重新初始化,或避免可变静态字段

7.2 调试继承关系的神兵利器

排查继承问题,我最常用的一个技巧是用Object.getPrototypeOf层层打印原型链:

function showChain(instanceOrClass) { let obj = instanceOrClass; while (obj) { console.log(obj.name || obj.constructor?.name || '[anonymous]'); obj = Object.getPrototypeOf(obj); } } class Parent {} class Child extends Parent {} showChain(Child); // 输出: // Child // Parent // Function (这是 Child 函数对象的原型,因为静态继承链) // Object

这里要注意区分两个链条:

  • 实例链:Child.prototype -> Parent.prototype -> Object.prototype -> null
  • 构造函数链:Child -> Parent -> Function.prototype -> Object.prototype -> null

实体方法查找走的是实例链,静态方法查找走的是构造函数链。混乱这两个链,是很多继承 bug 的根源。

7.3 性能与体积的取舍建议

类继承不是免费的,但现代 V8 引擎对 class 的优化已经很到位了,一般不用担心性能。真正要注意的是不要为了“继承而继承”:

  • 较短的继承链:两三层就好,别超过四层。
  • 优先使用原型共享方法,避免实例内部大量函数副本。
  • 静态属性不要放可变引用类型,如果必须放,在子类里独立初始化。
  • 多使用组合/mixin处理能力复用,只在真正的 is-a 关系时使用继承。

最后分享一个我自己的习惯:每次写完继承结构,我都会问自己一句:如果明天需求变了,这个结构需要大改吗?如果答案是“可能需要”,我会优先考虑组合;如果答案是“不会”,用继承会让代码更紧凑。这个简单的自我问答,帮我少掉了很多头发。


上面这些经验和代码,都是在实际业务里摔过跟头之后总结出来的。JavaScript 的类继承没那么玄乎,但也绝不像extends关键字看上去那样轻描淡写。你只需要记住:继承的本质是原型链,super是调用父类逻辑的通道,组合是继承缺失时的替代方案。掌握这三条主线,再复杂的类结构也能理清头绪。

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

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

立即咨询