在JavaScript的世界里,没有类(class)的传统概念——至少ES6之前是这样。我们靠的是函数和原型来实现对象的创建与继承。而其中最核心、也最容易让人困惑的部分,就是——原型继承。
今天这篇文章不讲花架子,也不堆代码。我会用你今天写的两段代码作为起点,带你一步步理解:
- 为什么
Cat.prototype = new Animal()不够好? - 为什么
Cat.prototype = Animal.prototype更危险? - 如何通过一个“空对象中介”实现安全继承?
- 真正健壮的继承机制应该长什么样?
准备好了吗?我们出发。
一、从你的第一段代码说起: 天真埋雷
这是很多初学者的第一反应写法:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
function Cat(name, age, color) {
Animal.call(this, name, age); // 借用构造函数
this.color = color;
}
// 尝试继承
Cat.prototype = new Animal();
const cat = new Cat('加菲猫', 2, '黄色');
看起来没问题?运行一下:
console.log(cat.name); // 加菲猫 ✅
console.log(cat.species); // 动物 ✅
但注意这一句:
console.log(cat.constructor); // Animal ❌
什么?我创建的是 Cat 实例,怎么构造器变成了 Animal?
问题出在哪?
因为:
Cat.prototype = new Animal();
这句话做了什么?
它创建了一个 真实的 Animal 实例 作为 Cat 的原型。这个实例虽然没有名字和年龄(都是 undefined),但它确实存在,并且它的 .constructor 指向 Animal。
更严重的是,当你后续访问 cat.__proto__.name 时,会发现它是 undefined —— 所有子类实例都共享这个“空壳父类”,一旦你在原型上添加可变属性(比如数组),就会出现数据污染!
结论:用
new Parent()来设置子类原型,会导致原型对象携带不必要的实例属性,且 constructor 错乱。
这不是真正的“干净继承”。
二、 自信引用:误入歧途
既然 new Animal() 有问题,那能不能直接把原型赋值过去?
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;
这次我们不再新建实例,而是直接复用 Animal.prototype 对象。
结果呢?
console.log(cat.species); // 动物 ✅
console.log(cat.constructor); // Cat ✅
console.log(cat instanceof Cat); // true ✅
console.log(cat instanceof Animal); // true ✅
哇!全都对了?是不是终于成了?
别急。再看看这句:
console.log(Animal.prototype.constructor);
// 输出:Cat 😱
你没看错。Animal 自己的原型构造器,也被改成 Cat 了!
为什么?
因为你写的不是“复制”,而是“引用”:
Cat.prototype = Animal.prototype;
// 两个变量指向同一个对象
所以当你执行:
Cat.prototype.constructor = Cat;
你实际上是在修改 Animal.prototype 本身!
这就像是两个人共用一张银行卡,你以为自己存钱,其实把别人的存款信息也改了。
如果项目中有多个类继承 Animal(比如 Dog、Bird),它们都会发现自己的 .constructor 被偷偷替换了 —— 后果不堪设想。
结论:直接共享 prototype 会污染父类原型,属于高危操作。
三、解决之道:引入“空对象中介”
为了避免这种污染,我们需要一个“中间人”——它能帮我们建立原型链连接,又不会影响原始父类的原型。
这就是你第二段代码的核心思想:
var F = function(){};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;
我们来拆解一下这个过程:
第一步:造一个空壳函数 F
var F = function(){};
这个 F 啥也不干,纯粹是个工具人。
第二步:让 F 的原型指向 Animal.prototype
F.prototype = Animal.prototype;
此时 F.prototype 和 Animal.prototype 是同一个对象?没错。但我们不直接操作它。
第三步:用 new F() 创建一个实例作为 Cat 的原型
Cat.prototype = new F();
关键来了!
new F() 创建了一个新对象,它的 __proto__ 指向 F.prototype,也就是 Animal.prototype。
也就是说:
Cat.prototype.__proto__ === Animal.prototype ✅
但我们 Cat.prototype 本身是一个全新的对象!你可以随意修改它,比如设置 .constructor,完全不会影响 Animal.prototype。
验证一下:
console.log(Animal.prototype.constructor); // Animal(没被改!)
console.log(cat.constructor); // Cat(正确!)
完美!
四、深入理解原型链:__proto__ 的传递关系
让我们打印看看:
console.log(cat.__proto__); // F {}
console.log(cat.__proto__.__proto__); // Animal.prototype
所以完整的查找链是这样的:
cat
→ cat.__proto__ (F 的实例)
→ cat.__proto__.__proto__ (Animal.prototype)
→ cat.__proto__.__proto__.__proto__ (Object.prototype)
→ null
每一层都有明确职责:
- 实例属性(name, age, color)在
cat自身; - 共享方法或属性(如 species)在
Animal.prototype; - 中间层
F {}只负责链接,不存储数据。
这就是典型的“寄生组合式继承”雏形。
五、封装成通用继承函数:打造你的“继承工具包”
既然这套逻辑可以复用,为什么不把它封装起来呢?
function extend(Child, Parent) {
// 创建空函数作为桥梁
var F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
使用方式超级简单:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
Animal.prototype.sayHi = function() {
console.log(`我是${this.name}`);
};
function Cat(name, age, color) {
Animal.call(this, name, age); // 借用构造函数
this.color = color;
}
extend(Cat, Animal);
const cat = new Cat('加菲猫', 2, '黄色');
cat.sayHi(); // 我是加菲猫
console.log(cat.species); // 动物
console.log(cat.constructor); // Cat
console.log(Animal.prototype.constructor); // Animal(安全!)
你看,既实现了功能,又保护了父类原型,干净利落。
六、现代JS中的替代方案:Object.create
其实上面的 extend 函数可以用更优雅的方式重写:
function extend(Child, Parent) {
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
}
Object.create(proto) 的作用正是:创建一个新对象,其 __proto__ 指向 proto。
它比 new F() 更语义化、更高效,而且不需要定义额外函数。
这也是 ES6 class 继承背后的原理之一。
七、总结:继承的本质是什么?
通过这两段代码的学习,我们可以提炼出几个关键认知:
| 问题 | 正确做法 | 错误做法 |
|---|---|---|
| 构造函数传参 | 使用 call/apply 借用父构造 | 直接调用 Animal() |
| 原型继承 | 使用 Object.create 或空函数中介 | Child.prototype = Parent.prototype |
| 修复构造器 | 显式设置 .constructor | 忽略不处理 |
| 安全性 | 不修改父类原型 | 直接修改共享原型 |
🔑 核心原则:继承要“连得上”,但不能“粘在一起”。
七、延伸思考:为什么现在都用 class?
你可能会问:“现在都用 class 了,还学这些干嘛?”
因为 class 只是语法糖,底层依然是基于原型的机制。不了解这些原理,遇到以下问题你会束手无策:
- 为什么子类 constructor 必须调用
super()? - 为什么静态方法也能继承?
- 如何手动模拟 class 的继承行为?
掌握原型继承,就像懂汇编的语言者看高级语言——看得透,写得稳。
结语:前端进阶的路上,没有捷径
你今天写的这两段代码,可能只是课堂笔记,也可能只是面试题练习。但我希望你能从中看到更多:
每一次对
.prototype的谨慎操作,都是对语言设计哲学的理解加深;
每一次对继承模式的优化,都是工程思维的成长。
别小看这些“老掉牙”的知识。它们像地基一样,撑起了整个前端世界的大厦。
共勉。
📌 如果你觉得这篇文章有收获,请点赞 + 收藏 + 关注,让更多人看到真正有价值的前端内容。
💬 欢迎在评论区分享你对原型继承的理解,或者提出疑问,我们一起探讨!
参考资料:
- 《JavaScript高级程序设计》第6章
- 《你不知道的JavaScript(上卷)》
- MDN: Object.create()