“继承”这道菜,你还在直接上手?JS 原型链的正确打开方式
在前端工程师的面试中,JavaScript 的继承机制几乎是必考题。无论是阿里 P7 的“手写继承”,还是字节跳动的“原型链深度解析”,都离不开对 prototype、__proto__、构造函数调用等核心概念的理解。
今天,我们就从一段看似“能跑就行”的继承代码出发,一步步拆解 JavaScript 继承的本质,并深入探讨:为什么很多人的继承写法其实存在严重隐患?如何写出既安全又可维护的继承结构?以及,大厂面试官到底想考察什么?
一、问题引入:你以为的“继承”,其实是“共享污染”
先看第一段代码:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
function Cat(name, age, color) {
Animal.apply(this, [name, age]);
}
Cat.prototype = Animal.prototype; // ⚠️ 危险操作!
Cat.prototype.constructor = Cat;
const cat = new Cat('加菲猫', 2, '黄色');
这段代码看起来实现了继承:Cat 能访问 species,也能通过 apply 调用父类构造函数初始化属性。
但问题出在这一行:
Cat.prototype = Animal.prototype;
❌ 错误点:直接赋值导致原型共享
这意味着 Cat.prototype 和 Animal.prototype 指向同一个对象—— 即 Animal 函数的原型对象(Animal.prototype 本身)。一旦你在 Cat.prototype 上添加方法(比如 Cat.prototype.meow = ...),Animal 的所有实例也会拥有这个方法!
Cat.prototype.say = function() { console.log('喵喵喵'); };
const dog = new Animal('旺财', 3);
dog.say(); // 🤯 居然能调用!这不是狗该有的技能啊!
这违背了继承的基本原则:子类扩展不应影响父类。
✅ 大厂面试高频考点:
“请指出Child.prototype = Parent.prototype的问题,并给出正确写法。”
二、进阶方案:空函数中介 —— 隔离原型链污染
为了解决上述问题,第二、三段代码引入了一个经典技巧:使用空函数作为中介。
var F = function () {};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;
🔍 原理剖析:为什么 new F() 能隔离?
-
F是一个空构造函数,不执行任何逻辑。 -
F.prototype = Animal.prototype:让F的原型指向Animal.prototype。 -
new F()创建一个新对象,其__proto__指向Animal.prototype,但自身是一个独立对象。 -
因此,
Cat.prototype = new F()后:cat instanceof Cat→ truecat instanceof Animal→ true- 在
Cat.prototype上添加方法,不会污染Animal.prototype
这就是经典的 “寄生组合式继承” 的雏形。这种方式是寄生组合式继承中用于安全设置原型链的关键技巧。
🔍 延伸知识:
Object.createvs 空函数 —— 以及对象与函数的本质差异 ES5 引入了Object.create(proto)方法,让我们能更语义化地创建一个以指定对象为原型的新对象。因此,我们可以用:
Cat.prototype = Object.create(Animal.prototype);
替代传统的空函数中介写法(
new F()),不仅代码更简洁,意图也更明确: “我要一个新对象,它的原型是Animal.prototype” 。但这里有一个常被忽略的底层细节:为什么早期要用“空函数”而不是直接用普通对象?这背后其实涉及 JavaScript 中“对象”和“函数”的根本区别 —— 它们的原型链起点不同。
🧬 关键点:Object 和 Function 的原型不一样
在 JavaScript 中:
- 所有普通对象(如
{}、new Object())的原型是Object.prototype。- 所有函数(包括构造函数、箭头函数等)的原型是
Function.prototype,而Function.prototype本身又继承自Object.prototype。
这意味着:
({}).__proto__ === Object.prototype; // true
(function(){}).__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
当我们使用
new F()创建实例时,虽然F是空函数,但new F()返回的是一个普通对象(其__proto__指向F.prototype)。而F.prototype被我们手动设置为Animal.prototype,于是这个新对象就“挂”在了正确的原型链上。如果直接用字面量对象
{}或Object.create(...),虽然也能达到类似效果,但必须确保新对象的constructor被正确重置,否则会丢失构造器信息。💡 补充:
Object.create(null)会创建一个完全没有原型的对象(即__proto__为null),常用于实现纯净的哈希表(如缓存、字典),避免hasOwnProperty或toString等原型方法造成干扰。
✅ 最佳实践建议
现代开发中,优先使用 Object.create 实现原型继承:
function extend(Parent, Child) {
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修复 constructor
}
它既避免了手动定义空函数的冗余,又清晰表达了“基于原型创建”的意图,同时天然规避了函数与对象原型差异带来的潜在混淆。
通过理解“object 和 function 的原型不一样”,我们不仅能写出更安全的继承代码,还能深入把握 JavaScript 对象模型的设计哲学:万物皆对象,但函数是特殊的对象。
三、封装成通用函数:写出可复用的继承工具
第四段代码将上述逻辑封装为 extend 函数:
function extend(Parent, Child) {
var F = new Function(); // 等价于 function(){}
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
✅ 优点:
- 复用性强
- 隔离原型污染
- 修复
constructor指向(否则cat.constructor === Animal)
📌 设计模式视角:
这种“中介对象”思想,本质上是 代理模式(Proxy) 或 桥接模式(Bridge) 的体现——通过中间层解耦两个对象的直接依赖。
四、属性遮蔽(Shadowing):你改的是实例属性,不是原型!
第三段独立代码揭示了一个重要概念:
function Cat() {}
Cat.prototype.species = '猫科动物';
const cat = new Cat();
cat.species = 'hello'; // 👈 注意!这是给实例加属性
console.log(cat.species); // 'hello'
console.log(Cat.prototype.species); // '猫科动物'(未变)
🔑 关键点:属性查找顺序
当访问 cat.species 时,JS 引擎按以下顺序查找:
- 实例自身属性(
cat.hasOwnProperty('species')→ true) cat.__proto__(即Cat.prototype)Cat.prototype.__proto__(即Object.prototype)- ……直到
null
因此,给实例赋值不会修改原型,而是“遮蔽(shadow)”了原型上的同名属性。
💡 面试加分项:
如果被问“如何真正修改原型上的属性?”,回答:Cat.prototype.species = '新值'; // 或 Object.getPrototypeOf(cat).species = '新值';
五、现代 JavaScript 的继承:class 语法糖下的真相
以上代码基于 ES5 构造函数,但必须提一句:ES6 的 class 本质仍是原型继承。
class Animal {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
Animal.prototype.species = '动物';
class Cat extends Animal {
constructor(name, age, color) {
super(name, age);
this.color = color;
}
eat() { console.log("eat jerry"); }
}
现代 JavaScript 引擎和 Babel 在实现 class extends 时,底层仍基于 Object.create 或 Object.setPrototypeOf 来链接原型链,其思想与我们手动实现的寄生组合继承一脉相承。包括使用 _inherits 工具函数实现原型链链接。
✅ 结论:理解 ES5 继承,是掌握 ES6 class 的基石。
六、高频面试题 & 总结
🧠 面试官可能追问:
-
组合继承 vs 寄生组合继承的区别?
- 组合继承:
Child.prototype = new Parent()→ 会调用两次父类构造函数(一次在设置原型时,一次在子类构造中)。 - 寄生组合继承:通过空函数中介,只调用一次,最优解。
- 组合继承:
-
如何判断一个对象是否继承自某个构造函数?
instanceofObject.prototype.isPrototypeOf()constructor(不可靠,易被覆盖)
-
原型链过长会影响性能吗?
- 会!属性查找是线性遍历,深度越大越慢。合理设计继承层级。
结语:继承不是“复制”,而是“委托”
JavaScript 的继承本质是基于原型的委托机制,而非类的拷贝。理解 __proto__ 与 prototype 的关系,掌握安全的继承写法,不仅能写出健壮代码,更能从容应对大厂面试中的“原型链连环问”。
记住:
直接赋值原型 = 共享污染
空函数中介 = 安全隔离
属性赋值 = 实例遮蔽,非原型修改
下次写继承时,别再“直接上手”了——用对方法,才能写出经得起 review 的代码。