JavaScript 面向对象编程:从原型到继承的深度解析
在 JavaScript 的世界里,一切皆对象。但它的“面向对象”方式却与众不同——没有类的束缚,只有灵活的原型链。本文将带你深入理解 JS 面向对象的本质,从对象创建到继承实现,层层剖析,助你打通 OOP 的任督二脉。
🔍 为什么说 JavaScript 的 OOP 很特别?
在 Java 或 C++ 这样的传统语言中,我们通过 class 定义模板,再用 new 创建实例,是一种典型的 类式继承(Class-based Inheritance)。
而 JavaScript 是一门 基于原型(Prototype-based) 的语言。它没有真正的“类”,只有对象之间的委托关系 —— 即通过 原型链(Prototype Chain) 实现属性查找和方法共享。
📌 关键点:ES6 的 class 只是语法糖,底层仍是原型机制。
这使得 JavaScript 的 OOP 更加动态、灵活,但也更容易让人困惑。要真正掌握它,我们必须回到“原型”这个核心概念上来。
🏗️ 第一步:如何创建一个对象?
方式一:对象字面量(适合单例)
const cat1 = {
name: "加菲猫",
color: "橘色"
};
简单直观,但无法复用,不适合批量创建。
方式二:构造函数模式(实现封装)
function Cat(name, color) {
this.name = name;
this.color = color;
}
const cat1 = new Cat("加菲猫", "橘色");
const cat2 = new Cat("黑猫警长", "黑色");
✅ 使用 new 关键字时发生了什么?
JavaScript 引擎会自动完成以下四步:
- 创建一个新的空对象
{}; - 将构造函数内部的
this指向该新对象; - 执行构造函数体内的代码,为对象添加属性;
- 返回这个新对象(除非构造函数显式返回其他对象)。
🧠 这个过程实现了“封装”——把创建对象的逻辑集中起来,避免重复。
⚠️ 构造函数的问题:方法重复浪费内存
如果我们给每个猫都加上 eat() 方法:
function Cat(name, color) {
this.name = name;
this.color = color;
this.eat = function() {
console.log(this.name + " 喜欢吃 jerry!");
};
}
那么每创建一个实例,都会重新生成一个 eat 函数,造成内存浪费!
cat1.eat !== cat2.eat; // true → 两个不同的函数副本
💡 解决方案:让所有实例 共享同一个方法 —— 这就是 原型(prototype) 的用武之地。
🌀 原型机制:共享的秘密武器
什么是 prototype?
每个函数都有一个 prototype 属性,指向一个对象(称为“原型对象”),该对象中的属性和方法可以被所有实例共享。
function Cat(name, color) {
this.name = name;
this.color = color;
}
// 共享的方法放在这里
Cat.prototype.eat = function() {
console.log(this.name + " 喜欢吃 jerry!");
};
Cat.prototype.type = "猫科动物";
此时:
const cat1 = new Cat("加菲猫", "橘色");
const cat2 = new Cat("黑猫警长", "黑色");
cat1.eat === cat2.eat; // true → 同一个函数引用
console.log(cat1.type); // 猫科动物(来自原型)
🧩 图解:构造函数、实例与原型的关系
graph TD
A[Cat 构造函数] -->|prototype| B(Cat.prototype 对象)
B -->|constructor| A
C[cat1 实例] -->|__proto__| B
D[cat2 实例] -->|__proto__| B
🔹
Cat.prototype是原型对象
🔹 所有由new Cat()创建的实例,其__proto__都指向Cat.prototype
🔹constructor属性反向指向构造函数本身
💡 注意:现代开发中应避免直接操作
__proto__,它是非标准但广泛支持的属性。推荐使用Object.getPrototypeOf(obj)获取原型。
🔍 原型链:属性查找的路径
当你访问 cat1.name,JS 引擎按如下顺序查找:
- 先看
cat1自身有没有name - 没有?就去
cat1.__proto__(即Cat.prototype)找 - 还没有?继续往上找
Cat.prototype.__proto__(即Object.prototype) - 最终到达
null,停止搜索
这就是 原型链(Prototype Chain)
📊 属性查找流程图
flowchart LR
Start[开始访问 obj.prop] --> HasOwn{obj 有 prop?}
HasOwn -- 是 --> Return[返回值]
HasOwn -- 否 --> HasProto{存在 __proto__?}
HasProto -- 否 --> NotFound[undefined]
HasProto -- 是 --> CheckProto[检查 __proto__ 上是否有 prop]
CheckProto --> HasOwn
如何判断属性在哪一层?
| 方法 | 说明 |
|---|---|
obj.hasOwnProperty('prop') | 是否为自身属性(不包括原型) |
'prop' in obj | 是否存在于自身或原型链中 |
Cat.prototype.isPrototypeOf(obj) | 判断某对象是否是另一个对象的原型 |
console.log(cat1.hasOwnProperty('name')); // true → 自身属性
console.log(cat1.hasOwnProperty('type')); // false → 来自原型
console.log('type' in cat1); // true → 存在于原型链
console.log(Cat.prototype.isPrototypeOf(cat1)); // true
🧬 继承:子类复用父类的能力
JavaScript 中的继承,本质上是 原型链的连接。常见的实现方式有多种。
方式一:构造函数绑定(仅继承属性)
利用 call / apply 改变 this 指向,在子类中调用父类构造函数。
function Animal(species) {
this.species = species || '动物';
}
function Cat(name, color, species) {
Animal.call(this, species); // 绑定 this,继承属性
this.name = name;
this.color = color;
}
const cat1 = new Cat("加菲猫", "橘色");
console.log(cat1.species); // 动物
✅ 优点:能正确继承父类实例属性
❌ 缺点:无法继承父类原型上的方法
方式二:原型链继承(继承方法)
将子类的原型设置为父类的一个实例。
function Animal() {
this.species = '动物';
}
Animal.prototype.sayHi = function() {
console.log('哪哪哪啦~');
};
function Cat(name, color) {
this.name = name;
this.color = color;
}
// 核心:继承整个原型链
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向
const cat1 = new Cat("加菲猫", "橘色");
cat1.sayHi(); // 哪哪哪啦~(成功继承!)
⚠️ 注意:
Cat.prototype = new Animal()会导致父类构造函数被提前执行- 必须手动修复
constructor,否则cat1.constructor === Animal
方式三:组合继承(最常用)
结合前两种方式,既继承属性,又继承方法。
function Animal(species) {
this.species = species || '动物';
}
Animal.prototype.sayHi = function() {
console.log('哪哪哪啦~');
};
function Cat(name, color, species) {
Animal.call(this, species); // 继承属性(第二次调用 Animal)
this.name = name;
this.color = color;
}
Cat.prototype = new Animal(); // 继承方法(第一次调用 Animal)
Cat.prototype.constructor = Cat;
const cat1 = new Cat("加菲猫", "橘色");
✅ 优点:功能完整,兼容性好
⚠️ 缺点:父类构造函数被执行了两次(性能略差)
方式四:寄生组合式继承(终极方案)
解决“调用两次构造函数”的问题,是目前最高效的继承方式。
function inherit(Child, Parent) {
// 创建一个干净的对象,其原型为 Parent.prototype
const F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
// 使用
inherit(Cat, Animal);
或者更现代的方式(ES5+):
Object.setPrototypeOf(Child.prototype, Parent.prototype);
// 或
Child.prototype.__proto__ = Parent.prototype;
但这不是重点。重点是:ES6 的 class 已经帮你做好了一切。
✨ ES6 Class:优雅的语法糖
虽然底层还是原型,但 class 让代码更清晰、易读、易维护。
class Animal {
constructor(species = '动物') {
this.species = species;
}
sayHi() {
console.log('哪哪哪啦~');
}
}
class Cat extends Animal {
constructor(name, color, species) {
super(species); // 调用父类 constructor
this.name = name;
this.color = color;
}
eat() {
console.log(`${this.name} 喜欢吃 jerry`);
}
}
const cat1 = new Cat("加菲猫", "橘色");
cat1.sayHi(); // 哪哪哪啦~
cat1.eat(); // 加菲猫 喜欢吃 jerry
🔧 extends 背后做了什么?
classDiagram
class Animal {
+species: string
+sayHi()
}
class Cat {
+name: string
+color: string
+eat()
}
Cat --|> Animal : extends
✅ 编译后等价于:
Cat.prototype.__proto__ = Animal.prototypeCat.__proto__ = Animalsuper指向父类构造函数
🎯 总结:
class不是新机制,而是对原型系统的良好封装,极大提升了开发效率。
📚 总结:JavaScript 面向对象的核心思想
| 概念 | 说明 |
|---|---|
| 构造函数 | 用于初始化实例属性,相当于“类”的定义入口 |
| prototype | 实现方法共享,避免内存浪费 |
| proto / [[Prototype]] | 实例指向原型的链接,构成原型链 |
| 原型链 | 属性查找的路径,也是继承的基础 |
| 继承本质 | 修改原型链,使子类能访问父类的属性和方法 |
| class 是语法糖 | 提供更友好的写法,底层仍依赖原型机制 |
🎯 写在最后
JavaScript 的面向对象看似复杂,实则简洁统一 —— 一切归于对象,一切基于原型。
一旦你理解了:
“对象通过
__proto__连接成链,属性沿着这条链向上查找”
你就掌握了 JS OOP 的灵魂。
无论你是使用原生构造函数、手动搭建继承体系,还是直接使用 class,背后的机制始终如一。
🚀 掌握原型,才能驾驭 JavaScript 的灵活性;理解继承,方能写出高质量、可扩展的应用架构。
🔖 点赞 + 收藏 + 关注,不错过更多前端深度解析!