JS面向对象OOP全解析:从原型到Class,告别模糊认知
前言:很多前端新手学JS面向对象时,都会被“原型”“构造函数”“继承”绕晕——JS明明到处都是对象,却和Java、Python的OOP不一样;ES6的class看似简单,底层却藏着原型的逻辑。本文从JS的语言特性出发,拆解封装、原型、继承三大核心,用通俗案例讲透JS OOP的本质,适合入门同学夯实基础。
一、先厘清:JS是“基于对象”,而非纯粹“面向对象”
面向对象(OOP)的核心是类(Class) 与实例(Instance) ,通过封装、继承、多态实现代码复用与扩展。但JS有个特殊之处:
它是一门基于对象(Object-based) 的语言——你接触到的几乎所有东西都是对象,哪怕是字符串、数字等简单数据类型,也有对应的包装类(如String、Number),能调用内置方法。
但它早期没有class关键字,也没有严格意义上的“类模板”,哪怕ES6新增了class,其底层依然是原型式面向对象,而非传统OOP的“类-实例”血缘关系。
那么问题来了:没有class和显式构造器,JS怎么实现面向对象?
二、封装:从对象字面量到构造函数
封装的核心是“将属性和方法打包”,隐藏内部细节、暴露可控接口,同时解决实例创建的代码重复问题。JS实现封装,经历了从简单到规范的过程。
1. 原始模式:对象字面量
最基础的创建对象方式,直接用字面量定义属性和方法,适合单个对象的场景:
// 单个用户对象
const user1 = {
name: "张三",
age: 18,
sayHi: function() {
console.log(`你好,我是${this.name}`);
}
};
user1.sayHi(); // 你好,我是张三
缺点很明显:创建多个同类对象时,代码大量重复,且实例之间没有关联,无法体现“同属一个类”的关系。
2. 优化:构造函数封装实例化过程
通过函数封装实例创建逻辑,用new关键字调用(构造函数约定首字母大写),实现批量创建实例,同时建立实例与构造函数的关联:
// 构造函数:封装用户类的实例化逻辑
function User(name, age) {
// new调用时,会自动创建空对象,this指向这个空对象
this.name = name; // 给实例添加独有属性
this.age = age;
this.sayHi = function() {
console.log(`你好,我是${this.name}`);
};
}
// 创建两个实例
const user1 = new User("张三", 18);
const user2 = new User("李四", 20);
user1.sayHi(); // 你好,我是张三
user2.sayHi(); // 你好,我是李四
关键细节:
- new调用构造函数时,会自动完成“创建空对象→绑定this→执行函数代码→返回对象”四步;
- 普通函数调用(无new)时,this指向window,会污染全局变量,务必注意调用方式;
- 可以通过instanceof判断实例归属:console.log(user1 instanceof User); // true。
但这个方案仍有不足:每个实例的sayHi方法都会重新创建一份,占用额外内存——这就是原型要解决的问题。
三、原型(Prototype)模式:解决共享与内存浪费
构造函数的独有属性/方法(如name、age)需要每个实例单独拥有,但公用属性/方法(如sayHi)没必要重复创建。JS通过原型,让所有实例共享公用资源。
1. 原型的核心逻辑
每个函数都有一个prototype属性,其值是一个对象(原型对象);构造函数的所有实例,都会通过私有属性__proto__(ES5推荐用Object.getPrototypeOf())指向这个原型对象。
原型对象上的属性和方法,会被所有实例共享,且只在内存中存储一份。
2. 优化构造函数:公有方法放原型
function User(name, age) {
// 实例独有属性
this.name = name;
this.age = age;
}
// 公有方法/属性放原型上,所有实例共享
User.prototype.sayHi = function() {
console.log(`你好,我是${this.name}`);
};
User.prototype.gender = "未知"; // 共享属性
const user1 = new User("张三", 18);
const user2 = new User("李四", 20);
// 共享原型上的方法和属性
user1.sayHi(); // 你好,我是张三
user2.sayHi(); // 你好,我是李四
console.log(user1.gender, user2.gender); // 未知 未知
3. 原型相关关键方法
- isPrototypeOf():判断原型对象是否属于某个实例 → User.prototype.isPrototypeOf(user1); // true;
- hasOwnProperty():判断属性是实例独有还是原型共享 → user1.hasOwnProperty("name"); // true(独有),user1.hasOwnProperty("gender"); // false(原型);
- in运算符:判断属性是否存在(实例或原型中) → "sayHi" in user1; // true。
注意:如果实例上定义了与原型同名的属性,会优先使用实例的属性(原型查找的就近原则)。
四、ES6 Class:原型的语法糖
ES6新增class关键字,让JS的OOP写法更贴近传统语言,但底层依然是原型机制,class只是简化了语法,没有改变本质。
// ES6 Class写法
class User {
// 构造器:对应ES5的构造函数
constructor(name, age) {
this.name = name;
this.age = age;
}
// 原型上的方法(无需写prototype)
sayHi() {
console.log(`你好,我是${this.name}`);
}
}
const user1 = new User("张三", 18);
console.log(user1.__proto__ === User.prototype); // true(底层还是原型)
对比ES5的原型写法,class的优势是语法更简洁、语义更清晰,避免了手动操作prototype的繁琐,但核心逻辑完全一致。
五、继承:JS原型链与构造函数绑定
继承是OOP的核心特性之一,让子类能复用父类的属性和方法,同时扩展自身功能。JS实现继承,核心是原型链和构造函数绑定。
1. 基础方案:构造函数绑定(apply/call)
通过apply或call,将父类构造函数的this绑定到子类实例,实现父类属性的继承,但无法继承父类原型上的方法:
// 父类:动物
function Animal() {
this.species = "动物";
}
// 父类原型方法
Animal.prototype.sayAnimal = function() {
console.log("我是动物");
};
// 子类:狗
function Dog(name) {
// 绑定父类构造函数的this,继承父类属性
Animal.apply(this);
this.name = name;
}
const dog1 = new Dog("旺财");
console.log(dog1.species); // 动物(继承父类属性)
dog1.sayAnimal(); // 报错(无法继承原型方法)
2. 优化方案:原型链继承
让子类的原型指向父类的实例,通过原型链实现“属性+方法”的全继承:
function Animal() {
this.species = "动物";
}
Animal.prototype.sayAnimal = function() {
console.log("我是动物");
};
function Dog(name) {
Animal.apply(this); // 继承父类属性
this.name = name;
}
// 子类原型指向父类实例,继承父类原型方法
Dog.prototype = new Animal();
const dog1 = new Dog("旺财");
dog1.sayAnimal(); // 我是动物(成功继承原型方法)
console.log(dog1.species); // 动物
核心逻辑:子类实例的__proto__指向子类原型(父类实例),父类实例的__proto__指向父类原型,层层向上形成原型链,直到Object.prototype(原型链终点为null)。
六、总结:JS OOP的本质
- JS是基于对象的语言,OOP底层依赖原型机制,而非传统类继承;
- 封装:通过构造函数/Class打包实例逻辑,区分独有与共享资源;
- 原型:解决公用方法的内存浪费,是实例共享资源的核心;
- 继承:通过构造函数绑定+原型链,实现父类属性与方法的复用;
- ES6 Class是语法糖,没有改变原型的底层逻辑。