《拆解 JS OOP 难点:构造函数、原型、Class 的底层关联》

61 阅读5分钟

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的本质

  1. JS是基于对象的语言,OOP底层依赖原型机制,而非传统类继承;
  2. 封装:通过构造函数/Class打包实例逻辑,区分独有与共享资源;
  3. 原型:解决公用方法的内存浪费,是实例共享资源的核心;
  4. 继承:通过构造函数绑定+原型链,实现父类属性与方法的复用;
  5. ES6 Class是语法糖,没有改变原型的底层逻辑。