深入浅出:JavaScript 面向对象编程的演进之路与原型链继承详解

15 阅读6分钟

JavaScript 是一门基于对象(Object-based)的语言,但在很长一段时间里,它并没有像 Java 或 C++ 那样完善的类(Class)机制。这导致初学者在处理对象的生成与继承时,往往会感到困惑。也不能怪大家,因为在JavaScript在创造之初,本来就是一个赶鸭子上架的kpi项目,创造者为了可以快速完成,更多的是考虑它的便捷,简单易上手(对比其他语言这是不得不承认的一点),这就是为什么大多数人在刚学习这门语言时,会有很强的不适应性,也是为什么JavaScript之前有重大更新的原因之一

本文将剥开语法的表象,沿着 JavaScript 语言设计的演进脉络,从最原始的对象字面量出发,一步步推导至现代的 ES6 Class 语法,并重点解析原型链继承的核心机制。

第一阶段:混沌初开(对象字面量)

在最早期,如果我们想要描述一只猫,我们可能会直接创建一个对象。这是最直观的方式,被称为“对象字面量”。

JavaScript

// 描述一只猫
var cat1 = {
    name: "加菲猫",
    color: "橘色"
};

// 描述另一只猫
var cat2 = {
    name: "小白",
    color: "白色"
};

这种写法虽然简单,但在实际开发中存在两个严重的痛点:

  1. 代码冗余:如果我们需要创建 100 只猫,就必须重复写 100 次结构相同的代码。
  2. 缺乏类型关联:cat1 和一个描述用户的对象 user1 在结构上没有任何本质区别,我们无法从代码层面看出它们属于同一个“类别”(即缺乏模板的概念)。

第二阶段:工业化生产(构造函数模式)

为了解决代码重复和缺乏模板的问题,我们可以借鉴工厂模式的思维,使用函数来封装实例化的过程。在 JavaScript 中,这种函数被称为构造函数。按照开发约定,构造函数的首字母应当大写。

JavaScript

// 定义一个“猫”的构造函数(类模板)
function Cat(name, color) {
    // 这里的 this 指向即将在 new 过程中创建的新对象
    this.name = name; 
    this.color = color;
    
    // 这是一个实例方法
    this.eat = function() {
        console.log("吃东西");
    }
}

// 使用 new 关键字实例化对象
var cat1 = new Cat("加菲猫", "橘色");
var cat2 = new Cat("黑猫警长", "黑色");

console.log(cat1.constructor === cat2.constructor); // true

核心知识点:new 关键字到底做了什么?

当我们在调用函数前加上 new 关键字时,JavaScript 引擎会在后台执行以下操作:

  1. 创建一个全新的空对象。
  2. 将构造函数中的 this 指向这个新对象。
  3. 执行构造函数中的代码(为这个新对象添加属性)。
  4. 返回这个新对象。

痛点分析
虽然构造函数解决了属性的封装问题,但它带来了一个严重的内存浪费问题。在上面的代码中,eat 方法被定义在构造函数内部。这意味着,每当我们 new 一个实例,都会在内存中重新创建一遍 eat 函数。如果有成千上万个实例,这将消耗大量不必要的内存。

第三阶段:共享经济(原型模式 Prototype)

为了解决方法重复创建的问题,JavaScript 引入了 prototype(原型)  机制。我们将特有的属性(如名字、颜色)放在构造函数中,而将公有的属性和方法放在原型对象上。

JavaScript

function Cat(name, color) {
    this.name = name;
    this.color = color;
}

// 将公共方法挂载到原型上
// 所有 Cat 的实例将共享同一个 eat 方法,不会重复创建
Cat.prototype.type = "猫科动物";
Cat.prototype.eat = function() {
    console.log("eat jerry");
}

var cat1 = new Cat("Tom", "蓝色");

// 验证属性来源
console.log(cat1.hasOwnProperty("name")); // true,这是实例自己的属性
console.log(cat1.hasOwnProperty("type")); // false,这是原型上的属性
console.log("type" in cat1); // true,in 操作符会查找原型链

