Dark Dwarf Blog background

JavaScript 原型链

JavaScript 原型链

1. 原型的引入

我们可以在构造函数里为对象添加方法,但这存在一个严重的性能问题。

function Player(name, marker) {
  this.name = name;
  this.marker = marker;
  
  // 每次 new Player 都会创建一个新的 sayName 函数
  // 这是极大的内存浪费!
  this.sayName = function() {
    console.log(this.name);
  };
}

// 使用 `new` 来创建 Player 对象实例
const player1 = new Player('steve', 'X');
const player2 = new Player('also steve', 'O');

console.log(player1.name); // 'steve'
console.log(player2.marker); // 'O'

在上面的例子中,player1player2 各自拥有一个完全独立但功能相同的 sayName 函数。如果有成千上万个玩家,就会有成千上万个重复的函数实例,这非常浪费内存。原型正是为了解决这个问题而生的。

2. 原型

a.a. 原型性质

JavaScript的原型有如下性质:

  1. 所有对象都有一个原型: 在 JavaScript 中,几乎所有对象在创建时都会被赋予一个“原型”对象。
  2. 原型是另一个对象: 原型本身也是一个普通对象,可以拥有自己的属性和方法。
  3. 对象继承自原型: 对象可以访问其原型上的所有属性和方法,就好像这些是它自己的一样。这种关系被称为“继承”。

可以把对象实例(如 player1)想象成一个学生,而原型(Player.prototype)就是学校的《学生手册》。学生有自己的姓名、学号(实例属性)。而《学生手册》规定了所有学生都应遵守的校规(原型方法)。所有学生共享同一本《学生手册》,而无需每个人都复制一份。

构造函数、实例和原型三者之间存在一个至关重要的“三角关系”:

  • 每个构造函数都有一个 prototype 属性,它指向一个对象(原型对象)。
  • 通过该构造函数创建的每个实例,其内部都有一个链接(在规范中是 [[Prototype]])指向这个原型对象。
  • 我们可以使用 Object.getPrototypeOf(实例) 来访问这个原型对象。
function Player(name) {
  this.name = name;
}
const player1 = new Player('steve');

// 验证这个“三角关系”
console.log(Object.getPrototypeOf(player1) === Player.prototype); // true

于是,为了解决构造函数中方法重复的问题,我们应该把需要共享的方法定义在构造函数的 prototype 上。

function Player(name, marker) {
  this.name = name;
  this.marker = marker;
}

// 将 sayName 方法定义在原型上
Player.prototype.sayName = function() {
  console.log(this.name);
};

const player1 = new Player('steve', 'X');
const player2 = new Player('also steve', 'O');

player1.sayName(); // 'steve'
player2.sayName(); // 'also steve'

// 两个实例共享同一个 sayName 函数
console.log(player1.sayName === player2.sayName); // true

3. 原型链

a.a. 属性查找机制

当我们访问一个对象的属性(如 player1.sayName)时,JavaScript 引擎会遵循以下路径查找:

  1. 在对象自身上查找: player1 对象自己有 sayName 属性吗?没有。
  2. 去其原型上查找: player1 的原型是 Player.prototypePlayer.prototype 上有 sayName 吗?有!引擎找到并执行它。
  3. 继续向上查找: 如果 Player.prototype 上也没有,引擎会继续查找 Player.prototype 的原型,一直找到原型链的终点。

这条从实例到原型,再到原型的原型等组成的查找路径,就是原型链

几乎所有 JavaScript 对象的原型链最终都会指向 Object.prototype。这里包含了很多通用的方法,如 .toString(), .hasOwnProperty() 等。Object.prototype 的原型是 null,代表原型链的终点。

如果在整条链上都找不到某个属性,则返回 undefined

b.b. hasOwnProperty 方法

使用 .hasOwnProperty() 方法可以检查一个属性是否是对象自身的,而不是从原型链上继承来的。

player1.hasOwnProperty('name');      // true, name 是 player1 自己的属性
player1.hasOwnProperty('sayName'); // false, sayName 是从原型继承的

3.继承

原型链是 JavaScript 实现继承的根本方式。下面是实现继承的一些方式:

a.a. ES6 Class

ES6 引入了 class 关键字,它只是原型继承的“语法糖”,但写法更清晰、更符合传统面向对象的思想。

class Person {
  constructor(name) {
    this.name = name;
  }
  sayName() {
    console.log(`Hello, I'm ${this.name}!`);
  }
}

class Player extends Person {
  constructor(name, marker) {
    super(name); // 调用父类的 constructor
    this.marker = marker;
  }
  getMarker() {
    console.log(`My marker is '${this.marker}'`);
  }
}

const player1 = new Player('steve', 'X');
player1.sayName();   // "Hello, I'm steve!" (继承自 Person)
player1.getMarker(); // "My marker is 'X'" (自己的方法)

b.b. 组合继承

在 ES6 之前,最常用的继承方式是“组合继承”(构造函数窃取 + 原型链继承),具体代码如下:

function Person(name) {
  this.name = name;
}
Person.prototype.sayName = function() { console.log(`I'm ${this.name}`); };

function Player(name, marker) {
  // 1. 构造函数窃取:继承实例属性
  Person.call(this, name);
  this.marker = marker;
}

// 2. 原型链继承:继承原型方法
// 创建一个继承自 Person.prototype 的新对象作为 Player 的原型
Player.prototype = Object.create(Person.prototype);

// 3. 修正 constructor 指向
Player.prototype.constructor = Player;

// 在 Player 的新原型上添加自己的方法
Player.prototype.getMarker = function() { console.log(`Marker: ${this.marker}`); };

const player1 = new Player('steve', 'X');
player1.sayName();   // "I'm steve"
player1.getMarker(); // "Marker: X"

在构造函数窃取中,我们通过 Personcall 方法来改变函数的 this 指向。为了让每个子类实例都拥有一份独立的、不共享的父类属性。如果 Person 有很多属性,通过 call(),这些属性都会被复制到新的 Player 实例上,而不是所有 Player 实例共享一份。

然后,我们通过下面的步骤创建原型链:

  1. 创建一个新对象。这个新对象的原型是 Person.prototype
  2. 把这个新对象赋值给 Player.prototype

这样,我们就建立了 player1 实例 -> Player.prototype -> Person.prototype 的原型链。

在完成这个原型链的搭建后,我们还需要做一些收尾工作:我们用 Object.create() 创建的原型的 constrctor 属性是 Person 的,我们需要把它指向 Player 的构造函数本身。

注意,不要直接赋值原型:Player.prototype = Person.prototype;,这不会创建继承链,而是让 Player.prototypePerson.prototype 指向了同一个对象。修改任何一方的原型都会影响另一方,导致严重的 bug。