JavaScript面向对象编程:从原型到class,跟着加菲猫吃透 JS 面向对象🐱
你是不是也曾经被JavaScript的原型链绕得头晕眼花?每次看到
__proto__和prototype就感觉在看天书?别担心,这几乎是每个前端开发者都会经历的阶段。今天,让我们一起揭开JavaScript面向对象编程的神秘面纱,从最基础的原型继承到ES6的class语法,彻底搞懂JS的OOP本质!✨
一、为什么JavaScript是"基于对象"而非"面向对象"?🤔
JavaScript是一种基于对象(Object-based) 的语言,这意味着你遇到的几乎所有东西都是对象(包括简单数据类型,它们也有包装类)。但严格来说,它不是一种真正的面向对象(OOP)语言,因为:
- 早期连
class关键字都没有(ES6之前) - 它使用原型继承而非类继承
this的指向依赖于调用方式,这与传统OOP语言有本质区别
💡 小知识:ES6的
class只是语法糖,底层仍然是基于原型的继承模型。所以,理解原型链是掌握JS面向对象编程的关键!
二、从对象字面量到构造函数:封装的初体验
代码一:原始对象字面量(不推荐)
// Cat 大写,开发约定是类
// name color 模板,抽象,封装的特性在显现
var Cat = {
name:"",
color:""
}
var cat1 = {}; // 空对象
cat1.name = "加菲猫";
cat1.color = "橘色";
var cat2 = {};
cat2.name = "黑猫警长";
cat2.color = "黑色";
问题:代码重复,无法批量创建实例,没有"类"的概念。
代码二:构造函数(封装实例化过程)🚀
function Cat(name, color) {
this.name = name;
this.color = color;
}
const cat1 = new Cat("加菲猫", "橘色");
const cat2 = new Cat("黑猫警长", "黑色");
关键点:
new关键字触发构造函数创建新对象- 构造函数内部的
this指向新创建的空对象 cat1.constructor === cat2.constructor为true,说明它们来自同一个"类"
🧠 深入理解this的指向
在JavaScript中,this的指向不是在函数定义时确定的,而是在函数执行时确定的。有以下几种情况:
-
作为普通函数调用(没有使用
new):function sayHello() { console.log(this); } sayHello(); // 浏览器中指向 window -
作为对象方法调用:
const cat = { name: "Tom", sayHello: function() { console.log(this.name); } }; cat.sayHello(); // 指向 cat 对象 -
作为构造函数调用(使用
new):function Cat(name) { this.name = name; } const cat = new Cat("Tom"); // this 指向新创建的 Cat 实例 -
使用
call、apply或bind显式绑定:function sayHello() { console.log(this.name); } const cat = { name: "Tom" }; sayHello.call(cat); // 指向 cat 对象
⚠️ 重要提示:不加new调用构造函数的危险!
function Cat(name, color) {
this.name = name;
this.color = color;
}
// 错误用法:忘记加 new
const cat = Cat("加菲猫", "橘色");
console.log(cat); // undefined
console.log(window.name); // "加菲猫"(浏览器中)
console.log(window.color); // "橘色"
真相:当不使用new调用构造函数时,this会指向全局对象(浏览器中是window,Node.js中是global)!
为什么这么危险?
- 属性被添加到全局对象
- 函数返回值是
undefined - 可能覆盖全局变量
严格模式下的"救命稻草" :
function Cat(name, color) {
"use strict"; // 启用严格模式
this.name = name;
this.color = color;
}
// 严格模式下,不加new会报错
const cat = Cat("加菲猫", "橘色");
// Uncaught TypeError: Cannot set properties of undefined (setting 'name')
在严格模式下,this不能指向全局对象,所以会直接抛出错误,防止了意外覆盖全局变量的危险。
💡 最佳实践:在实际项目中,可以使用
Object.create或添加检查来避免忘记new的问题。
三、原型对象:共享属性与方法的"金库" 💰
原型对象的核心机制
function Cat(name, color) {
this.name = name;
this.color = color;
}
Cat.prototype.type = "猫科动物";
Cat.prototype.eat = function() {
console.log("eat jerry");
}
原型对象的特性:
- 每个函数都有一个
prototype属性,指向一个对象 - 通过
new创建的实例对象会自动拥有一个__proto__属性,指向构造函数的prototype - 实例对象可以通过
__proto__访问其原型对象的属性和方法
🌐 原型链查找机制
当访问一个对象的属性时,JavaScript会按以下顺序查找:
- 先在实例自身上查找属性
- 如果没有找到,通过
__proto__向上查找原型链 - 直到找到属性或到达
Object.prototype - 未找到则返回
undefined
🔍 hasOwnProperty接口详解
hasOwnProperty是JavaScript对象原型链上的方法,用于判断对象是否包含自身(非继承)的指定属性。
// 创建一个基础对象
const base = { baseProp: "基础属性" };
// 创建继承自base的对象
const obj = Object.create(base);
obj.ownProp = "自身属性";
// 基本用法:只检查自身属性
console.log(obj.hasOwnProperty('ownProp')); // true
console.log(obj.hasOwnProperty('baseProp')); // false
// 常见陷阱:对象自身有hasOwnProperty属性
const problematic = {
hasOwnProperty: () => "被覆盖了!",
prop: "自身属性"
};
console.log(problematic.hasOwnProperty('prop')); // "被覆盖了!" ❌
console.log(Object.prototype.hasOwnProperty.call(problematic, 'prop')); // true ✅
// 安全用法:始终使用这个方式
const safeCheck = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
console.log(safeCheck(obj, 'ownProp')); // true
使用场景:
- 在
for...in循环中过滤继承属性 - 确保只处理对象自身属性
常见陷阱:
const foo = { hasOwnProperty: function() { return false; } };
console.log(foo.hasOwnProperty('bar')); // false(被覆盖了)
// 跳过`foo`自己的方法,直接用原始方法(Object)
console.log(Object.prototype.hasOwnProperty.call(foo, 'bar')); // true
💡 最佳实践:当需要安全地使用
hasOwnProperty时,始终使用Object.prototype.hasOwnProperty.call(obj, prop)。
关于原型的更多知识,可以看看我前面的一篇文章:吃透 JS 原型:从构造函数到原型链,一篇讲透核心逻辑JS原型是其面向对象的灵魂,以“委托”而非传统血缘实现继承。本文从 - 掘金
四、继承:让对象关系更清晰
方案一:构造函数继承(属性继承)
function Animal() {
this.species = "动物";
}
function Cat(name, color) {
Animal.apply(this); // 继承属性
this.name = name;
this.color = color;
}
原理:apply方法显式指定函数执行时的this指向,将父类的属性"复制"到子类实例上。
优点:可以向父类传参,每个实例有独立的属性
缺点:无法继承父类原型上的方法
这里你可能会问:
为什么 Animal.apply(this) 不能继承方法?
在JavaScript中,属性和方法的存储位置是不同的:
- 实例属性:在构造函数内部定义(
this.property = ...) - 方法:在
prototype对象上定义(Animal.prototype.method = function() {...})
function Animal() {
this.species = "动物"; // 实例属性
}
// 方法定义在prototype上,不是构造函数内部
Animal.prototype.say = function() {
console.log("我是动物");
};
当执行 Animal.apply(this) 时:
- 它只执行了Animal构造函数中的代码
- 只创建了
this.species = "动物"这个实例属性 - 没有将Cat的原型指向Animal的原型,所以无法访问
Animal.prototype.say
方案二:原型链继承
function Animal() {
this.species = "动物"; // 实例属性
}
Animal.prototype.sayHi = function() { console.log("12345"); }
function Cat(name, color) {
this.name = name;
this.color = color;
}
Cat.prototype = new Animal(); // 👈 关键行
const cat = new Cat("加菲猫", "橘色");
console.log(cat.species); // "动物" ✅
cat.sayHi(); // "12345" ✅
原理:将子类的prototype指向父类的实例,这样子类实例就能通过原型链访问父类的属性和方法。
缺点:如果父类有引用类型的属性(如数组、对象),会被所有实例共享。
方案三:组合继承(最优解)💎
function Animal(name) {
this.name = name;
}
Animal.prototype.sayHi = function() {
console.log(`Hi, I'm ${this.name}`);
}
function Cat(name, color) {
Animal.call(this, name); // 继承属性(构造函数继承)
this.color = color;
}
Cat.prototype = new Animal(); // 继承方法(原型链继承)
Cat.prototype.constructor = Cat; // 修复constructor
const cat = new Cat("加菲猫", "橘色");
cat.sayHi(); // Hi, I'm 加菲猫
这里你可能会想知道:
1、call()与Apply():
call 和 apply 功能完全相同,都是用来显式指定函数执行时的 this 指向。
它们的区别仅在于参数传递方式不同。
| 方法 | 语法 | 参数传递方式 | 继承示例 |
|---|---|---|---|
call | fn.call(thisArg, arg1, arg2, ...) | 逐个列出参数 | Animal.call(this, name, age) |
apply | fn.apply(thisArg, [arg1, arg2, ...]) | 参数放在数组中 | Animal.apply(this, [name, age]) |
2、为什么Apply(this)看起来“只有一个参数”?
当你看到这样的代码:
js
编辑
Animal.apply(this);
它确实只传了一个参数(只有 this),但这意味着:
调用
Animal()时不传任何实参,等价于Animal()。
这通常出现在父类构造函数不需要参数的情况下:
js
编辑
function Animal() {
this.species = "动物"; // 没有参数,所以 apply 只需传 this
}
function Cat(color) {
Animal.apply(this); // ✅ 正确:不需要额外参数
this.color = color;
}
对比有参数的情况:
js
编辑
function Animal(name) {
this.name = name;
}
function Cat(name, color) {
// ❌ 错误:没传 name,name 会是 undefined
Animal.apply(this);
// ✅ 正确:把参数放进数组
Animal.apply(this, [name]);
this.color = color;
}
3、为什么组合继承最优?
- 构造函数继承:确保每个实例有自己的属性(包括引用类型)
- 原型链继承:确保所有实例共享方法
- 修复constructor:使
cat.constructor指向正确的构造函数
💡 组合继承是JavaScript中实现继承的主流方式,也是大多数框架和库采用的继承模式。
五、ES6 class:优雅的语法糖 🎀
class Animal {
constructor(name) {
this.name = name;
}
sayHi() {
console.log(`Hi, I'm ${this.name}`);
}
}
class Cat extends Animal {
constructor(name, color) {
super(name); // 调用父类构造函数
this.color = color;
}
// 重写方法
// 如果想沿用父类Animal中的方法,也可以不重写,直接调用
sayHi() {
console.log(`Hi, I'm ${this.name}, a ${this.color} cat`);
}
}
const cat = new Cat("加菲猫", "橘色");
cat.sayHi(); // Hi, I'm 加菲猫, a 橘色 cat
ES6 class的底层机制
虽然class看起来像传统OOP语言,但它的底层实现仍然是基于原型的:
// class的等效原型实现
function Animal(name) {
this.name = name;
}
Animal.prototype.sayHi = function() {
console.log(`Hi, I'm ${this.name}`);
}
function Cat(name, color) {
Animal.call(this, name);
this.color = color;
}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Cat.prototype.sayHi = function() {
console.log(`Hi, I'm ${this.name}, a ${this.color} cat`);
};
关键点:
extends关键字底层是通过Object.create实现原型链继承super关键字用于调用父类构造函数或方法constructor是类的默认方法,创建实例时被调用
🤯 真相:即使使用
class,JS的底层仍然是基于原型的继承。cat.__proto__指向Cat.prototype,Cat.prototype.__proto__指向Animal.prototype。
六、原型链的终极图解 🌉
cat (实例)
↓
Cat.prototype (子类原型)
↓
Animal.prototype (父类原型)
↓
Object.prototype
↓
null (原型链终点)
执行流程:当调用cat.sayHi()时:
- 先在
cat实例上查找sayHi - 未找到,通过
__proto__查找Cat.prototype - 未找到,通过
__proto__查找Animal.prototype - 找到
sayHi,执行
七、总结与思考 💭
- JavaScript不是基于类的OOP,而是基于原型的OOP。理解原型链是掌握JS面向对象的关键。
- 构造函数:创建实例,定义实例属性。
- 原型对象:存储共享方法和属性。
- 继承:通过修改子类的
prototype指向父类实例或原型实现。 - class:ES6的语法糖,底层仍是原型继承。
🌟 终极建议:即使使用ES6的
class,也要理解其背后的原型机制。这样在遇到原型链问题时,才能游刃有余。
八、实践小贴士 🛠
-
不要混淆
__proto__和prototype:__proto__:实例对象的内部属性,指向其原型prototype:构造函数的属性,指向原型对象
-
避免原型污染:
// 错误示例:直接修改Object.prototype Object.prototype.myMethod = function() { /* ... */ } // 正确做法:使用安全的原型扩展 const safeObject = Object.create(null); safeObject.myMethod = function() { /* ... */ }
结语
JavaScript的面向对象编程虽然与传统OOP语言不同,但它的原型机制非常强大且灵活。从最初的原型链继承,到ES6的class语法糖,JavaScript的OOP之路一直在进化,但核心始终未变。
🐾 记住:无论你用
class还是prototype,理解原型链是写出高质量JS代码的关键。当你能清晰地画出原型链时,JavaScript的面向对象编程就不再是"天书",而是一门优雅的艺术!