JavaScript继承面试完全指南:从原型链到class底层原理

发布时间:2026/8/29 4:17:34
JavaScript继承面试完全指南:从原型链到class底层原理 面试官问“继承”的时候别以为他只是在考你背代码。我在面过几十个人之后发现继承这个话题在JavaScript里是个完美的“漏斗型”考点——从最简单的Object.create能一路问到V8里的隐藏类Hidden Class优化从口头背诵能问到你真的有没有在项目里被原型链坑过。这篇文章不跟你绕弯子直接把继承这条线从原理到面试题再到实战完整捋一遍。目标是你看完这一篇再碰到问继承的心里会非常有底。1. 面试官问继承到底想从你嘴里听到什么很多人在准备前端面试时把继承当成了“背答案”ES6的class继承怎么写ES5的寄生组合继承怎么手写背熟了就觉得自己会了。但真上了考场面试官往往会往下追问一些看起来“超纲”的问题比如“为什么class能extends一个函数”“super()为什么要放在this前面”“你怎么判断一个对象是不是某个类的实例”这一连串追问背后其实是在考察你对JavaScript底层运行机制的理解深度。1.1 八股文的本质是语言底层的逻辑链先说个判断继承在前端面试里考的不是语法而是原型链。所有继承方案不管写法怎么变最后都绕不开两个核心概念prototype和[[Prototype]]也就是__proto__。只要你把这两者之间的关系吃透了继承的所有写法都不需要死记硬背你甚至可以现场推导出最优解。很多人容易在这里犯迷糊。简单捋一下函数构造函数有一个prototype属性指向一个对象。这个对象就是将来通过new创建的实例的“模板”。实例内部有一个[[Prototype]]属性在浏览器里可以通过__proto____访问指向创建它的构造函数的prototype对象。当访问实例的某个属性时如果实例自身没有JS引擎会沿着[[Prototype]]链向上找一直找到Object.prototype再往上就是null。这条链就是继承的物理基础。你写的任何继承代码本质上都是在调整这条链的结构让一个对象能沿着链找到另一个对象上的属性和方法。理解了这一点后面所有方案都只是不同调整方式的组合而已。1.2 继承问题在面试中的典型追问路径根据我自己的面试经历和跟同行交流得到的信息面试官围绕继承的追问通常遵循一条比较固定的路径第一层你了解哪些继承方式能写一个组合继承吗第二层组合继承有什么缺点为什么需要寄生组合继承第三层ES6的extends和ES5的继承写法有什么区别第四层手写一个new运算符的实现。第五层instanceof的原理是什么Object.create(null)和{}有什么区别前两层是基础题后三层是用来区分“背题选手”和“理解选手”的。在我看来第五层才是继承八股文里最有含金量的部分因为new、instanceof、Object.create这三个知识点串联起了整个原型链机制。这篇文章后面会全部覆盖到。2. 七种继承方式的演进每一种都是在补前面的坑主流的JavaScript继承方案一共七种我习惯把它们分成三组来讲原型链及其变体、构造函数继承及其变体、组合方案。每一组都是在解决前一组遗留的问题理解这条演进线比单独背每一种写法有用得多。2.1 原型链继承一切的开端原型链继承是最基础的继承方式核心思路是让子类的prototype指向父类的一个实例。function Animal(name) { this.name name; this.colors [red, blue]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name) { this.name name; } // 关键让Dog的原型指向Animal的实例 Dog.prototype new Animal(); Dog.prototype.constructor Dog; const dog1 new Dog(旺财); const dog2 new Dog(来福); dog1.colors.push(green); console.log(dog2.colors); // [red, blue, green]这里有个非常经典的坑dog1.colors被改了dog2.colors也跟着变了。原因很简单colors是Animal实例上的引用类型属性而这个实例被当作了Dog的原型等于dog1和dog2共享了同一个数组。对于基本类型属性不会出问题因为修改时会直接覆盖但引用类型就会互相污染。另一个问题是你没法在不影响所有实例的情况下向父类构造函数传参。比如dog1和dog2想要不同的name在原型链继承里是做不到的因为new Animal()只执行了一次。2.2 构造函数继承解决引用共享问题既然原型链继承的问题是共享父类实例的属性那就干脆不共用原型了直接在子类构造函数里调用父类构造函数function Animal(name) { this.name name; this.colors [red, blue]; } function Dog(name) { Animal.call(this, name); // 关键借用构造函数 } const dog1 new Dog(旺财); const dog2 new Dog(来福); dog1.colors.push(green); console.log(dog1.colors); // [red, blue, green] console.log(dog2.colors); // [red, blue] console.log(dog1.name); // 旺财这种方式也叫盗用构造函数、经典继承解决了两个问题每个实例都有自己的引用属性副本互不干扰可以向父类传参。但它带来了更严重的新问题父类原型上的方法子类实例根本访问不到。也就是说Animal.prototype.sayName对dog1来说是不存在的因为dog1的原型链上根本没有Animal.prototype。这种继承方式在实际开发里几乎很少单独使用但它引入的call/apply调用父类构造函数的思路是后面组合方案的重要基础。2.3 组合继承原型链 构造函数的组合组合继承的思路很朴素用构造函数继承解决属性共享问题用原型链继承解决方法复用问题。function Animal(name) { this.name name; this.colors [red, blue]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name, breed) { Animal.call(this, name); // 第一次调用父类构造函数 this.breed breed; } Dog.prototype new Animal(); // 第二次调用父类构造函数 Dog.prototype.constructor Dog;这样写dog1和dog2都有自己独立的colors和name又能通过原型链访问到sayName方法看起来非常完美。这也是很长一段时间里JavaScript继承的标准答案。但组合继承有个瑕疵父类构造函数被调用了两次。第一次是在Dog的构造函数里由Animal.call执行的第二次是new Animal()。第二次调用的结果——也就是Animal实例上的name和colors——变成了Dog.prototype上的属性但这些属性是完全没用的因为每个Dog实例在创建时都会由第一次调用生成自己的同名属性覆盖掉它们。浪费内存不说还容易让人困惑。2.4 原型式继承Object.create的前身原型式继承的思路来自道格拉斯·克罗克福德核心是不需要构造函数直接以现有对象为模板创建新对象function object(o) { function F() {} F.prototype o; return new F(); } const animal { name: animal, colors: [red, blue] }; const dog object(animal); dog.name 旺财; console.log(dog.colors); // [red, blue]ES5引入的Object.create()就是这种继承方式的标准化实现。它的典型应用场景是你有一个对象作为基础想创建多个以它为原型的对象并且不需要各自独立的构造函数。如果传入的o是null创建出来的对象就没有原型链是一个真正意义的“纯净对象”。这种方式的引用共享问题依然存在但它本身的定位就不是为了创建子类而是为了对象之间的直接关联所以后来被Object.create()全面取代了。2.5 寄生式继承在原型式继承上加工寄生式继承就是在原型式继承的基础上给新对象增强一些属性或方法function createAnother(original) { const clone object(original); // 或者 Object.create(original) clone.sayHi function() { console.log(Hi); }; return clone; }这种方式有点像工厂函数给每个新对象“寄生”了额外的方法。缺点是这些方法无法复用每个实例都会创建一份新的函数。它的意义更多是思想上的在已有继承结果上做增强这个思想被用在了最后的寄生组合继承里。2.6 寄生组合继承继承方案的最终答案寄生组合继承的核心思路是不需要通过调用父类构造函数来得到父类的原型直接复制一份父类原型即可。function inheritPrototype(Child, Parent) { const prototype Object.create(Parent.prototype); // 创建父类原型的副本 prototype.constructor Child; // 修正constructor指向 Child.prototype prototype; // 替换子类原型 } function Animal(name) { this.name name; this.colors [red, blue]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name, breed) { Animal.call(this, name); this.breed breed; } inheritPrototype(Dog, Animal);这样就只调用了一次父类构造函数子类原型上也没有多余的垃圾属性。所有版本的继承方案里寄生组合继承是公认的最优解也是ES6class继承的底层实现基础。2.7 ES6 class继承现代写法ES6的class语法让继承变得非常简洁class Animal { constructor(name) { this.name name; } sayName() { console.log(this.name); } } class Dog extends Animal { constructor(name, breed) { super(name); // 调用父类构造函数 this.breed breed; } }这里的extends关键字和super()本质上就是寄生组合继承的语法糖。但如果你认为class只是写法上的变化面试官会接着追问你那class和构造函数到底有没有区别这个问题我们放到第4章详细讲。3. 为什么寄生组合继承是最优解从原理到手写前面已经给了结论这一章说清楚两件事它好在哪以及怎么手写出来。3.1 对比各种方案的关键维度把七种方案放在一张表里对比差异一目了然继承方式属性是否独立方法是否复用父类构造函数调用次数能否向父类传参原型链继承否是1次否构造函数继承是否每次实例化1次是组合继承是是每次实例化2次是原型式继承否是0次否寄生式继承否否0次否寄生组合继承是是每次实例化1次是寄生组合继承在四个维度上全部拿到了优。它既有组合继承的属性独立和方法复用又避免了一次多余的父类构造函数调用。这个“少一次调用”的好处不仅仅是性能更关键的是避免了子类原型上出现无用的属性让原型链更干净。3.2 手写寄生组合继承的完整过程面试时手写寄生组合继承你可以按这个顺序写同时心里要清楚每一步在干什么function objectCreate(parentPrototype) { function F() {} F.prototype parentPrototype; return new F(); } function inheritPrototype(Child, Parent) { // 1. 创建父类原型的副本避免直接修改Parent.prototype const prototype objectCreate(Parent.prototype); // 2. 修正constructor指向否则会是Parent prototype.constructor Child; // 3. 替换子类的原型 Child.prototype prototype; } // 使用示例 function Animal(name) { this.name name; } Animal.prototype.eat function() { console.log(this.name is eating); }; function Dog(name) { Animal.call(this, name); } inheritPrototype(Dog, Animal); Dog.prototype.bark function() { console.log(this.name is barking); }; const dog new Dog(旺财); dog.eat(); // 旺财 is eating dog.bark(); // 旺财 is barking console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true console.log(Dog.prototype.constructor Dog); // true这里最关键的就是objectCreate(Parent.prototype)这一步。你要的是“父类原型对象”本身而不是“父类实例”。很多人写的时候会不假思索地写成Child.prototype new Parent()这就又回到了组合继承的误区。如果你不想手写objectCreate直接用Object.create也是OK的。但面试官如果让你连Object.create的polyfill一起写出来上面的objectCreate就是标准答案。3.3 一个容易忽略的细节constructor写继承代码时prototype.constructor Child这一行是必须要有的。虽然constructor属性在实际业务代码里用得不多但它约定的语义是“实例是由谁构造出来的”。如果不修正Dog.prototype.constructor指向的还是Animalnew Dog()的实例被问到构造函数时就会得到错误答案。更实际的场景是某些第三方库或工具函数会通过obj.constructor.name来推断对象的类型。如果constructor指向错了这种推断就会出bug。所以不管用哪种继承方案修正constructor是一个好习惯。4. ES6 class继承语法糖还是新机制在面试里只要聊到继承几乎必定会问“ES6的extends和ES5的写法有什么区别”。很多人的第一反应是“class就是语法糖本质一样”。这话对了一半但不够准确。严格来说class继承在行为上跟手写的寄生组合继承有几处关键差异。4.1 extends和super的底层行为先看一段代码体会一下class继承背后做了什么事class Animal { constructor(name) { this.name name; } static create() { return new this(); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed breed; } bark() { console.log(this.name is barking); } }你可以在浏览器的控制台里直接验证Dog.__proto__ Animal的结果是true。这个就很有意思了在ES5写法里子类构造函数和父类构造函数之间并没有直接联系它们之间只有prototype对象的关系。但class的extends额外建立了一条静态层面的继承链让子类构造函数可以直接通过__proto__访问父类构造函数上的静态方法。这意味着在ES5里要想继承父类的静态方法你得手动改__proto__或者逐个复制在ES6 class里这件事是自动完成的。所以class继承并不是简单的“语法糖”它在语义上多了一层构造函数的继承关系。4.2 为什么super()必须在this之前这是一个高频面试题在class Dog extends Animal中构造函数里如果先用了this再调用super()会报错。原因需要从继承时的this绑定机制说起。在继承关系中子类的this并不是自动创建好的而是必须由父类构造函数来完成初始化。也就是说当调用new Dog()时如果没先执行super()this是没有绑定到任何对象的。这种情况下你去访问this.name自然就会得到“Must call super constructor in derived class before accessing this”的错误。用生活化的类比继承就像孩子出生。在ES5继承里子类是自己先“长成一个完整的人”再借用父类的方法来装饰自己而ES6 class继承里子类本质上是从父类那里“诞生”出来的必须先让父类完成基础构建才能在这块地基上盖自己的楼。class Animal { constructor(name) { this.name name; } } class Dog extends Animal { constructor(name, breed) { // 错误不能先访问this // this.breed breed; super(name); this.breed breed; // 正确 } }在子类构造函数中super(...)调用完成之前this是不存在的。这是ES6强制规定的行为也正好是面试官判断你有没有真正理解class继承机制的好题目。4.3 静态方法的继承与new.target因为extends建立了构造函数层面的继承链所以静态方法也能被继承class Animal { static create() { return new this(); } } class Dog extends Animal { constructor(name) { super(); this.name name || unknown; } } const dog Dog.create(); // new Dog() console.log(dog instanceof Dog); // true这里的new this()中的this指向的是Dog而不是Animal这正是静态方法继承的关键当子类没有定义create时调用Dog.create()会沿着__proto__链找到Animal.create但方法内部的this仍然是Dog。new.target也是一个和继承强相关的特性。它可以让你在父类构造函数中判断是谁调用了它class Animal { constructor() { if (new.target Animal) { throw new Error(Animal不能直接实例化); } console.log(创建了一个动物); } } class Dog extends Animal {} // new Animal(); // 会抛出错误 const dog new Dog(); // 创建了一个动物在实际项目里这个特性适合用来实现抽象基类——父类不允许被直接实例化只能被继承。4.4 对象字面量的__proto__和extends的关联在ES6里对象字面量也支持直接设置__proto__const animal { eat() { console.log(eating); } }; const dog { __proto__: animal, bark() { console.log(barking); } }; dog.eat(); // eating这其实和Object.create(animal)是等价的只不过写法更直观。在理解继承时要时刻记住无论是class、extends还是Object.create底层都在操作同一条原型链只是封装的层级不同而已。5. 继承实战组件基类、错误处理与工具库设计面试题背得再熟最终要落到项目里。这里分享几个我在实际开发中使用继承的典型场景以及每个场景里的坑。5.1 业务组件里的基类抽象在做一些中后台系统时我经常会把一些逻辑相近的页面组件抽象出一个基类。比如多个列表页都有“加载数据、处理异常、翻页、刷新”的逻辑就可以抽一个BaseListclass BaseList { constructor({ api, pageSize 10 }) { this.api api; this.pageSize pageSize; this.page 1; this.list []; this.loading false; } async fetchData() { this.loading true; try { const res await this.api({ page: this.page, pageSize: this.pageSize }); this.list res.list; this.total res.total; } catch (e) { // 子类可以覆盖这个错误处理方法 this.handleError(e); } finally { this.loading false; } } handleError(e) { console.error(e); } nextPage() { if (this.page * this.pageSize this.total) return; this.page; return this.fetchData(); } } class UserList extends BaseList { constructor() { super({ api: fetchUserList }); } // 重写错误处理 handleError(e) { Message.error(用户列表加载失败请稍后重试); } }这样两个子类页面就各自拥有独立的list、page状态不会互相污染同时共用了nextPage和fetchData的逻辑。这里要特别提醒基类里的方法被设计成可以被重写的所以基类内部的this.xxx调用必须始终走this这样才能保证子类重写后生效。5.2 自定义错误类别直接继承Error做前端错误监控时自定义错误类是刚需。但是直接class MyError extends Error会有一个坑class MyError extends Error { constructor(message) { super(message); this.name MyError; } } try { throw new MyError(boom); } catch (e) { console.log(e instanceof MyError); // true console.log(e.name); // MyError }在大部分环境下这没问题但某些老版本的浏览器或转译环境下instanceof Error可能返回false并且e.stack不正确。这是因为原生Error构造函数的内部机制和普通函数有差异super(message)创建出的对象在某些引擎下可能不是Error的实例。一个通用兜底方案是手动修正原型链class MyError extends Error { constructor(message) { super(message); this.name MyError; // 修正原型链 Object.setPrototypeOf(this, new.target.prototype); } }或者退而求其次不用class直接用工厂函数创建错误对象虽然细究起来不算“继承”但胜在稳。这个场景非常考验对原型链机制的掌握程度面试问“如何自定义一个错误类”其实就是在问你能不能注意到这个边界情况。5.3 工具库里的继承 vs 组合在工具库设计里我踩过最大的坑是“滥用继承”。比如有一个Request类负责HTTP请求后来想做一个上传专用的UploadRequest继承它。听起来合理但实际上上传和普通请求的差异远比想象中大断点续传、进度回调、并发控制这些逻辑塞进子类后基类的很多方法会被废弃子类里满屏的TODO: 不需要这个。后面我重构时把UploadRequest里的公共逻辑提取成纯函数和组合工具而不是硬套继承。这里给个判断标准如果子类只需要重写父类的一到两个方法继承是合理的如果子类只想复用父类的某几个方法而打算跳过其他逻辑那就该用组合。5.4 从需求到继承实现的一个完整流程示例假设业务方提了一个需求后台有“普通用户”和“VIP用户”VIP用户有积分倍率下单时能打折其他行为都一样。先画原型关系User是基类VipUser继承User重写calculatePrice方法。class User { constructor({ name, points }) { this.name name; this.points points; } calculatePrice(price) { return price; } getInfo() { return { name: this.name, points: this.points }; } } class VipUser extends User { constructor({ name, points, discount 0.8, bonusRate 1.5 }) { super({ name, points }); this.discount discount; this.bonusRate bonusRate; } calculatePrice(price) { const total price * this.discount; this.points Math.floor(price * this.bonusRate); return total; } }这个例子里基类承担了通用的用户信息逻辑子类只负责自身差异重写calculatePrice而不动getInfo。这个模式在业务里很常见理解了它你的继承就不只是面试题而是真正能用的工具。6. 高频追问new、instanceof与Object.create的底层原理这一章我把它看作是继承八股文里的“终极大题”。前面提到面试官最后一定会考你new和instanceof的底层实现因为这两个函数把原型链、构造函数、继承串成了一个完整的闭环。6.1 手写new运算符的完整实现new做了四件事创建一个新对象。将这个新对象的原型[[Prototype]]指向构造函数的prototype。将构造函数里的this绑定到这个新对象上并执行构造函数。如果构造函数返回的是一个对象则返回该对象否则返回创建的新对象。function myNew(Constructor, ...args) { // 1. 创建新对象并设置原型 const obj Object.create(Constructor.prototype); // 2. 执行构造函数绑定this const result Constructor.apply(obj, args); // 3. 根据返回值类型决定返回什么 return (typeof result object result ! null) || typeof result function ? result : obj; } function Animal(name) { this.name name; } const dog myNew(Animal, 旺财); console.log(dog.name); // 旺财 console.log(dog instanceof Animal); // true关键在第四步。如果构造函数显式返回了一个对象那么new的结果就是这个对象而不是你创建的新对象。这种情况在第三方库里偶有出现如果你没注意可能得到意料之外的返回值。6.2 instanceof的原理与手写instanceof的操作是检查右边构造函数的prototype是否出现在左边对象的原型链上。function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); const prototype right.prototype; while (true) { if (proto null) return false; if (proto prototype) return true; proto Object.getPrototypeOf(proto); } }这里有个容易踩的坑Object.create(null)创建出来的对象没有任何原型所以myInstanceof(obj, Object)会返回false。这在做数据检测时要注意某些低代码平台里用户传进来的对象可能就是这种“干净对象”别默认所有对象都是instanceof Object。另一个相关考点是[] instanceof Object的结果是true因为数组的原型链是Array.prototype - Object.prototype。所以instanceof不能精确判断一个对象的“具体类型”这也是为什么很多人推荐用Object.prototype.toString.call()来判断内置类型。6.3 用原型链知识推断继承关系的面试模拟最后给一道综合题你可以先自己想想答案有下面这段代码class Animal { constructor(name) { this.name name; } } class Dog extends Animal { constructor(name) { super(name); } } const dog new Dog(旺财);请回答以下问题dog.__proto__ Dog.prototype的结果是什么Dog.__proto__ Animal的结果是什么Dog.prototype.__proto__ Animal.prototype的结果是什么dog instanceof Animal的结果是什么Dog instanceof Animal的结果是什么逐一分析dog.__proto__ Dog.prototypetrue。这是new操作的基础行为。Dog.__proto__ Animaltrue。这是ES6class继承特有的“构造函数层面的继承”。Dog.prototype.__proto__ Animal.prototypetrue。这是原型链继承子类原型的父类是父类原型。dog instanceof Animaltrue。因为dog的原型链能走到Animal.prototype。Dog instanceof Animaltrue。因为Dog的原型链能走到Animal这个构造函数对象。这一点容易困惑但其实跟Dog作为类被new无关它只是Animal的静态方法可以被访问的体现。把这五个问题全部答清楚了继承整个主题对你来说就再也没有盲区了。我在实际工作中感受最深的一点是继承不是背出来的而是理解出来的。把原型链、构造函数、new和instanceof这条线打通后你会发现它不只是应付面试的八股文更是平时排查问题时的一把利器。每次遇到“为什么这里访问不到方法”“为什么这个对象不是我想要的类”之类的报错绕来绕去最终都会回到原型链上。所以慢慢看、动手跑一跑例子比什么都管用。