JavaScript面向对象:从"猫对象"到"继承大师"的搞笑进阶

101 阅读9分钟

如果你问JavaScript程序员:"面向对象不难吧?",十个人里可能有九个会先翻个白眼,再给你讲半小时"原型链有多绕"。其实JS的OOP就像外卖——表面上是"点餐-收货"的简单流程(类-实例),背地里藏着"商家备餐-骑手取餐-路线规划"的复杂逻辑(原型-继承)。今天咱们就带着笑点,从"创建一只猫"开始,把JS OOP的底裤扒得明明白白。

核心提示:JS OOP核心是「原型继承」,和Java等传统OOP的「类继承」本质不同,这是理解所有知识点的关键!

一、青铜入门:给猫贴标签的原始时代(对象字面量)

假设你是个刚入行的程序员,老板让你做个养猫系统,第一版你可能会这么写:先建个空对象,再挨个给猫加名字、颜色。就像给流浪猫挂牌子,每只都要手动写,累得手抽筋。

// 空对象就像没贴标签的猫笼
var cat1 = {};
cat1.name = '加菲猫'; // 给猫笼贴"加菲猫"标签
cat1.color = "橘色"; // 再贴"橘色"标签

var cat2 = {};
cat2.name = "黑猫警长"; // 重复操作,手酸了
cat2.color = "黑色";

这种"对象字面量"方式就像用便利贴记账——简单直接,但致命缺点很明显,适合临时创建少量独立对象,大规模使用会踩坑:

二、白银进阶:给猫办"身份证"(构造函数+new)

  • 缺乏关联:这些猫看似都是"猫",但JS无法识别它们的关联,用cat1 instanceof Cat检测会返回false,属于"散装对象";
  • 代码冗余:创建100只猫就要写100遍name和color赋值,堪称"程序员手部劳损模拟器";

聪明的程序员很快发现不对劲:既然都是猫,能不能搞个"猫模板"?于是构造函数应运而生,就像派出所的身份证制作模板,填好信息就能出证。

  • 维护困难:若要给所有猫加"年龄"属性,需逐个修改,成本极高。
// 猫的"身份证模板",首字母大写是约定
function Cat(name, color) {
  this.name = name; // 把名字填进模板
  this.color = color; // 把颜色填进模板
}

// 用new"打印"身份证,得到真正的猫实例
const cat1 = new Cat('加菲猫', '橘色');
const cat2 = new Cat('黑猫警长', '黑色');

易错点:新手常犯var Cat = {name:"",color:""}; var cat1 = Cat;的错误,此时cat1和Cat指向同一对象,修改cat1会同步改变Cat!

这里的new关键字堪称"魔法咒语",执行时偷偷完成5步核心操作,缺一不可:

但别高兴太早,要是给猫加个"吃饭"方法,问题又来了:

function Cat(name, color) {
  this.name = name;
  this.color = color;
  this.eat = function() { // 每只猫都带一个吃饭方法
    console.log('吃杰瑞');
  };
}

构造函数直接定义方法会导致严重问题:每创建一个实例,就会生成一个新的eat方法副本。若创建100只猫,就有100个eat方法,纯属内存浪费(每只猫扛一张餐桌)。

  1. 关联原型:将obj的__proto__指向构造函数的prototype(建立家族关系);
  2. 创建空对象:var obj = {};(猫坯子);

三、黄金高手:给猫建"共享食堂"(原型模式)

  1. 绑定this:构造函数中的this指向obj(给坯子贴标签);

原型对象的核心思想很简单:把所有猫都要用到的方法,放到一个"共享食堂"里,谁要吃饭直接去食堂,不用自己扛桌子。这个食堂就是构造函数的prototype属性。

  1. 执行构造:运行函数代码,给obj添加属性(完善猫的信息);
function Cat(name, color) {
  this.name = name; // 个体属性自己带
  this.color = color;
}

// 共享方法放进"食堂"
Cat.prototype.eat = function() {
  console.log(this.name + '吃杰瑞');
};

const cat1 = new Cat('加菲猫', '橘色');
const cat2 = new Cat('黑猫警长', '黑色');
cat1.eat(); // 加菲猫吃杰瑞
cat2.eat(); // 黑猫警长吃杰瑞
  1. 返回对象:若构造函数无返回对象,自动返回obj(输出成品猫)。

原型模式的核心优势是「方法共享」,不管创建多少只猫,eat方法仅存一个副本。JS的属性查找遵循"就近原则",专业称为「原型链委托」:

防坑技巧:为避免忘写new导致this指向全局,可在构造函数加防护:if(!(this instanceof Cat)) return new Cat(name,color);

有了这个模板,创建多少只猫都不怕重复,且类型检测有效:cat1 instanceof Cat // true,相当于给每只猫办了"防伪身份证"。

但要是想给猫加个"物种"属性,所有猫都共享"猫科动物"这个值,直接放原型里就行。可万一有只猫想改物种为"铲屎官的主人",直接改自己的属性就行,不会影响其他猫——这就像食堂里的公共调料,你自己带瓶辣椒精,不会改变食堂的调料罐。

四、铂金大师:给猫找"亲戚"(继承机制)

当你不仅要养猫,还要养狗、养鸟时,总不能给每种动物都建一套模板和食堂吧?这时候就需要"继承"——让猫继承动物的基本属性,再加上自己的特色。

此时「原型对象」登场,其设计理念是"共享经济"——将公共方法集中存放在构造函数的prototype属性中,所有实例共享同一副本,实现内存优化。

