1. 从原型链开始:继承的真正底层逻辑
1.1 为什么JS继承比Java难理解
很多前端小白初学JS继承时,第一个感受就是:为什么这么绕?Java里一个extends关键字就搞定了,JS却要弄出好几种写法,还涉及prototype、__proto__、constructor这些概念。
这个困惑很正常,因为JS继承的底层逻辑和Java这类基于类的语言完全不是一个路数。Java的继承发生在“类”层面,类是模板,对象是模板的实例,继承就是模板之间的复制粘贴。而JS在设计之初并没有“类”这个概念,它只有对象。继承的本质是对象和对象之间的关系,一个对象能“借用”另一个对象的属性和方法。
用生活化的例子来理解:Java的继承像工厂里的模具,模具定了,所有产品都按模具来,产品之间彼此独立。JS的继承更像在职场上认师傅,你处理不了的需求转交给师傅,师傅处理不了再转给他的师傅,方法就沿着这条“师傅链”一级级往上找。
明白了这个底层差异,再回头看各种继承写法,就不觉得是死记硬背了——它们其实都是在用不同方式,去建立这条“对象找对象借东西”的链路。
1.2 一条原型链带你看清__proto__与prototype
先搞清楚两个最核心的概念:prototype和__proto__。
prototype是“函数独有的属性”,只有函数对象才有,它指向一个对象,这个对象会被作为原型,用来给通过new创建出来的实例共享属性和方法。__proto__是“对象独有的属性”,每个对象都有(除了Object.create(null)创建的),它指向创建这个对象的函数的prototype。
听起来还是抽象,看一段代码:
javascript复制function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log('你好,我是' + this.name);
};
const p = new Person('张三');
console.log(p.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(p instanceof Person); // true
这段代码就是一个最简单的原型链关系:实例p的__proto__指向构造函数Person的prototype,而Person.prototype的constructor指回Person自身。
当访问p.sayHello()时,JS引擎先看p自身有没有sayHello,没有的话就顺着p.__proto__找到Person.prototype,再没有就继续往上找到Object.prototype,直到null为止。
这条路就是“原型链”。你访问一个对象不存在的属性时,JS不会直接报undefined,而是会沿着这条链一路找上去,找到就用,找不到才返回undefined。
1.3 原型链到底在解决什么问题
理解原型链之后,继承的所有写法都变得透明了。JS继承的本质,就是让一个对象能通过__proto__这条链,访问到另一个对象上的属性或方法。
举个例子,你想创建一个“学生”对象,希望它拥有“人”对象的能力,同时又要有自己独有的方法。聪明的做法不是把“人”的方法全部复制到“学生”身上,而是直接让“学生”的__proto__指向“人”的原型对象。这样“学生”天生就能访问“人”的方法,还不用重复占用内存。
这也是为什么原型链继承最初看起来很简单,但实际写起来会踩坑的原因——它的设计思路对,但实现方式如果不注意,会导致共享属性被意外修改、实例无法正确初始化等一系列问题。接下来的几种经典写法,本质上都是对“如何优雅地建立这条链”的探索。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种经典继承写法逐个拆解
2.1 原型链继承:最接近本质的写法
第一种要聊的写法是最直接的原型链继承,代码长这样:
javascript复制function Animal(name) {
this.name = name;
this.colors = ['白色', '黑色'];
}
Animal.prototype.getName = function() {
return this.name;
};
function Dog(name) {
this.name = name;
}
// 核心:让Dog.prototype指向Animal的实例
Dog.prototype = new Animal();
const dog1 = new Dog('旺财');
const dog2 = new Dog('大黄');
console.log(dog1.getName()); // 旺财
console.log(dog2.getName()); // 大黄
原理很简单:Dog.prototype被替换成了Animal的一个实例,那么dog1.__proto__指向这个实例,这个实例的__proto__又指向Animal.prototype,于是getName方法就被dog1间接访问到了。
但问题也随之而来,最典型的就是引用类型属性被所有实例共享:
javascript复制dog1.colors.push('棕色');
console.log(dog2.colors); // ['白色', '黑色', '棕色']
colors是数组,属于引用类型。在原型链继承里,dog1和dog2的colors指向的是Animal实例上同一个数组。dog1改了,dog2也变。这在真实业务场景里是致命的——你改了一个实例的数据,其他实例全被污染。
另一个问题是Dog.prototype.constructor被篡改了。因为整个Dog.prototype被赋值为new Animal(),它的constructor指向的是Animal而不是Dog,如果后续代码依赖constructor做类型判断,就会出现诡异的结果。
这也是我见过很多新手前端一遍遍调试却找不到原因的地方,问题根源不在业务逻辑,而在原型链的指向关系。
2.2 构造函数继承:把this借过来用
既然原型链继承存在共享引用问题,自然有人想到另一个方向,既然属性是实例自己的,那干脆在子构造函数里直接调用父构造函数,把父类的this绑定到子类的实例上。这就是构造函数继承:
javascript复制function Animal(name) {
this.name = name;
this.colors = ['白色', '黑色'];
}
Animal.prototype.getName = function() {
return this.name;
};
function Dog(name) {
Animal.call(this, name); // 核心:借用父构造函数
this.breed = '金毛';
}
const dog1 = new Dog('旺财');
dog1.colors.push('棕色');
const dog2 = new Dog('大黄');
console.log(dog2.colors); // ['白色', '黑色']
Animal.call(this, name)这行代码很关键,它相当于把Animal函数体内的逻辑“借”到当前Dog实例上执行了一遍。这样每个Dog实例都会创建属于自己的colors数组,互不干扰,引用类型属性共享的问题解决了。
但构造函数继承也有遗憾——方法无法复用。父类原型上的方法(比如getName)在子类实例上根本访问不到,因为dog1.__proto__指向的是Dog.prototype,而Dog.prototype并没有指向Animal.prototype。如果硬要在子类里用父类的方法,只能把所有方法都写在构造函数体内,每个实例都会复制一份函数,内存开销变大,而且违背了“方法共享”的初衷。
所以当时的做法经常是:构造函数继承解决属性问题,原型链继承解决方法问题,两者各有短板,那能不能合起来用?
2.3 组合继承:原型链+构造函数各取一半
组合继承是目前ES5时代最经典、也是我认为面试中最值得背下来的继承写法。它把两种思想做了叠加:用构造函数继承解决属性初始化问题,用原型链继承解决方法复用问题。
javascript复制function Animal(name) {
this.name = name;
this.colors = ['白色', '黑色'];
}
Animal.prototype.getName = function() {
return this.name;
};
function Dog(name) {
Animal.call(this, name); // 第一次调用Animal
this.breed = '金毛';
}
Dog.prototype = new Animal(); // 第二次调用Animal
Dog.prototype.constructor = Dog; // 修复constructor指向
Dog.prototype.getBreed = function() {
return this.breed;
};
const dog1 = new Dog('旺财');
dog1.colors.push('棕色');
const dog2 = new Dog('大黄');
console.log(dog2.colors); // ['白色', '黑色']
console.log(dog1.getName()); // 旺财
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
这个写法里,Animal其实被调用了两次。第一次是Animal.call(this, name),把父类的实例属性初始化到当前实例上;第二次是new Animal(),把父类实例赋值给Dog.prototype,用于继承父类原型上的方法。
结果也很清晰:每个Dog实例都有自己的name和colors,同时又能访问Animal.prototype上的方法,instanceof判断也正常。
但“两次调用父构造函数”这个设计也意味着冗余——父类原型上被添加的name和colors属性,其实在Dog.prototype上永远不会被用到,因为每个实例自己身上都有了一份,它们只是白白挂在原型上占着位置。
2.4 寄生组合继承:面试中“最佳方案”的真相
寄生组合继承是ES5时代各种折腾之后的集大成方案。它解决了组合继承“父构造函数调用两次”的冗余问题,思路很巧妙:不用new Animal()来设置原型,而是创建一个继承了Animal.prototype的对象作为Dog.prototype。
javascript复制function Animal(name) {
this.name = name;
this.colors = ['白色', '黑色'];
}
Animal.prototype.getName = function() {
return this.name;
};
function Dog(name) {
Animal.call(this, name); // 只调用一次Animal
this.breed = '金毛';
}
// 核心:用Object.create创建一个以Animal.prototype为原型的对象
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.getBreed = function() {
return this.breed;
};
Object.create(Animal.prototype)做了一件很干净的事:创建一个空对象,让这个空对象的__proto__指向Animal.prototype,然后把这个空对象作为Dog.prototype。
这样既不会像原型链继承那样在Dog.prototype上残留父类实例的属性,也不会像组合继承那样调用两次构造函数。dog1 instanceof Animal依然是true,方法也能正常访问。
如果你去面试时被问到“ES5里怎么实现继承最优”,回答寄生组合继承基本不会出错。ES6的class和extends底层,本质上就是寄生组合继承的语法糖。
为了让大家更直观地感受这几种写法的差异,我整理了一张对比表:
| 写法 | 引用类型属性共享问题 | 方法复用 | 父构造函数调用次数 | 构造器指向 |
|---|---|---|---|---|
| 原型链继承 | 会共享,有bug | 支持 | 1次 | 被篡改,需手动修复 |
| 构造函数继承 | 不共享 | 不支持,方法无法复用 | 1次 | 正常 |
| 组合继承 | 不共享 | 支持 | 2次,有冗余 | 需手动修复 |
| 寄生组合继承 | 不共享 | 支持 | 1次 | 需手动修复 |
这张表看明白之后,ES5继承的考法基本就没什么能难住你了。
3. ES6的Class继承:语法糖背后的原理
3.1 类继承基本写法
ES6的class和extends让JS继承的写法一下子清爽了很多:
javascript复制class Animal {
constructor(name) {
this.name = name;
this.colors = ['白色', '黑色'];
}
getName() {
return this.name;
}
}
class Dog extends Animal {
constructor(name) {
super(name); // 相当于ES5的Animal.call(this, name)
this.breed = '金毛';
}
getBreed() {
return this.breed;
}
}
const dog1 = new Dog('旺财');
dog1.colors.push('棕色');
const dog2 = new Dog('大黄');
console.log(dog2.colors); // ['白色', '黑色']
console.log(dog1.getName()); // 旺财
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
这段代码看起来和Java差不多了,但请注意,它只是语法糖,底层依然是原型链那一套。Dog内部依然有一个prototype属性,dog1.__proto__依然指向Dog.prototype,Dog.prototype.__proto__依然指向Animal.prototype。
3.2 extends底层帮你做了哪些事
extends帮你做的最重要一件事,就是自动建立原型链关系。在ES5的寄生组合继承里,我们需要手动写这三行:
javascript复制Animal.call(this, name);
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
而在class语法里,三件事都被内置了。super(name)对应Animal.call(this, name),extends自动完成原型链的指向,constructor的指向也会自动修正。
这也是为什么class写法更不容易出错——它把之前ES5里容易踩坑的细节全部封装起来了。但面试时绝对不可以说“class继承跟Java一样”,因为面试官下一句通常会追问:class继承和ES5的继承相比,有没有本质区别?
3.3 super的调用顺序为什么不能乱
class继承里有一条硬性规则:在子类的构造函数中,super()必须在使用this之前调用,否则会报错。
javascript复制class Dog extends Animal {
constructor(name) {
this.breed = '金毛'; // 报错:Must call super constructor in derived class before accessing 'this'
super(name);
}
}
报错的原因和ES5的继承机制差异有关。在ES5里,new Dog()会先执行Dog构造函数创建一个this,再借用Animal给这个this添加属性。而ES6的class机制是先执行父类的构造函数创建出this,子类构造函数拿到这个this之后再做个性化处理。
所以super必须调用,而且必须在你访问this之前调用,因为它就是“创建this”的那一步。理解了这个机制,就明白为什么顺序不能乱,也理解了为什么在class子类里写super()和写this的顺序是硬性要求而不是个人风格问题。
再展开一个容易忽略的点:即使子类没有显式写constructor,extends也会自动补一个默认构造函数,它内部会调用super(...args),把参数原样传给父类。所以在简化写法里你看到的“子类没写构造函数,却照样能初始化父类的属性”,底层就是这套自动补全机制在起作用。
4. 面试中被反复追问的细节:这些区别必须说清楚
4.1 实例、构造函数、原型三者的指向关系
面试中关于继承的高频追问,很多都围绕着“谁指向谁”展开。基础结论有三条:
- 每个构造函数都有一个
prototype属性,指向它的原型对象。 - 这个原型对象有一个
constructor属性,指回构造函数。 - 每个实例都有一个
__proto__属性,指向构造函数的prototype。
画成代码就是从三者的相互引用:
javascript复制function Person() {}
const person = new Person();
// 三条核心指向关系
person.__proto__ === Person.prototype; // 实例指向原型
Person.prototype.constructor === Person; // 原型指回构造函数
person.__proto__.constructor === Person; // 通过实例也能追溯到构造函数
记住这个三角关系之后,再遇到xxx.prototype、xxx.__proto__、xxx.constructor之间的比较题,套进去就行。但这里有一个容易踩的坑:Function.prototype和Object.prototype之间的指向关系。Function的prototype是一个函数,这个函数的__proto__指向Object.prototype,所以函数也能调用对象上的方法,比如toString、hasOwnProperty。这条链走下去,最终所有对象都汇聚到Object.prototype,再往上就是null,这也是原型链的终点。
4.2 instanceof的原理与局限性
instanceof的底层逻辑很简单:沿着左操作数的原型链一路向上找,看能不能找到右操作数的prototype对象。
javascript复制dog1 instanceof Dog; // true,因为dog1.__proto__ --> Dog.prototype
dog1 instanceof Animal; // true,因为dog1.__proto__.__proto__ --> Animal.prototype
dog1 instanceof Object; // true,因为再往上是Object.prototype
但它有两个容易忽略的局限性。
第一个局限性:instanceof只能检查原型链关系,不能检查“某个对象是否真的来自某个构造函数”。如果通过Object.create(Animal.prototype)创建了一个对象,它instanceof Animal返回true,但它其实从来没有执行过Animal的构造函数,没有name属性。
第二个局限性:原型可以随时被替换。如果代码中途执行了Dog.prototype = {},之前创建的dog1实例的__proto__仍然指向旧的原型对象,此时dog1 instanceof Dog会返回false。这种问题在大型项目中不好排查,因为代码运行久了,各种动态修改很容易让原型链“断掉”。
所以面试时回答instanceof,不只是说它的用途,还要补一句“它检查的是原型链上的引用关系,而不是值的来源”,这句话就是加分项。
4.3 继承和组合:什么时候不该“硬继承”
继承不是放之四海而皆准的银弹。面试官有时会问“如果两个业务场景有重叠,你会选择继承还是组合”,这个问题没有标准答案,但有判断依据。
继承适合的场景是“is-a”关系。狗是一种动物,学生是一个人,这种关系用继承来表达是自然的。
组合适合的场景是“has-a”或者“能做什么”的关系。司机有驾驶能力,打印机有打印功能,这些用函数组合或者混入(mixin)来组织更干净。
javascript复制// 组合思路:把能力拆成独立的方法,按需混入
const canEat = {
eat() {
console.log(this.name + '吃东西');
}
};
const canSleep = {
sleep() {
console.log(this.name + '睡觉');
}
};
function Dog(name) {
this.name = name;
}
Object.assign(Dog.prototype, canEat, canSleep);
const dog = new Dog('旺财');
dog.eat(); // 旺财吃东西
dog.sleep(); // 旺财睡觉
这个例子里,Dog通过Object.assign把多个能力对象的方法复制到原型上,组合出自己需要的行为。这种思路避免了多层继承带来的继承链臃肿问题,也让代码更容易测试、更容易修改。
真实项目里,我的经验是:先考虑组合,除非有非常明确的“is-a”关系,再考虑继承。这个原则说起来简单,但在设计阶段很容易被“代码相似就想继承”的冲动带偏。一个判断技巧是,你在写继承之前先问自己:如果父类要改一个方法,子类真的都希望跟着改吗?如果有一半的子类会受影响,那这个继承关系大概率设计得不好。
5. 从面试官视角看:这些写法怎么答最加分
5.1 面试官真正想听到的回答结构
每年我都要面试不少前端候选人,问“JS继承有哪几种写法”时,得到的最多回答是背课文式的:原型链继承、构造函数继承、组合继承,然后一句“组合继承最好”。
这种回答能及格,但拿不到高分。面试官真正想听到的答案,是带着比较和判断的完整逻辑:
先说结论:“JS继承的核心是原型链,所有写法都是围绕__proto__的指向做文章。”
再逐个展开,每个写法都讲两点——核心代码和最大的坑。比如原型链继承要讲引用类型共享的问题,构造函数继承要讲方法无法复用的问题,组合继承要讲两次调用构造函数的问题,寄生组合继承要讲Object.create如何避免冗余,class要讲super和this的调用顺序。
最后补一句关键总结:“实际开发中我不会手写ES5继承,直接用class + extends就够了,但面试题考ES5继承,本质是考察原型链功底以及对语言底层的理解。”
这个回答结构里,你展现的不是死记硬背,而是对每种方案取舍的理解。面试官问继承,表面上考语法,实际上考的是你有没有意识到“写法只是手段,原型链才是本质”。只要抓住这条主线,怎么问都不会跑偏。
5.2 附加题:原型链上的this到底是什么
这是我在面试中比较喜欢抛的一个追问,因为它能真正区分“背了题”和“理解了”。
javascript复制const obj = {
name: '原型上的name',
getName() {
console.log(this.name);
}
};
const child = Object.create(obj);
child.name = '实例上的name';
child.getName(); // 实例上的name
getName定义在obj上,但调用时this指向调用者child,所以输出的是child.name。
这个机制叫“this运行时绑定”。原型链决定了方法从哪里找,但this的值取决于调用时的上下文,与方法定义的位置无关。JS引擎通过原型链找到方法,然后把this设置为当前调用的对象,再执行方法体。
理解这个点之后,很多“为什么子类实例能共享父类方法但又不互相干扰”的疑问就迎刃而解。父类方法定义了一份,但每次通过不同实例调用时,this都指向当前实例,所以访问的属性都是当前实例自己的。
面试时如果能把“原型链解决的是方法查找问题,this解决的是方法执行时的上下文问题”这句话说出来,就已经把这个知识点讲到比较深了。
5.3 几个常见面试追问的应对思路
除了基础写法,面试官还经常围绕继承出一些变体题,这里列几个我实际问过的,以及我认为比较稳的回答方向。
第一道:new一个对象到底做了什么?
回答要点:创建一个空对象,设置它的__proto__指向构造函数的prototype,执行构造函数并绑定this,如果构造函数返回了对象则返回该对象,否则返回新创建的对象。可以现场手写一个简易版new来验证自己理解到位。
第二道:Object.create和new有什么区别?
回答要点:Object.create不需要执行构造函数,直接指定一个对象作为新对象的原型。new则必须执行一遍构造函数来初始化实例。因此Object.create更轻量,适合做原型链继承这种不需要实例属性的场景。
第三道:如何实现多重继承?
回答要点:JS本身不支持多重继承,但可以用混入或者组合。ES6的class继承是单继承,可以使用Object.assign把多个对象的方法合并到类原型上,实现类似效果。这个回答要顺手点明“继承表达了is-a关系,多重继承会带来歧义,混入更安全”。
这些问题都不难,关键是提前准备好回答框架。面试本质上是一个沟通场景,你的答案要有逻辑、有层次,而不是简单罗列点。同一个知识点,用“第一第二第三”的方式讲出来,和用“因为,所以,举个例证”的方式讲出来,给面试官的印象是完全不同的。
我自己带新人的时候经常说一句话:学JS继承,不要只背写法,要问自己三个问题——这种写法解决了什么问题?留下了什么问题?为什么会有下一个写法?三个问题想明白,JS继承的所有知识点就串成了一条线,再也不用担心面试被问倒。
