一、原型(prototype)
- prototype属性和[[prototype]]内部属性
1.1. 任何一个函数(箭头函数除外),都有一个prototype属性,它指向prototype对象。prototype对象中有一个constructor属性,constructor又指向函数本身。
function foo() {
console.log('hello');
}
foo.prototype.constructor == foo; //true
1.2. 任何一个对象,都有一个内部的[[prototype]]属性,它指向这个对象的构造函数的prototype对象。[[prototype]]是ECMAScript定义的内部属性,在脚本中不可直接访问,但浏览器对每个对象都支持一个__proto__属性,脚本可以访问__proto__,等同于[[prototype]]。
const obj = {}; //等同于const obj = new Object({});
obj.__proto__ == Object.prototype; //true
- 构造函数
2.1. 构造函数也是普通的函数,所以它也有prototype属性。prototype的constructor指向构造函数本身。
function Food(name) {
this.name = name;
}
Food.prototype.constructor == Food; //true
2.2. 构造函数如果不使用new关键字调用,跟普通函数没有区别。如果使用new关键字调用,将按照以下步骤执行。
- 创建一个新对象。
- 将this指向这个新对象。
- 执行构造函数的代码。
- 返回这个新对象。
function Food(name) {
this.name = name;
this.eat = function () {
console.log('eat!');
}
}
const food = new Food('apple');
food.__proto__ == Food.prototype; //true
- 原型对象
原型对象的用途是它可以包含所有实例共享的属性和方法。
在下面的代码中,当eat方法被包含在构造函数中时,food1和food2被实例化时,将会分别创建一个eat方法。同理,当有很多实例时,那么将会有很多eat方法被创建出来,这无疑会造成资源的浪费。
而将eat方法放在构造函数的prototype对象中,如代码中的NewFood所示,不论创建多少个实例,他们都共用eat方法。这样就通过原型对象,实现了方法(或属性)的共享。
function Food(name) {
this.name = name;
this.eat = function () {
console.log('eat!');
}
}
const food1 = new Food('apple');
const food2 = new Food('banana');
food1.eat == food2.eat; // false
function NewFood(name) {
this.name = name;
}
NewFood.prototype.eat = function () {
console.log('eat!');
}
const newFood1 = new NewFood('apple');
const newFood2 = new NewFood('banana');
newFood1.eat == newFood2.eat; // true
- 查找属性
当代码读取对象的属性时,会按照特定的顺序来执行搜索。
function Food(name) {
this.name = name;
}
Food.prototype.name = 'pear';
Food.prototype.eat = function () {
console.log('eat!');
}
const food = new Food('apple');
console.log(food.name); // apple
结合上图分析,查找food.name的过程,首先在实例本身查找,找到属性name,那么停止查找。查找food.eat(),也是先在实例本身查找,并没有找到,则通过内部的[[prototype]]在构造函数的prototype对象中查找,找到了,则执行food.eat().
二、原型链
- 链的形成
在上面的图中,food的内部指针[[prototype]]指向了构造函数的原型对象,那么如果又有另一个构造函数的原型,指向了food,那么就形成了一个由原型组成的链。
function Food(name) {
this.name = name;
}
Food.prototype.eat = function() {
console.log('eat!');
}
function Vegetable(name, color) {
this.name = name;
this.color = color;
}
Vegetable.prototype = new Food(); // 链的形成
const tomato = new Vegetable('tomato', 'red');
console.log(tomato.name); // 输出 tomato
console.log(tomato.color); // 输出 red
tomato.eat(); // 输出 eat!
- 属性查找
当脚本访问对象的属性时,将沿着原型链向上寻找。具体来说,首先查找对象本身,如果没有找到,则在构造函数的原型中寻找,如果还是没有找到,则继续向上,向构造函数的原型的[[prototype]]所指向的原型对象中去寻找,以此类推。
- 实现方法
在上面的栗子中,Food的属性值都是基本数据类型,但如果属性值是引用类型,例如在下面代码中新增的regions属性,tomato和potato将会共用属性regions。修改了tomato的regions属性,发现potato的regions属性也跟着变化了。这是因为通过继承,regions出现在了Vegetable的原型对象中,所以,Vegetable的所有实例都共享了regions。
function Food(name) {
this.name = name;
this.regions = ['north', 'south']; // 属性值是引用类型
}
Food.prototype.eat = function() {
console.log('eat!');
}
function Vegetable(name, color) {
this.name = name;
this.color = color;
}
Vegetable.prototype = new Food();
const tomato = new Vegetable('tomato', 'red');
const potato = new Vegetable('potato', 'yellow');
console.log(`tomato: ${tomato.regions}`); // tomato: ['north', 'south']
console.log(`potato: ${potato.regions}`); // potato: ['north', 'south']
tomato.regions.push('east');
console.log(`tomato: ${tomato.regions}`); // tomato: ['north', 'south', 'east']
console.log(`potato: ${potato.regions}`); // potato: ['north', 'south', 'east']
如下图所示,在访问tomato.regions的时候,先在tomato实例本身查找regions,没有找到,则到tomato.__proto__所指的原型对象中去搜索,找到了regions,将它修改为[‘north’, ‘south’, ‘east’]。当访问potato.regions的时候,同理会访问到原型对象的regions,所以得到的结果是[‘north’, ‘south’, ‘east’]。
假如你的需求场景,确实是要共用regions的,这当然没有问题。但通常情况下,我们都是希望实例间可以保持属性的私有和方法的共享。
在实际工作中,应用最广泛的继承实现方法是组合继承( combination inheritance),也叫做伪经典继承。它综合利用原型和构造函数的优点,实现了实例之间的属性私有和方法共享。
function Food(name) {
// 把需要私有的属性,放在构造函数内部
this.name = name;
this.regions = ['north', 'south'];
}
// 把需要被共享的方法,放在原型对象中
Food.prototype.eat = function() {
console.log('eat!');
}
function Vegetable(name, color) {
// 通过call方法,使得Food的name和regions属性,也成为了Vegetable的属性,
// 这样既实现了Vegetable对Food的属性的继承,
// 又使这些属性对Vegetable的所有实例来说,是私有的
Food.call(this, name);
this.color = color;
}
Vegetable.prototype = new Food();
const tomato = new Vegetable('tomato', 'red');
const potato = new Vegetable('potato', 'yellow');
console.log(`tomato: ${tomato.regions}`); // tomato: ['north', 'south']
console.log(`potato: ${potato.regions}`); // potato: ['north', 'south']
tomato.regions.push('east');
console.log(`tomato: ${tomato.regions}`); // tomato: ['north', 'south', 'east']
console.log(`potato: ${potato.regions}`); // potato: ['north', 'south']
结合代码和下图分析,当访问tomato.regions的时候,首先查找tomato本身,找到了regions属性,则执行push操作。由于tomato修改的是它本身的regions属性,所以potato.regions不受影响。
得到这个结果的根本原因在于:当访问tomato.regions的时候,根据属性查找的顺序规则,原型对象上的regions属性被屏蔽掉了。
总结
- ECMAScript把原型链作为实现继承的最主要方法。
- 原型链的基本思想是:利用原型,让一个引用类型继承另一个引用类型的属性和方法。
- 原型链的实现方式:每一个构造函数,都包含一个原型对象,而构造函数的实例也有一个内部指针,指向原型对象。如果让这个实例等于另一个构造函数的原型对象,那么就形成了一个链,是由原型组成的链,也就是原型链。