前端面试这件事,有个很有意思的现象:几乎每一次技术面都会把“继承”作为一个绕不开的考点,而很多候选人要么只能背出“extends、super”这两个关键字,要么把ES5和ES6的继承混为一谈。几年前我开始带团队做前端面试时,就把“ES6的继承方法”列为一个标准考察项,因为这道题真正能体现候选人有没有理解JavaScript对象模型的底层逻辑,而不只是刷过几道面经。
这篇文章我打算用一种“陪练”的方式来写:先把这道面试题背后的考察逻辑讲清楚,再把它涉及的原理、手写实现、高频追问一条条拆开,最后放一份模拟面试实录。无论你是正在准备跳槽的候选人,还是要面试别人的技术人,都能从这里找到可直接用的思路。
1. 面试官到底在考什么:这道题背后的三个考察层级
很多人拿到“es6的继承方法”这个题,第一反应是“我会,class、extends、super嘛”。于是面试官接着问一句“那你讲讲子类实例的proto指针具体是怎么指的呢?”很多人就在这里卡住了。所以这个题目从来不是考一个点,它至少分了三个层级。
第一层是概念层:你能不能准确说出ES6继承的基本写法,比如 class Child extends Parent、子类构造函数里必须先调用 super(),以及子类可以覆写父类方法这些最基础的东西。这个层级考察的是“用没用过”。
第二层是原理层:你要知道 class 只是一个语法糖,底层仍然是原型链机制。你需要能回答“子类的构造器函数本身”和“子类的 prototype 对象”这两条原型链分别指向哪里,要能画出完整的原型链关系图,要理解为什么子类的实例能够调用父类的方法。这个层级考察的是“懂不懂”。
第三层是应用层:面试官会抛出场景题,比如“如果继承一个内置的 Array 会被谁污染?怎么绕过去?”“静态方法能被继承吗?为什么能?” “子类构造函数里想使用 this,为什么必须先调用 super()?底层是怎么规定的?” 这一层考察的是“踩没踩过坑、会不会排查问题”。
我建议所有准备这道题的人都按这个层级去准备,而不是停留在背概念。因为面试官问同一个题目,根据你的回答深度会决定追问的难度。你回答得越深,反而越容易把话题引向你能掌控的领域;你只会念概念,对方就只能用刁钻的边界问题来试探你的底线。
2. 合并核心原理:从原型链到 Class 继承的运行时真相
2.1 class 只是语法糖,底层仍然是原型链
ES6 里定义类、继承类,写起来非常接近传统面向对象语言,比如:
class Parent { constructor(name) { this.name = name; } greet() { return `Hello, ${this.name}`; } } class Child extends Parent { constructor(name, age) { super(name); this.age = age; } info() { return `${this.greet()}, age ${this.age}`; } }这段代码人畜无害,看起来就像经典的类继承。但你一旦用 Babel 或者直接用运行时去检查,会发现它最后落的还是函数加 prototype 那一套。用生活里的类比来说,class 是给电动车、燃油车统一设计的“驾驶舱仪表盘”,但核心引擎依然是你熟悉的内燃机/电机——原型链就是那个引擎。只要你开车,无论你开的是什么车,都得遵循引擎的运行逻辑。
所以如果你在面试中只盯着 extends,你就看不到“引擎”。真正决定继承工作方式的,是三样东西:构造函数的 [[Prototype]]、构造函数的 prototype 属性、实例对象的 [[Prototype]]。
2.2 关键原型链:两条proto指针的走向
为了说清楚,我们直接用上面 Child 和 Parent 的代码做例子。当一个子类通过 extends 继承父类时,JS 引擎做了一件很多人没注意的事:它把子类自身的 [[Prototype]](也就是 Child.proto)指向了父类 Parent。
Child.__proto__ === Parent; // true Child.prototype.__proto__ === Parent.prototype; // true这里就形成了两条完全不同的链路:
- 第一条:实例对象 child.proto=== Child.prototype,而 Child.prototype.proto=== Parent.prototype,所以 child 可以沿着原型链访问到 parent 原型上的 greet 方法。
- 第二条:函数对象自身的链路:Child.proto=== Parent,这条链让子类可以“继承”父类的静态方法。
我面试中特别爱让候选人画这两条线。很多人知道第一条,却忽略第二条。但实际上,这两条链同时被设置,才是 ES6 继承和普通“原型式继承”最大的区别。如果你只用 ES5 的寄生组合式继承,子类构造函数 Child 的proto并不会自动指向 Parent,这时想让 Child 直接调用 Parent 的静态方法,就得手动把 Child.proto= Parent 或者把 Parent 的静态属性拷贝过来。
2.3 默认构造函数行为:不写 constructor 时发生了什么
还要注意一个高频细节:如果子类没有显式写 constructor,引擎会帮你补一个。补出来的并不是一个空函数,而是一个类似这样的默认构造器:
class Child extends Parent {} // 等价于 class Child extends Parent { constructor(...args) { super(...args); } }这个默认构造器会把你调用 new Child(...) 时传入的所有参数,原样透传给父类构造函数。所以面试里如果问“子类不写 constructor,父类需要处理参数吗”,答案是父类自己按自己的 constructor 构造逻辑去处理,子类不干预。
2.4 与 ES5 寄生组合式继承的对比
为了让你回答“为什么 ES6 继承比 ES5 继承更好”时更有底气,我贴一下经典的 ES5 寄生组合式写法:
function Parent(name) { this.name = name; } Parent.prototype.greet = function () { return 'Hello ' + this.name; }; function Child(name, age) { Parent.call(this, name); this.age = age; } Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; Child.__proto__ = Parent; // 这一行 ES5 经典写法里通常不会自动做ES5 写法中每一点都需要程序员自己手动管理:要用 Parent.call(this, name) 来模拟“调用父类构造函数”,要用 Object.create(Parent.prototype) 来建立原型连接,还要记得把 Child.prototype.constructor 指回 Child,最后还得手动处理静态继承。而 ES6 的 extends 把上面这些行为全部内置了,并且强制要求先 super 再 this,从语言层面堵住了一些误用。
2.5 行为差异对比表
我把 ES5 和 ES6 在关键行为上的差异整理成了一张表,方便你直接拿去复习:
| 对比维度 | ES5 手写寄生组合式 | ES6 class 继承 |
|---|---|---|
| 构造函数调用父类 | Parent.call(this, ...)手动调用 | super(...)内建规范 |
| 原型连接 | Object.create(Parent.prototype)手动 | extends 自动完成 |
| constructor 恢复 | 手动Child.prototype.constructor = Child | 语言自动处理 |
| 静态方法继承 | 需要手动遍历拷贝或设__proto__ | 自动通过Child.__proto__继承 |
| 实例化限制 | 任何函数都可能被 new | class 类不能被直接作为普通函数调用 |
| 内置类型扩展 | 很难正确继承 | 通过 Symbol.species 机制规避污染 |
| 破坏性 this 使用 | 可以先访问 this 再调用父逻辑 | 必须先 super 再使用 this |
这张表基本上可以拿来当回答提纲模板:先说语法便利,再说底层一致性,再说静态继承差异,最后提到内置类型扩展这个延伸点。如果你能顺着这张表展开讲两分钟,面试官很难不满意。
3. 核心实操要点:super、this 与静态成员的继承细节
3.1 为什么子类构造函数必须先调用 super()
这是面试里可能出现的第一个追问死角,很多候选人只会说“这是规定”,但不知道是什么规定的。你得从对象初始化的角度去讲:在 ES6 类继承的机制里,只有通过 super() 调用父类构造函数,JS 引擎才会为当前实例的 this 绑定初始状态。你可以这么理解:父类构造函数负责“创建并初始化”这个新对象的内存块;子类拿到这块已经被初始化好的内存,再往上面补齐自己的字段信息。
当你子类构造函数里写 this.age = age,而前面没有 super(name) 时,this 处于“未初始化”状态。感兴趣的话可以亲自试一下:直接 new Child('A', 18),引擎会抛错 ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor。这个报错信息已经把规则说得非常明白了:要么调用 super,要么永远别碰 this。
还有一个更隐蔽的边界:如果子类构造函数返回了一个对象,那么此时是否可以不调用 super?答案是可以的。比如:
class Child extends Parent { constructor() { return { custom: true }; } }这种情况下子类构造函数没有使用 this,也没有访问父类初始化,它直接返回了一个自定义对象。因为当构造函数返回对象时,new 表达式的最终结果就是这个对象,原计划为新实例做的初始化就都跳过不做了。不过日常业务代码里基本没人这么写,但面试官偶尔会用这种边界题来划分“死记硬背”和“真懂规范”。
3.2 super 关键字在普通方法与静态方法里的不同含义
super 这个关键字在不同上下文里指向完全不同的东西:
- 在子类构造函数里:super(...) 调用的是父类的构造函数。
- 在子类普通方法里:super.method() 中的 super 指向 Parent.prototype,等价于 Parent.prototype.method.call(this)。
- 在子类静态方法里:super.method() 中的 super 指向 Parent 这个函数对象本身。
这三种情况只要搞混一处,代码就会出问题。举个例子,假设父类有静态方法:
class Parent { static hello() { return 'parent static hello'; } hello() { return 'parent instance hello'; } } class Child extends Parent { static test() { return super.hello(); } test() { return super.hello(); } } Child.test(); // 'parent static hello' new Child().test(); // 'parent instance hello'之所以分别输出不同的结果,正是因为 super 在静态方法语境里被绑定为父类构造函数,而在实例方法语境里被绑定为父类原型。你理解了这个绑定规则,就不会再疑惑“为什么同样的写法结果不一样”。
我在辅导候选人时发现一个有效的记忆方法:super 的绑定目标和 this 类似,都取决于“当前函数是在哪个对象上定义的”。定义在类构造函数里就指向父类构造函数;定义在普通方法里就指向父类 prototype;定义在静态方法里就指向父类函数自身。
3.3 静态成员继承与子类改写的边界
前面说过 Child.proto=== Parent,这条链路让 Child 可以直接访问 Parent 的静态方法。这条链也意味着你可以在子类里覆写静态方法:
class Child extends Parent { static hello() { return super.hello() + ' | child'; } }这里如果父类是 Parent,子类的静态方法里调用 super.hello(),拿到的依然是从 Child.proto链路上解析出来的父类静态方法。这就是静态继承对写代码的实际影响。
但要小心一个容易踩的坑:静态属性不会被 Object.create 式的原型继承自动处理?实际上只要 extends 建立了 Child.proto到 Parent 的链接,静态属性和静态方法都会沿着这条函数原型链被访问到。因此你不需要像 ES5 那样手动遍历拷贝。这一点也经常作为加分项,是很多面试官期待听到的高级点。
3.4 实例属性初始化顺序
ES6 类的字段声明(也就是 class 里直接写 age = 18 这种语法)也有明确的执行顺序:父类实例字段在 super() 返回后立刻初始化,然后才轮到自己类的实例字段,最后才轮到 constructor 剩余逻辑。
考虑一个实际例子:
class Parent { name = 'parent default'; constructor(name) { this.name = name; // 这里会用参数覆盖默认值 } }如果你在子类里通过 super(name) 调用父类构造函数,父类的 constructor 会主导 name 的最终值,子类声明的实例字段在 super 返回之后才初始化。如果你在子类构造函数里也写了 this.name = 'child override',那会覆盖父类设置的值。所以面试时一旦聊到“赋值的最终结果”,你得按“父类 constructor -> 子类实例字段 -> 子类 constructor 剩余代码”这个顺序去推断。
4. 高频追问题库:这些边界问题别翻车
4.1 追问一:ES6 的类真的“没有原型链”吗
很多候选人一提到 class 就陷入“面向对象”的思维定势,以为它不像 function 那样有 prototype。事实恰好相反,class 的本质还是 function,只不过它是一个带有 const 级别限制的特殊函数。你可以运行 typeof Parent,输出就是 function。所以类构造器也有 prototype 对象,也有 prototype.constructor 指向自身。对 class 调用 new 和直接调用普通函数的差异在于,class 内部有 [[IsClassConstructor]] 标记,这个标记让引擎禁止在缺少 new 时调用 class,否则会抛出 TypeError。
4.2 追问二:new.target 在继承中有什么作用
这是一个比较冷门的知识点,但很值得掌握。new.target 是函数内部的一个元属性,它表示“当前 new 表达式实际指向的构造函数”。当执行 new Child() 时,即使我们正在执行父类的 constructor 代码,函数体内的 new.target 依然是 Child 而不是 Parent。
这个特性可以让我们在父类中禁止直接实例化,强制要求父类只能被继承:
class Parent { constructor() { if (new.target === Parent) { throw new Error('Parent cannot be directly instantiated'); } } }这也是实现“抽象类”语义比较简单的方式,很多优秀实践里都这么限制。遇到面试官聊“抽象类怎么做”,你可以把这个方案丢出来。
4.3 追问三:继承内置类型比如 Array,会出现什么问题
经典考题:我们希望做一个自带去重能力的数组类:
class UniqueArray extends Array { push(val) { return super.push(...new Set([...this, val].filter(item => !this.includes(item) && item !== val))); // 简化演示,一般的记忆思路是覆写相关方法 } }实际上比较常见的坑不是 push 本身,而是那些返回新数组的内置方法,比如 map、filter、slice。默认情况下,这些方法返回的仍然是子类实例,因为它们内部使用了 Symbol.species。当你希望这些方法返回标准数组而不是子类实例时,就需要定义一个静态 getter:
class UniqueArray extends Array { static get [Symbol.species]() { return Array; } }这背后的逻辑是:数组原型上的方法在创建新实例时,会读取 constructor[Symbol.species],如果这个值是 Array,就按普通数组创建;如果没覆盖,那它就保持为 UniqueArray。这个机制也叫“衍生对象”机制。能答出 Symbol.species,面试官会意识到你对 ES6 内建类型扩展有真实经验。
4.4 追问四:箭头函数里的 super 和 this 有什么特殊性
箭头函数没有自己的 super,也没有自己的 this。所以如果你在类的方法里嵌套一个箭头函数,并且在这个箭头函数里写 super.method(),它会按照词法作用域去解析,也就是找它外层那个函数的 super 绑定对象。
这意味着如果你想在回调函数里调用父类方法但不想改变 this 指向,箭头函数会非常方便:
class Child extends Parent { init() { setTimeout(() => { // 这里的 super 和 this 都是从 init 方法继承来的 super.greet(); }, 100); } }如果你在这里用普通 function 回调,this 就会丢失,super 也无法直接访问。这个边界非常值得在面试中主动提出来,能顺带展示你对箭头函数词法行为理解得透。
4.5 追问五:mixin 与多继承
很多人会追问“ES6 继承能实现多继承吗”。严格来说,class 的 extends 只能一继承一,不支持直接多继承。但我们可以用 mixin 组合来达到类似效果,原理是把多个父类的原型方法混入一个中间函数:
const mixinA = (Base) => class extends Base { a() { return 'a'; } }; const mixinB = (Base) => class extends Base { b() { return 'b'; } }; class Base {} class Combo extends mixinA(mixinB(Base)) {}这种高阶函数式 mixin 方案可以叠加多个行为,而且符合函数式组合的直觉:mixinA(mixinB(Base)) 先从 Base 构建一个中间类,再往上叠加 A 的能力。面试里能把这个方案说出来,说明你不仅会继承,还知道继承的局限性,以及如何用组合来弥补。
5. 模拟面试实录:一次完整的问答场景
为了让你直观感受“答到什么程度是加分”,我写一段模拟面试场景。面试官和候选人分别用“面”和“答”来标注。
面:“说说 ES6 的继承,你平时怎么写?” 答(普通水平):“就是用 class 定义父类,子类 extends 一下,子类构造函数里先 super 一下,然后就用 this.xxx = xxx 就行了。直接在子类里定义同名方法就能覆盖父类的方法。”
面:“那你能说说子类的原型链是怎么连到父类的吗?” 答(普通水平卡住):“呃……子类实例可以通过proto找到父类的 prototype 吧……”
面:“如果我说要详细一点呢?子类构造函数本身和父类构造函数之间有什么关系?” 答(加分水平):“这里面其实有两条链。Child 作为构造函数,它的proto指向 Parent,所以 Child 能直接调用 Parent 的静态方法。另外 Child.prototype 的proto指向 Parent.prototype,所以实例能访问父类原型上的方法。这两条链由 extends 在创建子类时自动建立,我对标 ES5 的实现时,得手动写 Object.create(Parent.prototype) 和 Child.proto= Parent 这两步。”
面:“为什么子类里想用 this 必须先用 super?” 答(加分水平):“因为子类构造函数执行的时候,this 还没有被初始化。父类的构造函数负责创建和初始化这个实例,子类必须先通过 super() 让父类完成初始化,然后才能往同一个对象上挂自己的字段。如果不先调用 super,引擎会直接抛错,因为 this 处于未初始化状态,任何读写都不被允许。只有子类构造函数返回一个新的对象时,才存在不调用 super 的可能,因为那个新对象会成为 new 表达式的结果。”
面:“如果想继承 Array,并在派生类里实现一个 max 方法,需要注意什么?” 答(加分水平):“要注意两点,第一是覆写内置方法的时候尽量基于 super 调用原逻辑,第二是如果希望 filter、map 这些方法返回标准数组而不是子类实例,需要显式设置 Symbol.species 为 Array,否则数组方法会一直返回子类实例。比如 new Child().map(...) 返回的仍是 Child 类型,在某种场景下会产生意外的子类逻辑。”
你可以看到,加分回答的特征是:“先给结论,再讲机制,最后补边界”。哪怕是同一个问题,只要你把关键机制的“因为所以”讲清楚,面试官基本不会再刁难下去。
6. 我这几年踩过的坑:一类继承相关的问题排查克星
我带了这么久的前端团队,见过太多在 ES6 继承上栽跟头的案例。下面这几个坑我会在实际面试里专门拎出来讲,因为它们都是真实项目中出现的。
6.1 坑一:Toast/SDK 类继承 EventEmitter 时静默失效
我们曾经在一个老项目里封装一个全局事件总线,使用 class 继承了一个事件库的 EventEmitter。结果页面运行时某些模块 listen 不到事件,排查了半天,发现子类实例化时事件库内部使用了 this 引用某个来自父类构造函数的内部属性,而我们的构造函数在执行完 super() 后又立即覆写了同名属性,导致事件库内部记录被覆盖。
这类问题的核心教训是:不要随意覆写父类内在使用的同名字段。如果你必须覆写,先去看父类源码,确认它是在构造函数阶段还是使用阶段读取的。很多组件库/工具库的继承设计和普通业务父类不一样,它们对内部字段名有约定,盲改很容易翻车。
6.2 坑二:Error 子类的 stack 和 name 表现异常
自定义错误类型的时候,最正统的办法是:
class ValidationError extends Error { constructor(message) { super(message); this.name = 'ValidationError'; } }如果你遗漏了 this.name 的赋值,实例的 name 永远是 Error,非常不利于日志聚合。另一个坑是有些环境下 Error 子类的 stack 信息抓不到正确的调用栈,因为引擎的 stack 是根据 new.target 或 throw 位置生成的,如果你做了额外的中间包装,很容易丢失调用栈。
遇到这种情况,我的建议是尽量直接 throw new ValidationError(...),不要在封装函数里夹一层 try/catch 后再 new 一个。必要的时候可以用 Error.captureStackTrace(this, ValidationError),但要注意不是所有平台都支持。
6.3 坑三:类组件继承时的 this 绑定问题
最早很多人在 React 类组件里做继承封装,写自定义高阶代码时发现方法里的 this 变成 undefined。原因很简单:类方法没有做实例绑定,而事件回调环境里的 this 会丢失。虽然现在 React 社区已经全面转向函数组件和 Hooks,但如果你维护老项目且需要类组件,最好在 constructor 里把方法 bind 回去,或者使用类字段箭头函数:
class Child extends BaseComponent { handleClick = () => { this.setState({ clicked: true }); }; }箭头函数类的字段会作为每个实例自己的属性存在,天然不丢失 this。但要注意,这也意味着它不在原型上,每个实例都会为这个函数分配一块内存,如果你有大量实例,内存占用会略高。
6.4 坑四:父类设计时没考虑 protected/private 需求
ES2022 正式引入了私有字段,比如 #secret。私有字段在继承中有个关键规则:子类可以继承父类的私有字段吗?可以,子类实例里确实会有这块内存,但子类方法并不能直接访问它。你必须在父类里提供公开方法,子类才能间接访问。
所以如果你设计一个类库,希望将来让外部扩展,就不要把核心状态全放进私有字段,至少要留出受保护的访问入口,否则别人的 subclass 写起来会非常痛苦。
6.5 坑五:过度使用继承,忽视组合
最后这个坑不完全是代码层面的,更多是设计层面的。我见过不少团队为了复用几行代码,硬造出长长的继承链,最后整棵树腐化到没人敢动。在实际项目里,继承是一种强耦合方案;子类一旦 extends 父类,就自动依赖父类的所有内部细节。
我的实践原则是:如果两个类之间真的有“是什么”的关系,才考虑继承;如果只是“用某个能力”的关系,优先组合。比如“订单服务”不应该是“日志服务”的子类,而是应该内部持有 logger 实例。这一点在面试中也可以作为收尾观点说出来,既能展示你的设计素养,又能把话题拉到你熟悉的工程实践上。
7. 结构化速查:面试答题的万能模板
给所有准备这道题的人一个直接能用的答题模板。当面试官问“讲讲 ES6 继承方法”,你可以按以下四步组织回答:
- 语法层:先用 extends/super/constructor 写出一个最小可运行示例,说明基本用法。
- 原理层:指出 class 底层是函数与原型链,说清 Child.proto与 Child.prototype.proto两条链。
- 行为层:解释为什么子类构造器里必须先 super,再 this;简述静态方法如何沿函数原型链继承。
- 扩展层:提一个你实际用过的边界点,比如 Symbol.species 控制衍生对象、new.target 模拟抽象类、mixin 组合代替多继承。
这个模板的好处是,即使面试官只给你两分钟,你也能快速展示出“会用、懂原理、有思考”的形象;如果面试官愿意深挖,你自己也能顺着第四层继续展开。
下面这张速查表可以直接收藏,面试前看一眼:
| 常考问题 | 一句话答案关键 |
|---|---|
| 子类不写 constructor 会发生什么 | 默认补一个透传参数的 super(...args) |
| super 在普通方法和静态方法里区别 | 普通方法绑定 Parent.prototype,静态方法绑定 Parent |
| 为什么不先 super 不能用 this | 父类构造函数负责实例初始化,this 在此之前未绑定 |
| 静态方法怎么被继承 | 靠 Child.proto=== Parent 的原型链 |
| 怎么控制衍生对象类型 | 覆写静态的 Symbol.species |
| 怎么实现抽象类 | constructor 中用 new.target 限制 |
| 多继承怎么做 | 使用高阶 mixin 组合 |
| 私有字段子类能访问吗 | 不能直接访问,只能通过父类提供的公开方法 |
| 内置类型继承的坑 | 关注方法返回类型和 Symbol.species |
| class 与普通函数区别 | 有 [[IsClassConstructor]] 标记,不可无 new 调用 |
8. 最后再看一眼:回答这道题时的表达技巧
我面试过很多人,发现一个共同的表达毛病:回答问题时喜欢先绕很多背景,最后才给结论。而技术面试本质上是“时间限制下的信息传达”,你要做的是先亮出核心结论,再用结构去支撑。
比如你被问“ES6 继承的了解”,不要开口就说“我以前做个项目巴拉巴拉”,你可以说:“ES6 继承是基于 class 和 extends 的语法糖,底层是原型链机制。它和 ES5 继承最大的区别在于,extends 会同时建立两条原型连接,一条是子类构造函数的原型链,一条是子类原型的原型链。然后再补充一个我常用的例子……”这样的开头信息密度非常高,面试官马上就知道你懂行。
另外,在聊到 super 的时候,我建议你顺手提一句“super 既可以用作函数调用,也可以用作属性访问”,这样能证明你不只是背了关键字。等到你讲 static 继承,就提“Child.proto指向 Parent”,这又是你底层功底的证明。
最后一道防线是:如果某个细节真的拿不准,不要硬编。你可以坦白说“这个边界我平时没有太关注,但我理解主要是因为……”,面试官一般会顺着你的思路去引导。硬背一个错误的答案反而会让面试官对你的基础产生怀疑。
我个人在这几年面试经历里的体会是,ES6 继承这道题真正刷掉人的从来不是语法,而是那些“你说不清为什么”的细节。如果你能把每一个为什么都聊到代码层面,那这道题对你来说就不再是背诵题,而是一道展示功底的开放题。希望这篇文章能帮你把这条链路彻底打通。