JavaScript原型继承的“道”与“术”:从基础到安全继承模式

16 阅读6分钟

在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.prototypeAnimal.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()