从“加菲猫”到原型链:前端面试必问的 JavaScript 面向对象底层原理大揭秘!
一句话概括:你以为你在 new 一只猫,其实你是在操控整个宇宙的原型链。
引子:为什么前端面试总爱问“面向对象”?
在字节、阿里、腾讯等一线大厂的前端/全栈岗面试中,JavaScript 的面向对象(OOP)实现机制几乎是必考题。
但奇怪的是——JS 并不是一门“传统”的面向对象语言。它没有类(Class)?不,ES6 有 class;但它真的有“类”吗?答案是否定的。
本文将通过一个贯穿始终的例子——Cat(猫)类的演化史,带你从零开始理解 JS 的 OOP 底层机制,深入剖析:
- 构造函数如何工作?
prototype和__proto__到底是什么关系?- ES6 的
class是语法糖还是真·类? - 如何实现继承?有哪些坑?
- 高频面试题背后的原理到底是什么?
准备好了吗?让我们从一只橘猫开始。
一、原始模式:对象字面量的“手搓”时代
var Cat = {
name: "",
color: ""
}
var cat1 = {};
cat1.name = '加菲猫';
cat1.color = '橘色';
这是最原始的“实例化”方式——手动创建对象并赋值。
问题很明显:重复代码多、无法复用、没有模板约束。
💡 面试延伸:这种写法在性能上其实没问题(现代 JS 引擎对对象字面量高度优化),但工程上不可维护。这也是为什么我们需要“构造函数”。
二、构造函数:new 出来的不只是对象,还有 this 的命运
function Cat(name, color) {
this.name = name;
this.color = color;
}
const cat1 = new Cat('加菲猫', '橘色');
关键点解析:
-
new做了什么?- 创建一个空对象
{}; - 将
this绑定到这个空对象; - 执行构造函数体(给
this添加属性); - 返回该对象(除非显式 return 另一个对象)。
- 创建一个空对象
-
this的指向由调用方式决定:- 普通调用
Cat()→this === window(非严格模式); new Cat()→this指向新实例。
- 普通调用
✅ 面试高频题:
“new操作符的底层实现是什么?”
答案可手写一个myNew函数模拟:
function myNew(Constructor, ...args) {
const obj = Object.create(Constructor.prototype);
const result = Constructor.apply(obj, args);
return (typeof result === 'object' && result !== null) ? result : obj;
}
三、原型模式:方法复用的终极答案
如果我们在构造函数里定义方法:
function Cat(name, color) {
this.eat = function() { console.log("eat jerry"); } // ❌ 每个实例都新建函数!
}
内存浪费! 每只猫都有自己的 eat 函数副本。
正确姿势:挂到 prototype 上
Cat.prototype.eat = function() { console.log("eat jerry"); };
这样所有实例共享同一个方法,节省内存。
核心概念辨析:
| 属性 | 含义 |
|---|---|
Cat.prototype | 构造函数的原型对象,用于存放共享属性/方法 |
cat1.__proto__ | 实例的内部指针,指向 Cat.prototype |
cat1.constructor | 默认指向 Cat(可通过 prototype.constructor 修改) |
🔍 浏览器机制补充:
__proto__是非标准但广泛支持的属性,ES6 后推荐用Object.getPrototypeOf(obj)。
实际上,obj.__proto__ === Object.getPrototypeOf(obj)。
🔍 重要补充:
实例对象本身并不具有constructor属性。当你访问cat1.constructor时,JS 引擎会沿着原型链向上查找,最终在Cat.prototype上找到它。
这就是为什么:
console.log(cat1.hasOwnProperty('constructor')); // false
console.log('constructor' in cat1); // true
如果你重写了 Cat.prototype(例如 Cat.prototype = {}),而没有手动恢复 constructor,那么 cat1.constructor 就会指向 Object,造成类型判断错误!
四、属性查找:hasOwnProperty vs in 运算符
console.log(cat1.hasOwnProperty('type')); // false(type 在 prototype 上)
console.log("type" in cat1); // true(会沿原型链查找)
hasOwnProperty:仅检查自身属性;in运算符:检查自身 + 原型链上的属性。
⚠️ 性能提示:
for...in会遍历原型链上所有可枚举属性,若只想遍历自身属性,务必配合hasOwnProperty使用:
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
// 处理自身属性
}
}
五、ES6 class:不过是披着羊皮的原型狼
class Cat {
constructor(name, color) {
this.name = name;
this.color = color;
}
eat() {
console.log("eat jerry");
}
}
看起来像 Java/C++ 的类?错!
class只是语法糖;eat()方法实际被挂载到Cat.prototype上;new Cat()依然走原型链机制;Cat本质仍是函数(typeof Cat === 'function')。
🧪 验证:
console.log(Cat.prototype.eat === cat1.eat); // true
console.log(cat1.__proto__ === Cat.prototype); // true
结论:ES6 的 class 让代码更清晰,但底层仍是“原型式面向对象”。
六、继承:从 call/apply 到原型链的完整拼图
在实现继承时,我们常需要在子构造函数中“借用”父构造函数来初始化实例属性。这里就涉及 JavaScript 中三大 this 绑定利器:call、apply、bind。
🧠 call / apply / bind 对比(聚焦 this 绑定)
| 方法 | 作用 | 是否立即执行 | 参数形式 | 典型用途 |
|---|---|---|---|---|
fn.call(thisArg, arg1, arg2, ...) | 绑定 this 并立即调用 | ✅ 是 | 逐个参数 | 构造函数借用、方法劫持 |
fn.apply(thisArg, [arg1, arg2, ...]) | 绑定 this 并立即调用 | ✅ 是 | 参数数组 | 传递动态参数列表(如 Math.max.apply(null, arr)) |
fn.bind(thisArg, arg1, arg2, ...) | 返回绑定好 this 的新函数 | ❌ 否 | 可预设参数 | 事件回调、函数柯里化 |
💡 在继承场景中:
我们需要立即执行父构造函数,并让其this指向子实例,因此使用call或apply。
由于构造函数参数通常是固定的,call更语义清晰;若参数来自数组(如arguments或 rest 参数),则用apply。
1. 借用构造函数(仅初始化实例属性,不建立原型继承)
function Animal() {
this.species = '动物';
}
Animal.prototype = {
sayHi: function() {
console.log("hi");
}
}
function Cat(name, color) {
// 在当前子实例上下文中调用父构造函数
// 使用 call:参数明确,语义清晰
Animal.call(this);
// 也可用 apply:Animal.apply(this, []); (无参时等效)
this.name = name;
this.color = color;
}
✅ 效果:子实例获得了
species属性(作为自身属性);
❌ 局限:并未建立原型链,因此无法访问Animal.prototype上的方法(如sayHi)。
💡 关键澄清:
这里并不是“执行父构造函数来继承”,而是借用父构造函数的逻辑来初始化当前对象的属性。
它本质上是一种属性复制技巧,和“继承”无关——真正的继承必须通过原型链实现。
2. 原型链继承(继承方法)
Cat.prototype = new Animal(); // 将 Cat 的原型设为 Animal 实例
现在 cat.sayHi() 可以调用了!
但问题来了:
new Animal()会执行一次父构造函数(可能带副作用);- 所有
Cat实例共享Animal实例的属性(若Animal有引用类型属性,会互相污染)。
3. 组合继承(经典方案)
function Cat(name, color) {
// 借用父构造函数,初始化实例属性
Animal.call(this);
this.name = name;
this.color = color;
}
// 建立原型链,继承方法
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向
✅ 通过构造函数借用初始化实例属性,通过原型链继承方法;
❌ 调用了两次Animal(一次在call,一次在new)。
4. 寄生组合继承(最优解)
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Object.create(proto)创建一个新对象,其__proto__指向proto;- 避免了调用
Animal构造函数; - 完美继承,无副作用。
🌟 面试加分项:
ES6 的class extends底层正是基于寄生组合继承 +super()机制实现的。
而super()内部就用到了类似Parent.call(this)的逻辑来初始化父类属性。
七、高频面试题关联 & 深度思考
Q1:instanceof 的原理是什么?
一、先理解:什么是 “instance(实例)”?
在面向对象编程中, “实例” 指的是通过构造函数(或类)创建出来的具体对象。
例如:
function Cat(name) {
this.name = name;
}
const tom = new Cat('Tom');
Cat是一个构造函数(模板) ;tom是Cat的一个 实例(instance) ;- 我们说:“
tom是Cat的一个实例”。
而 instanceof 运算符的作用,就是判断某个对象是否是某个构造函数的实例。
console.log(tom instanceof Cat); // true
二、instanceof 的核心原理
obj instanceof Constructor的本质是:
检查Constructor.prototype是否出现在obj的原型链(prototype chain)上。
换句话说:
- JS 不关心“你是怎么创建的”,只关心“你的原型链上有没有这个构造函数的原型”。
原型链回顾:
每个对象都有一个内部属性 [[Prototype]](可通过 __proto__ 或 Object.getPrototypeOf() 访问),它指向其构造函数的 prototype 对象。
这条从对象 → 原型 → 原型的原型 …… 直到 null 的链条,就叫原型链。
tom.__proto__ === Cat.prototype
Cat.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
所以当执行 tom instanceof Cat 时,JS 引擎会沿着 tom 的原型链向上查找,看是否能找到 Cat.prototype。
Q2:如何判断一个属性是自身的还是继承的?
答:用 hasOwnProperty。
Q3:prototype 和 __proto__ 的区别?
prototype:函数才有的属性,用于构建实例的原型;__proto__:所有对象都有,指向其构造函数的prototype。
📌 记住:
obj.__proto__ === obj.constructor.prototype(前提是未修改过原型链)
Q4:为什么说 JS 是“基于原型”的语言?
因为:
- 没有“类”的概念(即使有
class,也是模拟); - 对象直接从其他对象继承(通过
__proto__); - 动态性极强:运行时可修改原型,影响所有实例。
结语:你 new 的不是猫,是 JavaScript 的灵魂
从对象字面量 → 构造函数 → 原型模式 → ES6 class → 继承体系,
我们看到的不仅是一只猫的成长史,更是 JavaScript 面向对象设计哲学的演进。
在大厂面试中,能讲清楚:
new的过程、- 原型链查找机制、
- 继承的多种实现及优劣、
class的本质,
你就已经超越了 80% 的候选人。
最后送大家一句哲理:
“在 JS 的世界里,万物皆对象,而对象皆可被 new。”
参考资料 & 延伸阅读:
- MDN: Inheritance and the prototype chain
- 《你不知道的 JavaScript(上卷)》—— Kyle Simpson
- V8 引擎源码中的对象模型(Hidden Classes)