当需要创建"动物→猫→橘猫"等多层关系时,就需要「继承」。最经典的「组合继承」结合两种方式的优点,是ES6前的最优解:

反例警示:仅用原型链继承(Cat.prototype = new Animal())会导致引用类型共享。若Animal有this.friends = ['狗'],修改cat1.friends会同步影响cat2!

  • 原型链继承:用原型关联继承方法,实现方法复用。
  • 构造函数继承:用apply/call继承属性,解决引用类型共享问题;

核心关系:构造函数 → prototype(原型对象) → proto(实例指向原型),三者形成"铁三角",是JS OOP的核心骨架。

// 父类:动物
function Animal() {
  this.species = '动物'; // 所有动物都有物种属性
}

// 动物的共享方法
Animal.prototype.breathe = function() {
  console.log('呼吸空气');
};

// 子类:猫,继承动物
function Cat(name, color) {
  Animal.apply(this); // 继承属性:借动物的构造函数用用
  this.name = name;
  this.color = color;
}

// 继承方法:把猫的食堂连到动物的实例上
Cat.prototype = new Animal();
// 修复身份证信息:告诉猫它的模板是Cat,不是Animal
Cat.prototype.constructor = Cat;

组合继承虽好用,但存在「父构造函数调用两次」的小瑕疵:

最终实例的species会覆盖原型的species,虽不影响功能,但造成原型上的属性冗余。ES6的Class语法完美解决了这个问题。

  • 第二次:Animal.apply(this)(给实例加species属性)。
  • 第一次:Cat.prototype = new Animal()(给原型加species属性);

cat1自身 → Cat.prototype(原型对象) → Object.prototype(原型的原型) → null(查找终止)

当执行cat1.eat()时,查找流程为:

五、王者终极:穿"语法糖"外衣的高手(ES6 Class)

特殊情况:箭头函数无自身this,继承外层作用域的this,可解决回调函数this指向问题。

举例验证:给Cat.prototype.type = '猫科动物',所有猫可共享该属性;若某只猫执行cat1.type = '铲屎官的主人',仅会在自身创建属性,不影响原型和其他实例(自己带辣椒精,不碰食堂调料罐)。

很多从Java、C#转来的程序员,看到原型链就头大。ES6贴心地推出了Class语法,把原型链这堆复杂逻辑藏在里面,穿上一件"传统类"的外衣,让大家用着更顺手。

没有完美的继承方式,只有适配的场景,对应方案清晰匹配:

Class的核心优势在于「语法直观」,尤其对传统OOP开发者友好,同时自动处理了constructor指向修正等细节,减少手动操作失误。

console.log(
  garfield.__proto__ === Cat.prototype, // true
  garfield.__proto__.__proto__ === Object.prototype, // true
  garfield.__proto__.__proto__.__proto__ // null
);
检测方法作用示例结果
hasOwnProperty检测是否为自身属性cat1.hasOwnProperty('name')true
hasOwnProperty检测是否为自身属性cat1.hasOwnProperty('eat')false
in运算符检测自身或原型是否有该属性'eat' in cat1true
// 看起来像传统类,实则还是原型那套
class Cat {
  constructor(name, color) { // 构造函数,和以前一样
    this.name = name;
    this.color = color;
  }

  eat() { // 方法自动放进原型,不用手动写prototype
    console.log(this.name + '吃杰瑞');
  }
}

// 继承也变得简单直观
class OrangeCat extends Cat {
  constructor(name) {
    super(name, '橘色'); // 调用父类构造函数,相当于apply
    this.weight = '胖'; // 橘猫特色属性
  }
}

const garfield = new OrangeCat('加菲猫');
garfield.eat(); // 加菲猫吃杰瑞
console.log(garfield.weight); // 胖

就像养猫,没必要给每只猫都办全套手续,自己家的主子,怎么舒服怎么养——代码也是一样。

实战建议:JS是多范式语言,不必硬套"纯面向对象"。简单场景用对象字面量(如配置项),复杂组件(如弹窗、表单)用Class,灵活搭配才是最优解。

场景需求推荐方案核心优势
创建少量独立对象对象字面量简单直接
创建多个同类对象(无复杂方法)构造函数解决代码冗余
创建多个同类对象(有共享方法)构造函数+原型内存优化
复杂继承关系(ES5环境)组合继承属性和方法都能继承
现代开发(追求可读性)ES6 Class + extends语法直观,规避细节坑

关键认知:Class不是全新的OOP实现,只是原型继承的"优雅包装",理解原型链仍是掌握Class的核心。

千万别被Class的外表骗了,它本质是「原型继承的语法糖」,底层逻辑和构造函数+原型完全一致。可通过控制台验证原型链结构:

原型的3大价值:方法共享(省内存)、动态更新(改原型影响所有实例)、实现继承(原型链关联)。

共享方法放原型,实例属性放构造;引用类型要隔离,原型链要理清楚。

所有继承和方法共享的底层支撑都是原型链,核心口诀:

六、终极总结:JS OOP的核心真相

this的指向不看定义位置,只看调用方式,4大核心场景一目了然:

聊了这么多,JS OOP的核心逻辑可归纳为3个"灵魂真相",记住就能规避90%的坑:

调用方式this指向示例
new 构造函数新创建的实例new Cat('加菲猫')
对象.方法调用方法的对象cat1.eat()
普通函数调用全局对象(严格模式为undefined)Cat('加菲猫')
apply/call/bind绑定的目标对象Cat.apply(obj)

1. 原型是灵魂

2. this是变色龙

3. 继承是组合