从"养猫"看懂JS面向对象:原型链与Class本质拆解
JavaScript的面向对象始终带着独特的设计印记------早期依靠"函数+原型"实现封装与继承,ES6引入的Class语法,本质仍是原型机制的语法糖。许多开发者困惑于原型链的复杂逻辑,实则可通过生活化场景轻松破解。
本文以"猫咪饲养"为核心案例,从手动创建单只猫、批量实例化,到实现猫继承动物的通用能力,全程结合可运行代码,拆解JS面向对象的底层逻辑。无论你是刚接触JS的新手,还是想巩固原型链知识的开发者,都能通过实操掌握核心要点。
一、原始时代:手动捏猫,累到怀疑人生
JS早期没有class,想创建对象全靠"手工打造"。手写的第一版代码,完美还原了这种"原始作坊模式":
javascript
// 先定义个猫的"空模板"(大写开头是约定,暗示这是类)
var Cat = {
name: "",
color: ""
};
// 手动捏第一只猫:加菲猫
var cat1 = {}; // 先造个空壳
cat1.name = '加菲猫'; // 手动填名字
cat1.color = '橘色'; // 手动染毛色
// 手动捏第二只猫:黑猫警长
var cat2 = {}; // 再造个空壳
cat2.name = '黑猫警长'; // 重复填信息
cat2.color = '黑色'; // 重复染毛色
灵魂拷问:这种方式有多坑?
- 想养10只猫就要写10遍重复代码,堪称"复制粘贴地狱"
- 某天想给所有猫加"品种"属性,得一只只修改,比给小区所有宠物挂号还麻烦
- 猫和猫之间没有任何"血缘关系",没法统一管理
这时候你肯定会想:能不能搞个"猫工厂",一键批量产猫?------这就是JS面向对象的核心诉求:封装实例化过程,减少重复代码。
二、工业革命:构造函数,批量产猫的流水线
构造函数,就是"猫工厂"的第一版蓝图。它把"捏猫壳→填信息"的流程封装成流水线,new关键字一按,就能自动产出猫咪:
javascript
// 构造函数=猫工厂蓝图(首字母大写是约定,告诉别人"我是工厂")
function Cat(name, color) {
console.log(this); // 打印:Cat {}(新鲜出炉的空猫壳)
this.name = name; // 给空猫壳贴名字标签
this.color = color; // 给空猫壳染毛色
}
// 坑点预警:没按"工厂开关"(new)直接调用,会搞破坏!
Cat('黑猫警长', '黑色'); // this指向window,相当于在全局乱贴猫标签
// 正确操作:按new开关启动流水线
const cat1 = new Cat('加菲猫', '橘色'); // 产出第一只猫
console.log(cat1); // 输出:Cat { name: '加菲猫', color: '橘色' }
const cat2 = new Cat('黑猫警长', '黑色'); // 秒产第二只猫
// 验证"血缘关系":同一工厂产的猫,共享同一个"出生证明"
console.log(cat1.constructor === cat2.constructor); // true
// 验明正身:判断这是不是咱工厂的猫
console.log(cat1 instanceof Cat); // true
趣味拆解:new这个"工厂开关"到底做了啥?
- 造空壳:自动生成一个空对象(
const 空猫壳 = {}) - 绑身份:让构造函数里的
this指向这个空猫壳 - 填信息:执行构造函数代码,给空猫壳添加属性
- 送出厂:自动返回填好信息的猫壳(不用写return)
关键知识点:this的"变脸术"
- 用
new调用时,this是"待出厂的猫"(实例对象) - 直接调用时,this是window(全局对象),会污染全局------这就是为啥构造函数必须用new调用!
三、优化升级:原型模式,猫咪的共享仓库
构造函数解决了批量创建的问题,但新的隐患随之出现:若在构造函数内定义方法,每个实例都会生成独立的方法副本,造成内存浪费。
javascript
function Cat(name, color) {
this.name = name;
this.color = color;
// 每只猫都有独立的eat方法,内存浪费严重
this.eat = function() { alert('喜欢jerry'); }
}
const cat1 = new Cat('tom', '黑色');
const cat2 = new Cat('咖啡猫', '橘色');
console.log(cat1.eat === cat2.eat); // false(看似一样,实则是两个独立函数)
上述代码中,猫工厂刚投产,你就发现新问题:给每只猫都配"吃老鼠"的方法,100只猫就有100套工具,纯属浪费内存。 这时候prototype原型登场了------它是JS给每个构造函数配的"共享仓库",把所有猫都需要的属性/方法放进去,所有猫咪共享使用,不用重复创建
原型的正确使用方式
javascript
function Cat(name, color) {
this.name = name; // 私有属性:每只猫名字不同
this.color = color; // 私有属性:每只猫毛色不同
}
// 共享仓库:prototype里的东西所有猫都能共用
Cat.prototype.type = '猫科动物'; // 所有猫的共同品种
Cat.prototype.eat = function() {
console.log('eat jerry'); // 所有猫的共同技能
};
const cat1 = new Cat('tom', '黑色');
const cat2 = new Cat('咖啡猫', '橘色');
// 从共享仓库"借"属性
console.log(cat1.type, cat2.type); // 输出:猫科动物 猫科动物
// 个性定制:给cat1单独贴标签,不影响其他猫
cat1.type = '铲屎官的主人';
console.log(cat1.type, cat2.type); // 输出:铲屎官的主人 猫科动物
// 验证共享:两只猫用的是同一个eat方法
console.log(cat1.eat === cat2.eat); // true(内存优化成功!)
核心逻辑:原型链的"查找规则"
每只猫(实例)出生时,都会自带一张"借条"------__proto__,这张借条指向"共享仓库"(Cat.prototype)。
当你访问cat1.type时,JS会按以下顺序查找:
- 先看cat1自己有没有type属性(刚开始没有)
- 顺着
__proto__借条找到Cat.prototype,发现有type属性,直接使用 - 若仓库里也没有,会继续顺着
Cat.prototype.__proto__找Object的原型,直到找到null(原型链尽头)
四、验物工具:3个方法分清"自己的"和"借的"
以下代码里,藏着3个"验物神器",能快速分清属性是猫咪自己的,还是从原型仓库借的(面试常考!):
javascript
function Cat(name, color) {
this.name = name;
this.color = color;
}
Cat.prototype.type = '猫科动物';
Cat.prototype.eat = function() { console.log('eat jerry'); };
const cat1 = new Cat('tom', '黑色');
// 1. hasOwnProperty:查是不是自己的属性(私有属性)
console.log(cat1.hasOwnProperty('name')); // true(自己的名字)
console.log(cat1.hasOwnProperty('type')); // false(借的品种)
// 2. in:查有没有这个属性(不管是自己的还是借的)
console.log('name' in cat1); // true
console.log('type' in cat1); // true
// 3. isPrototypeOf:查仓库是不是给这只猫用的
console.log(Cat.prototype.isPrototypeOf(cat1)); // true
// 4. for...in:遍历所有属性(自己的+借的)
for (var prop in cat1) {
console.log(prop, cat1[prop]);
// 输出:name tom、color 黑色、type 猫科动物、eat [Function]
}
五、现代包装:ES6 class,精装修的猫工厂
当你觉得prototype写起来麻烦时,ES6的class就来了------它不是新的面向对象模型,只是给原型模式套了个"精装修外壳",让代码更像传统OOP语言(比如Java):
javascript
// class=精装修的猫工厂,底层还是原型模式
class Cat {
// constructor:换了名字的构造函数(流水线核心)
constructor(name, color) {
this.name = name;
this.color = color;
}
// 类内方法:自动放进Cat.prototype仓库(共享方法)
eat() {
console.log('eat');
}
}
const cat1 = new Cat('tom', '黑色');
console.log(cat1); // 输出:Cat { name: 'tom', color: '黑色' }
cat1.eat(); // 调用共享方法
// 照妖镜:打印原型链,暴露底层逻辑
console.log(
cat1.__proto__, // 指向Cat.prototype(共享仓库)
cat1.__proto__.constructor, // 指向Cat类(工厂本身)
cat1.__proto__.__proto__, // 指向Object.prototype(顶层仓库)
cat1.__proto__.__proto__.__proto__ // null(仓库尽头)
);
关键结论:class是语法糖,不是新东西
class里的constructor等价于原来的构造函数- 类内定义的方法(比如
eat),会自动挂载到prototype上 - 实例的
__proto__依然指向类的prototype,原型链逻辑没变
六、家族传承:继承,让猫拥有动物的技能
养完猫你又有新需求:猫和狗都是动物,都需要"呼吸"、"打招呼",能不能让猫直接继承动物的技能?------这就是继承的核心:子类复用父类的属性和方法。
以下代码,完美实现了两种继承逻辑:
1. 第一步:借属性------用apply复制父类实例属性
javascript
// 父类:动物工厂
function Animal() {
this.species = '动物'; // 所有动物的共同属性
}
// 子类:猫工厂(要继承动物的属性)
function Cat(name, color) {
Animal.apply(this); // 关键一步:把Animal的属性复制到Cat实例上
this.name = name;
this.color = color;
}
const cat = new Cat('加菲猫', '橘色');
console.log(cat); // 输出:Cat { species: '动物', name: '加菲猫', color: '橘色' }
这里的apply就像"属性复印机",把父类Animal的species属性,复制到子类Cat的实例上------但这只能继承实例属性,没法继承父类原型上的方法。
2. 第二步:借方法------用原型链打通共享仓库
javascript
// 父类:动物工厂
function Animal() {
this.species = '动物';
}
// 父类共享仓库:动物的共同技能
Animal.prototype = {
sayHi: function() {
console.log('哪哪哪啦'); // 动物的打招呼方式
}
};
// 子类:猫工厂
function Cat(name, color) {
Animal.apply(this); // 复制实例属性
this.name = name;
this.color = color;
}
// 关键一步:打通原型链,让猫能借动物的方法
Cat.prototype = new Animal();
// 测试:猫能不能调用动物的sayHi方法?
const cat = new Cat('加菲猫', '橘色');
cat.sayHi(); // 输出:哪哪哪啦(继承成功!)
继承逻辑拆解:
Animal.apply(this):复制父类的"身体零件"(实例属性)Cat.prototype = new Animal():让猫的共享仓库,指向动物的实例,从而打通"猫→动物"的原型链- 当猫调用
sayHi时,会顺着原型链找到Animal.prototype里的方法
ES6 extends:一键继承的快捷键
ES6的extends把上面两步合并成了一句话,本质还是原型链继承:
javascript
class Animal {
constructor() {
this.species = '动物';
}
sayHi() {
console.log('哪哪哪啦');
}
}
// extends=一键继承,super=Animal.apply(this)
class Cat extends Animal {
constructor(name, color) {
super(); // 必须先调用super,才能用this
this.name = name;
this.color = color;
}
}
const cat = new Cat('加菲猫', '橘色');
console.log(cat.species); // 输出:动物
cat.sayHi(); // 输出:哪哪哪啦
核心逻辑:
- 定义父类
Animal,包含属性species和方法sayHi()。 - 子类
Cat通过extends Animal继承父类。 - 在
Cat的构造函数中,必须先调用super()来初始化父类部分,然后才能添加自己的属性(name、color)。 - 创建的
Cat实例既能访问自身属性,也能继承并使用父类的属性和方法。 - 核心:
extends实现继承,super()调用父类构造函数,确保子类正确初始化。
七、灵魂总结:JS面向对象的核心逻辑
- 封装:用构造函数/class当"工厂",封装实例化流程,减少重复代码
- 共享 :用
prototype当"共享仓库",存放公共属性/方法,优化内存 - 继承 :用
apply复制父类实例属性,用原型链打通父类共享方法,extends是语法糖 - 原型链:实例→构造函数.prototype→Object.prototype→null,查找属性的"链路规则"
面试高频考点速记:
prototype:构造函数的"共享仓库"__proto__:实例的"借条",指向构造函数的prototypeconstructor:实例的"出生证明",指向创建它的构造函数hasOwnProperty:判断属性是实例自己的还是借的instanceof:判断实例是否属于某个构造函数(查原型链)
最后:你在代码里踩过的坑,都是面试考点
在我的代码里,藏着很多新手常犯的错误,也是面试高频题:
- 构造函数忘记用
new调用,导致this指向window - 把共享方法写在构造函数里,造成内存浪费
- 继承时只复制实例属性,忘了打通原型链,导致父类方法无法调用
写代码如养猫------既要给每只猫独立的个性(实例属性),也要让它们共享本能(原型方法);既要建立清晰的血缘(原型链),又要避免乱贴标签(this误用)。理解了"工厂+仓库+借条"的逻辑,JS面向对象就不再那么难理解了,它会成为你手中驯服复杂系统的温柔工具。
JS 的面向对象,不是冰冷的模板,而是一套有温度的协作机制。
愿你写出的每一行代码,都像照顾一只猫那样------既有结构,又有爱;既能复用,又保留个性。