JavaScript面向对象编程:从原型到class,跟着加菲猫吃透 JS 面向对象🐱

92 阅读8分钟

JavaScript面向对象编程:从原型到class,跟着加菲猫吃透 JS 面向对象🐱

你是不是也曾经被JavaScript的原型链绕得头晕眼花?每次看到__proto__prototype就感觉在看天书?别担心,这几乎是每个前端开发者都会经历的阶段。今天,让我们一起揭开JavaScript面向对象编程的神秘面纱,从最基础的原型继承到ES6的class语法,彻底搞懂JS的OOP本质!✨

一、为什么JavaScript是"基于对象"而非"面向对象"?🤔

JavaScript是一种基于对象(Object-based) 的语言,这意味着你遇到的几乎所有东西都是对象(包括简单数据类型,它们也有包装类)。但严格来说,它不是一种真正的面向对象(OOP)语言,因为:

  1. 早期连class关键字都没有(ES6之前)
  2. 它使用原型继承而非类继承
  3. 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.constructortrue,说明它们来自同一个"类"
🧠 深入理解this的指向

在JavaScript中,this的指向不是在函数定义时确定的,而是在函数执行时确定的。有以下几种情况:

  1. 作为普通函数调用(没有使用new):

    function sayHello() {
      console.log(this);
    }
    sayHello(); // 浏览器中指向 window
    
  2. 作为对象方法调用

    const cat = { name: "Tom", sayHello: function() { console.log(this.name); } };
    cat.sayHello(); // 指向 cat 对象
    
  3. 作为构造函数调用(使用new):

    function Cat(name) { this.name = name; }
    const cat = new Cat("Tom"); // this 指向新创建的 Cat 实例
    
  4. 使用callapplybind显式绑定

    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)!

为什么这么危险?

  1. 属性被添加到全局对象
  2. 函数返回值是undefined
  3. 可能覆盖全局变量

严格模式下的"救命稻草"

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会按以下顺序查找:

  1. 先在实例自身上查找属性
  2. 如果没有找到,通过__proto__向上查找原型链
  3. 直到找到属性或到达Object.prototype
  4. 未找到则返回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():

callapply 功能完全相同,都是用来显式指定函数执行时的 this 指向

它们的区别仅在于参数传递方式不同

方法语法参数传递方式继承示例
callfn.call(thisArg, arg1, arg2, ...)逐个列出参数Animal.call(this, name, age)
applyfn.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、为什么组合继承最优?

  1. 构造函数继承:确保每个实例有自己的属性(包括引用类型)
  2. 原型链继承:确保所有实例共享方法
  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.prototypeCat.prototype.__proto__指向Animal.prototype

六、原型链的终极图解 🌉

cat (实例)
  ↓
Cat.prototype (子类原型)
  ↓
Animal.prototype (父类原型)
  ↓
Object.prototypenull (原型链终点)

执行流程:当调用cat.sayHi()时:

  1. 先在cat实例上查找sayHi
  2. 未找到,通过__proto__查找Cat.prototype
  3. 未找到,通过__proto__查找Animal.prototype
  4. 找到sayHi,执行

七、总结与思考 💭

  1. JavaScript不是基于类的OOP,而是基于原型的OOP。理解原型链是掌握JS面向对象的关键。
  2. 构造函数:创建实例,定义实例属性。
  3. 原型对象:存储共享方法和属性。
  4. 继承:通过修改子类的prototype指向父类实例或原型实现。
  5. class:ES6的语法糖,底层仍是原型继承。

🌟 终极建议:即使使用ES6的class,也要理解其背后的原型机制。这样在遇到原型链问题时,才能游刃有余。

八、实践小贴士 🛠

  1. 不要混淆__proto__prototype

    • __proto__:实例对象的内部属性,指向其原型
    • prototype:构造函数的属性,指向原型对象
  2. 避免原型污染

    // 错误示例:直接修改Object.prototype
    Object.prototype.myMethod = function() { /* ... */ }
    
    // 正确做法:使用安全的原型扩展
    const safeObject = Object.create(null);
    safeObject.myMethod = function() { /* ... */ }
    

结语

JavaScript的面向对象编程虽然与传统OOP语言不同,但它的原型机制非常强大且灵活。从最初的原型链继承,到ES6的class语法糖,JavaScript的OOP之路一直在进化,但核心始终未变。

🐾 记住:无论你用class还是prototype,理解原型链是写出高质量JS代码的关键。当你能清晰地画出原型链时,JavaScript的面向对象编程就不再是"天书",而是一门优雅的艺术!