原型与原型链

1 基础概念#

  • js 分为函数对象普通对象,每个对象都有 __proto__ 属性,但是只有函数对象才有 prototype 属性。
  • Object、Function都是js内置的函数, 类似的还有我们常用到的Array、RegExp、Date、Boolean、Number、String 等。

2 理解原型对象#

  函数被创建时,会根据一组特定的规则为该函数创建一个 prototype 属性。该属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数) 属性,该属性是一个指向 prototype 属性所在函数的指针。

function Person() {
}
Person.prototype.name = 'Tom';
Person.prototype.age = 27;
Person.prototype.sayHello = function () {
console.log('hello ' + this.name);
}
let person1 = new Person();
person1.sayHello(); // hello Tom
let person2 = new Person();
person2.sayHello(); // hello Tom
console.log(person1.sayHello === person2.sayHello) // true

  创建构造函数 Person 后,其原型对象默认获得 constructor 属性,并从 Object 继承相应的属性及方法。

2.1 prototype#

  需要注意的是只有函数对象才有且必有 prototype 属性。上文中说到函数的 prototype 属性指向函数的原型对象,那么,什么是原型呢?可以理解为:每一个 JavaScript 对象(null除外)在创建时会将其与另一个对象关联,这个被关联的对象即为原型,每一个对象都会从原型“继承”属性。

note

问题1:为什么说每个 js 对象都会有原型,而上文说,只有函数对象才会有 prptotype

为便于理解构造函数与原型对象之间的关系,可看下图:

此时,回到问题1,每个 js 对象(包括函数对象及普通对象)都会有原型,对于函数对象来说,可以通过 prototype 属性访问到其原型,那么对于普通对象来说,怎样访问到其原型呢?

2.2 __proto__#

当调用构造函数创建一个新实例时,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262 第5版中管这个指针叫 [[Prototype]] 。虽然在脚本中没有标准的方式访问[[Prototype]],但 Firefox、Safari和 Chrome 在每个对象上都支持一个属性 __proto__;而在其他实现中,这个属性对脚本则是完全不可见的。需要注意的是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。如图所示:

3 原型的原型#

  上文中提到,每一个 js 对象都会有原型,那么原型对象也会拥有原型。原型对象是通过 Object 构造函数生成的,即隐式调用Person.prototype = new Object(),那么原型对象作为 Object 的实例,其 __proto__ 属性指向构造函数的 prototype,即 Object.prototype

4 原型链#

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么,假如让原型对象等于另一个原型对象的实例,那么此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含这一个指向另一个构造函数的指针。假如另一个原型对象又是再一个原型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链接,即原型链的概念。

  • 构造函数 Person 作为实例对象时,隐式调用了 Person = new Function()。因此,构造函数 Person 的__proto__ 属性指向 Function.prototype
  • Function.prototype也是对象,故隐式调用Function.prototype = new Object(),因此,其 __proto__ 属性指向Object.prototype
  • Object.prototype 也是对象,其__proto__ 属性指向 null,原型链的顶层。

tip

(1)对象都有原型对象,对象默认继承自其原型对象

(2)所有的函数都是 Function 的实例

(3)所有的原型链尾端都会指向Object.prototype

5 原型的动态性#

在原型中查找值的过程是一次搜索,因此对原型对象所做的任何修改都能够立即反映到实例对象上,即使是先创建实例后修改原型。

function Person(name) {
this.name = name;
};
// 创建实例对象(修改原型对象之前)
let p1 = new Person('Tom');
// 添加原型属性
Person.prototype.sayHi = function () {
console.log('hi');
}
// 创建实例对象(修改原型对象之后)
let p2 = new Person('Jerry');
p1.sayHi(); // hi
p2.sayHi(); // hi
console.log(p1.__proto__ === p2.__proto__); // true

  上例中,实例 p1 是在原型对象添加新属性之前创建的,但 p1 仍然可以访问新添加的方法。其原因在于实例与原型之间的连接是一个指针。当调用 p1.sayHi() 时,首先会从实例中搜索名为 sayHi 的属性,若未找到,则继续搜索原型。此外,无论实例是在修改原型属性之前或之后创建,其原型链均相同,即:

p1.__proto__ === p2.__proto__

注意,若重写整个原型对象,则会改变实例对象的原型链

function Person(name) {
this.name = name;
};
// 创建实例对象(修改原型对象之前)
let p1 = new Person('Tom');
// 添加原型属性
Person.prototype = {
sayHi: function () {
console.log('hi');
}
}
// 创建实例对象(修改原型对象之后)
let p2 = new Person('Jerry');
console.log(p1.__proto__ === p2.__proto__); // false
p2.sayHi(); // hi
p1.sayHi(); // TypeError: p1.sayHi is not a function

6 原型对象的缺点#

  上文的例子提到对原型对象所做的任何修改都能够立即反映到实例对象上,尽管这一共享的特性带来了便捷,但这个也恰恰是其最大的问题所在。

  原型中所有属性是被其实例共享的,这种共享对于函数非常合适。对于基本类型的属性则可以通过在实例上添加一个同名属性,隐藏原型中的对应属性。然而对于包含引用类型值的属性来说,问题则比较突出,如:

function Person() {
}
Person.prototype = {
constructor: Person,
name: 'Tom',
friends: ['A', 'B'],
}
let p1 = new Person();
let p2 = new Person();
p1.friends.push('C');
console.log(p1.friends); // ["A", "B", "C"]
console.log(p2.friends); // ["A", "B", "C"]
console.log(p1.friends === p2.friends); // true

例子中,由于 friends 数组存在于 Person.prototype 而非 p1 中,因此 p1.friends 的改变也会反映到 p2.friends 中。