Higanbana

创建对象(二)——原型模式

理解原型对象

我们创建的每一个函数都有一个默认的prototype属性,它指向一个对象,对象中默认的有一个叫做constructor的属性,指向这个函数本身。


如上图,右侧的这个方框就是函数function的原型,也就是说prototype属性指向的这个对象就是原型。
当构造函数创建出一个新实例后,该实例会默认具有一个__proto__属性,这个属性指向构造函数的原型对象。因此,如果我们把属性和方法都添加到原型对象中,不同的实例就可以访问到相同的属性和方法了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person() {
}
Person.prototype.name = "wanghan";
Person.prototype.age = 20;
Person.prototype.getName = function () {
console.log(this.name);
};
var fun1 = new Person();
var fun2 = new Person();
console.log(fun1.name); //wanghan
console.log(fun2.age); //20
fun1.getName(); //wanghan
fun2.getName(); //wanghan
console.log(fun1.getName == fun2.getName); //true

我们先声明了一个空的构造函数Person,将一些属性和方法添加到了Person函数的prototype属性中,然后实例化了两个对象,根据后面的输出我们可以确定两个实例访问的是相同的对象,它们共享这些属性和方法。下图展示了例子中各个对象的关系:

实例与原型中属性的纠葛

当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性。换句话说,添加这个属性只会阻止我们去访问原型中的那个属性,但不会修改那个属性。你也可以理解为,当我们访问实例对象时,会优先访问它自身的属性和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person() {
}
Person.prototype.name = "wanghan";
Person.prototype.age = 20;
Person.prototype.getName = function () {
console.log(this.name);
};
var fun1 = new Person();
var fun2 = new Person();
fun1.name = "Tom";
console.log(fun1.name); //Tom
console.log(fun2.name); //wanghan
fun1.getName(); //Tom
fun2.getName(); //wanghan

在这个例子中,我们给实例fun1添加了属性name="Tom"

  • 访问fun1.name时,先在实例fun1中寻找属性name,找到之后返回Tom就不必再搜索原型了。
  • 而访问fun2.name时,先在实例fun2中没有找到属性name,于是搜索原型,结果找到了值为wanghan的属性name,验证了在实例中添加属性不会修改原型中的同名属性。
  • 访问fun1.getName()时,在在实例fun1中没有找到方法getName(),搜索原型找到了这个方法,执行其中的代码,此时方法中的this已经指向了实例(回忆一下操作符new调用构造函数经历的过程),因此输出的是实例属性name的值Tom

判断属性的位置

  • hasOwnProperty()方法只在给定属性存在于实例对象中时返回true
  • 单独使用in操作符时,无论属性存在于实例中还是原型中,只要能够访问到,就会返回true

如果实例和原型中都存在着一个相同属性,结合这两种方法我们就可以判断,我们访问到的这个同名属性到底是实例中的还是原型中的。

1
2
3
4
5
6
7
8
9
10
function Person() {
}
Person.prototype.name = "wanghan";
Person.prototype.age = 20;
var fun = new Person();
fun.name = "Tom";
console.log("name" in fun); //true
console.log(fun.hasOwnProperty("name")); //true --name来自实例
console.log("age" in fun); //true
console.log(fun.hasOwnProperty("age")); //false --age来自原型

更简单的原型语法

在前面的例子中,我们给原型对象添加对象的时候,输入了好多遍Person.prototype,其实这些不必要的输入都是可以避免的,最常见的方法就是以对象字面量的形式创建对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {
}
Person.prototype={
name: "wanghan",
age: 20,
getName: function () {
console.log(this.name)
}
};
var fun = new Person();
console.log(fun.name); //wanghan
console.log(fun.age); //20
fun.getName(); //wanghan

这样创建对象是不是很轻松,而且输出的结果跟之前相比并没有什么变化。但是这里有一点需要注意,重写原型对象的实质是,我们重建了一个对象赋值给了函数的prototype,所以新建的这个原型对象中默认的constructor属性也是新建的,它不指向构造函数Person,但是必要情况下我们可以手动让它指向Person

1
2
3
4
5
6
7
8
9
10
function Person() {
}
Person.prototype={
constructor: Person, //看这里
name: "wanghan",
age: 20,
getName: function () {
console.log(this.name)
}
};

重写原型对象的弊端

由于实例与原型之间的松散连接关系,即使我们先创建实例,再给原型对象添加属性,我们也照样可以访问到这些属性。

1
2
3
4
5
function Person() {
}
var fun = new Person();
Person.prototype.name ="wanghan";
console.log(fun.name); //wanghan

但是重写原型对象之后就不一样了。

1
2
3
4
5
6
7
8
function Person() {
}
var fun = new Person();
Person.prototype={
constructor: Person,
name: "wanghan"
};
console.log(fun.name); //undefined

我们前面说过,重写原型对象实际上是新建了一个新的对象赋值给构造函数的prototype。如果是先创建一个实例,那么实例指向最开始的一个只含有constructor属性的原型对象,即使随后又新建了一个原型对象,它的指向也不会再发生变化。

要想解决这个问题就要牢记,要在重写原型对象之后新建实例,这样实例指向的就是重写之后的原型对象。

1
2
3
4
5
6
7
8
function Person() {
}
Person.prototype={
constructor: Person,
name: "wanghan"
};
var fun = new Person();
console.log(fun.name); //wanghan

原型对象的问题

原型模式也不是没有缺点。

  • 原型中的所有属性和方法都是被实例所共享的,共享方法(函数)是非常合适的,对于那些基本值的属性也还说的过去,但是对于包含引用类型值得属性来说,问题就非常突出了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function Person() {
    }
    Person.prototype={
    constructor: Person,
    name: "wanghan",
    friends: ["zhangmin","yangfan"]
    };
    var fun1 = new Person();
    var fun2 = new Person();
    fun1.friends.push("Tom");
    console.log(fun1.friends); //["zhangmin", "yangfan", "Tom"]
    console.log(fun2.friends); //["zhangmin", "yangfan", "Tom"]

原型对象中有一个字符串数组,然后创建了两个实例,操作实例fun1向原型对象的数组中又添加了一个字符串Tom,由于数组是引用类型值,并且两个实例共享原型对象中的属性,所以我们刚刚的修改也会在实例fun2中体现出来。这个问题正是我们极少看到有人单独使用原型模式的原因所在。

  • 原型对象直接在原型对象里定义了属性值,使所有实例默认情况下都取得相同的属性值,要克服这一缺点可以组合使用原型模式和构造函数模式。

后记

我画框图是用电脑自带的画图软件画的你敢信,举得例子也是自己手打出来的,运行正确之后就剪切到markdown上面。眼看着快要写完了,时间也快凌晨两点了,喜滋滋地准备收尾,谁知道电脑突然一黑,重启了。再次打开markdown之后我就绝望了,就剩下前两段摆在上面。我冷静了二十分钟,发了个朋友圈,然后重写到凌晨四点。嗨呀,好气呀。