从“加菲猫”到原型链:前端面试必问的 JavaScript 面向对象底层原理大揭秘!

79 阅读8分钟

从“加菲猫”到原型链:前端面试必问的 JavaScript 面向对象底层原理大揭秘!

一句话概括:你以为你在 new 一只猫,其实你是在操控整个宇宙的原型链。


引子:为什么前端面试总爱问“面向对象”?

在字节、阿里、腾讯等一线大厂的前端/全栈岗面试中,JavaScript 的面向对象(OOP)实现机制几乎是必考题。
但奇怪的是——JS 并不是一门“传统”的面向对象语言。它没有类(Class)?不,ES6 有 class;但它真的有“类”吗?答案是否定的。

本文将通过一个贯穿始终的例子——Cat(猫)类的演化史,带你从零开始理解 JS 的 OOP 底层机制,深入剖析:

  • 构造函数如何工作?
  • prototype__proto__ 到底是什么关系?
  • ES6 的 class 是语法糖还是真·类?
  • 如何实现继承?有哪些坑?
  • 高频面试题背后的原理到底是什么?

准备好了吗?让我们从一只橘猫开始。

image.png


一、原始模式:对象字面量的“手搓”时代

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('加菲猫', '橘色');

关键点解析:

  1. new 做了什么?

    • 创建一个空对象 {}
    • this 绑定到这个空对象;
    • 执行构造函数体(给 this 添加属性);
    • 返回该对象(除非显式 return 另一个对象)。
  2. 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')); // falsetype 在 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 绑定利器:callapplybind

🧠 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 指向子实例,因此使用 callapply
由于构造函数参数通常是固定的,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。”


参考资料 & 延伸阅读