核心辨析

  • constructor:原型对象上有一个 constructor 属性,指向构造函数本身。这让我们知道 cat1 是由 Cat 生产出来的。
  • hasOwnProperty:用于区分一个属性是属于实例自身,还是继承自原型。
  • 原型链查找原则:当我们访问 cat1.eat() 时,引擎首先在 cat1 自身查找;如果没有找到,就会顺着 proto 找到 Cat.prototype 上的 eat 方法。

第四阶段:继承的难题(核心重点)

在面向对象编程中,继承是最核心但也最复杂的概念。假设我们有一个父类 Animal,如何让 Cat 继承 Animal 的属性和方法?

JavaScript

// 父类
function Animal() {
    this.species = '动物';
}
Animal.prototype.sayHi = function() {
    console.log('我是动物');
}

尝试一:借用构造函数(继承属性)

我们可以利用 call 或 apply 方法,在子类构造函数中调用父类构造函数,并强行绑定 this。

JavaScript

function Cat(name, color) {
    // 核心操作:将 Animal 的 this 指向当前的 Cat 实例
    // 这样 Cat 实例就拥有了 species 属性
    Animal.call(this); 
    
    this.name = name;
    this.color = color;
}

var cat = new Cat("加菲猫", "橘色");
console.log(cat.species); // "动物" —— 属性继承成功
// cat.sayHi(); // 报错!—— 方法继承失败

缺陷:这种方式只能继承父类构造函数内部定义的属性,无法访问父类原型(Animal.prototype)上定义的方法。

尝试二:原型链继承(继承方法)

为了继承方法,我们需要将子类的原型指向父类的一个实例。

JavaScript

// 核心操作:打通原型链
// Cat.prototype 变成了 Animal 的一个实例
// 所以 Cat 的实例可以访问 Animal.prototype 上的方法
Cat.prototype = new Animal();

// 修正 constructor 指向(虽然不修正也能跑,但为了严谨性建议修正)
Cat.prototype.constructor = Cat; 

var cat = new Cat("加菲猫", "橘色");
cat.sayHi(); // "我是动物" —— 方法继承成功

终极方案:组合继承

在 ES6 之前,最标准的继承方式是结合上述两种方法:在构造函数中借用 call 继承属性,利用原型链继承方法

JavaScript

function Cat(name, color) {
    // 1. 继承属性
    Animal.call(this); 
    this.name = name;
    this.color = color;
}

// 2. 继承方法
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

var cat = new Cat("Tom", "Blue");
// 既有属性,又有方法,完美!

第五阶段:返璞归真(ES6 Class 语法糖)

到了 ES6,JavaScript 终于引入了 class 和 extends 关键字,让写法看起来更像传统的面向对象语言。

JavaScript

// 现代写法
class Cat extends Animal {
    constructor(name, color) {
        super(); // 相当于 Animal.call(this)
        this.name = name;
        this.color = color;
    }
    
    eat() {
        console.log("eat jerry");
    }
}

const cat1 = new Cat('tom', '蓝色');
cat1.eat();

本质揭秘:只是语法糖

千万不要被 class 迷惑。在底层,JavaScript 依然是基于原型的语言。我们可以通过打印代码来验证这一点:

JavaScript

console.log(typeof Cat); // "function" —— Cat 本质上还是一个函数
console.log(cat1.__proto__ === Cat.prototype); // true —— 原型机制依然存在
console.log(cat1.__proto__.constructor === Cat); // true

ES6 的 class 只是将我们在第四阶段手动实现的“组合继承”逻辑进行了语法层面的封装,使其更易读、更易写。

总结

从对象字面量的复制粘贴,到构造函数的封装,再到利用原型链实现资源共享与继承,JavaScript 的 OOP 之路展示了其独特的设计哲学。

理解原型链不仅是为了应对面试,更是为了深刻理解 JavaScript 对象系统的本质:它不是通过复制模板来生成对象,而是通过“链条”将对象关联起来,实现属性和方法的查找与复用。