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'
在上面的例子中,player1
和 player2
各自拥有一个完全独立但功能相同的 sayName
函数。如果有成千上万个玩家,就会有成千上万个重复的函数实例,这非常浪费内存。原型正是为了解决这个问题而生的。
2. 原型
原型性质
JavaScript的原型有如下性质:
- 所有对象都有一个原型: 在 JavaScript 中,几乎所有对象在创建时都会被赋予一个“原型”对象。
- 原型是另一个对象: 原型本身也是一个普通对象,可以拥有自己的属性和方法。
- 对象继承自原型: 对象可以访问其原型上的所有属性和方法,就好像这些是它自己的一样。这种关系被称为“继承”。
可以把对象实例(如
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. 原型链
属性查找机制
当我们访问一个对象的属性(如 player1.sayName
)时,JavaScript 引擎会遵循以下路径查找:
- 在对象自身上查找:
player1
对象自己有sayName
属性吗?没有。 - 去其原型上查找:
player1
的原型是Player.prototype
。Player.prototype
上有sayName
吗?有!引擎找到并执行它。 - 继续向上查找: 如果
Player.prototype
上也没有,引擎会继续查找Player.prototype
的原型,一直找到原型链的终点。
这条从实例到原型,再到原型的原型等组成的查找路径,就是原型链。
几乎所有 JavaScript 对象的原型链最终都会指向 Object.prototype
。这里包含了很多通用的方法,如 .toString()
, .hasOwnProperty()
等。Object.prototype
的原型是 null
,代表原型链的终点。
如果在整条链上都找不到某个属性,则返回
undefined
。
hasOwnProperty
方法
使用 .hasOwnProperty()
方法可以检查一个属性是否是对象自身的,而不是从原型链上继承来的。
player1.hasOwnProperty('name'); // true, name 是 player1 自己的属性
player1.hasOwnProperty('sayName'); // false, sayName 是从原型继承的
3.继承
原型链是 JavaScript 实现继承的根本方式。下面是实现继承的一些方式:
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'" (自己的方法)
组合继承
在 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"
在构造函数窃取中,我们通过 Person
的 call
方法来改变函数的 this
指向。为了让每个子类实例都拥有一份独立的、不共享的父类属性。如果 Person 有很多属性,通过 call()
,这些属性都会被复制到新的 Player
实例上,而不是所有 Player
实例共享一份。
然后,我们通过下面的步骤创建原型链:
- 创建一个新对象。这个新对象的原型是
Person.prototype
。 - 把这个新对象赋值给
Player.prototype
。
这样,我们就建立了 player1 实例 -> Player.prototype -> Person.prototype
的原型链。
在完成这个原型链的搭建后,我们还需要做一些收尾工作:我们用 Object.create()
创建的原型的 constrctor
属性是 Person
的,我们需要把它指向 Player
的构造函数本身。
注意,不要直接赋值原型:
Player.prototype = Person.prototype;
,这不会创建继承链,而是让Player.prototype
和Person.prototype
指向了同一个对象。修改任何一方的原型都会影响另一方,导致严重的 bug。