首先,先看一下原型模式的例子:
function Person() {}
Person.prototype.name = "windy-boy"
Person.prototype.age = '18'
Person.prototype.eat = function() {
console.log('吃饭')
}
const p1 = new Person()
const p2 = new Person()
console.log(p1.name) // windy-boy
console.log(p2.name) // windy-boy
构造函数Person里面啥也不写,所有需要定义的属性和方法都存放在构造函数一个名叫prototype的属性中。先不管prototype具体是什么,这里先把它当成一个容器。Person这个粑粑他有一个叫prototype的容器,他把所有以后他子女们需要用到的东西放到这个容器里面,比如放了一把刀,那么以后他的子女需要用刀的时候自行去拿,但是,因为他只放了一把刀,而且这个prototype容器也不具备克隆功能,所以,这把刀就具有了唯一性,所有的子女用的都是同一把刀。也就是说,上面的例子中p1.eat === p2.eat是true的。回想一下构造函数模式的一大弊端,就是在实例化的时候都会重新创建方法,即p1.eat !== p2.eat。原型模式就把这个问题解决了。那么接下来,就是要具体来理解一下这个prototype了。
理解原型对象
每个函数在创建的时候都会生成一个对象(用于存储特定类型的所有实例共享的属性和方法),这个对象就是通过构造函数来创建的那个对象的原型对象。上面例子中,Person.prototype就是p1和p2的原型对象。
这个时候就构成了一个三角关系了(严格来说并非三角关系,因为实例和构造函数之间并非直接联系起来的),构造函数、原型对象、实例。
先说构造函数和原型对象,上面例子中,我们把原型对象比作一个容器,但是,又如何得知这个容器是谁的呢?虽然你说我有绳子绑着呢(prototype),但这就好比牵了一头牛就说这头牛是你的了?还得这头牛认你才行啊。所以这个容器又怎么和构造函数确立关系呢,原来自这个容器诞生起,就有一个脐带连接着Person,这个脐带就叫做constructor。也就是说,原型对象通过这个属性可以访问到构造函数。这个时候,构造函数和原型对象就互通了,构造函数通过prototype访问原型对象,原型对象通过constructor访问构造函数。那么实例又是如何和前面两者搭上关系的呢?这都要归功于[[Prototype]],它会指向原型对象。但这个东东没有标准的方式能够访问到它。虽然没有标准的方式访问,但是主流浏览器都支持一个叫__proto__的属性,通过它实例就能够访问到原型对象。
画个草图表示一下三者之间的关系:
需要注意的几个方法
- isPrototypeOf()
这个方法是原型对象的方法
Person.prototype.isPrototypeOf(p1) // true
通过这个方法可以判断实例是否是构造函数的实例。
- getPrototypeOf()
es5新增的一个方法,Object.getPrototypeOf()。可以获取一个对象的原型
Object.getPrototypeOf(p1) === Person.prototype // true
- hasOwnProperty()
判断属性是否存在对象实例中,实例方法,继承自Object。通过它可以区分属性是实例属性还是原型属性
p1.hasOwnProperty('name') //false
p1.name = 'windy-boy'
p1.hasOwnProperty('name') //true
当然,只有它还是不够的,它只能确立是否是实例属性。如果它返回false只能知道不是实例属性,如何确立它是原型属性呢?万一压根没有这个属性呢?这时需要用到in操作符。只要实例能够访问到的属性,就返回true
name in p1 //true
通过两者相结合,就能确立是否为原型属性了。hasOwnProperty返回fasle(不在实例中),in返回true(实例能访问到,即存在这个属性),就是原型属性。
所以说,当使用for in 遍历对象属性的时候,还需要判断这个遍历出来的属性是否为实例属性。
需要注意的几个点
- 当实例属性和原型属性同名时,只能访问到实例属性。
- delete操作符可以完全删除实例属性。
- 有时候,我们为了书写方便,会把整个原型重新赋值,而不是在原型上一个个添加。
Person.prototype = {
name: 'windy-boy',
age: 18,
eat: function() {
console.log('吃饭')
}
}
这个时候就等于是重写了整个原型对象。会导致constructor不再指向Person。而是指向新的对象(object)。如果还需要它指向Person,可以重新设置回来:
Person.prototype = {
constructor: Person,
name: 'windy-boy',
age: 18,
eat: function() {
console.log('吃饭')
}
}
这样显示的设置会导致constructor属性从不可枚举变成可枚举。
- 原型具有动态性。
什么意思呢?就是指在原型上添加的属性和方法,能够立即反应在所有的实例上。即使是先生成实例后给原型添加属性和方法,先生成的实例依然可以访问到这些属性和方法。为什么是这样子的呢,就拿前面的例子来说,Person这个粑粑制作的那个容器,里面的东西是所有它的子女都可以拿到的,子女们拿的是这个容器里面的东西,而不是把这个容器拷贝一遍再去拿,这样的话,Person不管什么时候往这个容器塞东西,只要这个容器里面有这个东西,它的子女们就可以拿的到。
当然,就像上一个注意点说的,如果是重写了整个原型对象的话,那么在重写原型对象之前生成的实例和重写之后的原型对象是没有联系的。这时候内存中实际是存储了两个原型对象,一个是之前的,一个是新生成的,它们之间是没有联系的,所以之前生成的实例是访问不到新生成的原型对象的。
原型模式的缺点
- 无法传参,也就是说所有实例初始化所获取的原型属性值都是相同的。
- 原型的属性和方法都是共享的,也就导致在实例中修改某个原型属性的值会反馈到所有实例。当然,如果修改的是基本类型的值,问题不大,因为这样会生成一个实例属性,从而屏蔽了原型属性。但如果修改的是引用类型的值,比如一个数组,在一个实例中往数组里面push多一个值,这个值会反馈在所有的实例身上。
组合使用构造函数模式和原型模式
针对以上原型模式的缺点,所以现在自定义类型最常见的方式就是将构造函数模式和原型模式组合起来使用。构造函数存放实例属性,原型模式存放需要共享的属性。
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.eat = function() {
console.log('吃饭')
}
const p1 = new Person()
const p2 = new Person()
动态原型模式
什么是动态原型模式呢?看下例子:
function Person(name, age) {
this.name = name
this.age = age
if (typeof this.eat !== 'function') {
Person.prototype.eat = function() {
console.log('吃饭')
}
}
}
const p1 = new Person()
const p2 = new Person()
哇哦,这不是组合使用构造函数模式和原型模式吗?两者差别在哪呢?
差别就在往原型对象上添加原型属性的时候,动态原型模式在添加原型属性的时候多了一层判断,不满足条件则不添加,原来这就是‘动态’啊。那么问题来了,我每个原型属性的添加都要这样判断一下吗?
大可不必:
function Person(name, age) {
this.name = name
this.age = age
if (typeof this.eat !== 'function') {
Person.prototype.eat = function() {
console.log('吃饭')
}
Person.prototype.sleep = function() {
console.log('睡觉')
}
}
}
像这样,只要有一个判断条件就够了。
- 注意
使用动态原型模式不能使用对象字面量的模式重写原型,前面已经讲过,这样会导致constructor指向新的对象,从而使现有的实例和新原型之间的联系断开。