对于有其他面向对象语言开发经验的人来说,在看到独立的构造函数和原型时,很可能会感到非常困惑。比如在Java中(没有Java经验的开发者此段可忽略,只要知道后面提出的结论就好了),有类的概念(当然ES6也引入了类,我们这里以ES5为基础),比如以下代码所示:
public class Person {
private String name;
private int age;
private String job;
public Person(String name, int age, String job) {
this.name = name;
this.age = age;
this.job = job;
}
public void sayName(){
System.out.println(this.name);
}
}
复制代码
这是非常简单的一个类,它有三个属性,一个构造函数和一个方法。如果比较JavaScript,function Person
就相当于类,但是我们发现,Java中的类是一个整体,而JavaScript除了function Person
,还有一个Person.prototype
,被定义成了两部分。所以,JavaScript对于对象的封装性还是不够完美,而动态原型模式正是致力于要解决这个问题,它把所有的信息都封装在了构造函数中,通过在构造函数中初始化原型,既很好地体现了封装性,又保持了组合使用构造函数和原型模式的特点,可以说一举两得,非常完美。下面我们来看一个例子:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
if(typeof this.sayName != 'function'){
Person.prototype.sayName = function(){
console.log(this.name);
};
Person.prototype.sayJob = function(){
console.log(this.job);
};
}
}
var p1 = new Person('张三', 18, 'JavaScript');//sayName不存在,添加到原型
var p2 = new Person('李四', 20, 'Java');//sayName已经存在,不会再向原型添加
p1.sayName();//张三
p2.sayName();//李四
复制代码
如代码所示,第一次创建对象,执行构造函数时,判断sayName()
是否存在,如果不存在,就把它添加到原型,使用if
判断可以确保只在第一次调用构造函数时初始化原型,避免了每次调用的重复声明。
实际上这里不仅仅可以使用sayName()
做为判断条件,还可以使用sayJob()
,这个条件只是为了测试原型是否已经初始化,只要是原型初始化之后应该存在的属性或方法都可用来做为判断条件。
之前讲过,原型也可以用对象字面量来重写,那动态原型模式可不可以使用对象字面量呢?我们来尝试一下:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
if(typeof this.sayName != 'function'){
Person.prototype = {
constructor: Person,
sayName: function(){
console.log(this.name);
}
}
}
}
var p1 = new Person('张三', 18, 'JavaScript');//sayName不存在,添加到原型
var p2 = new Person('李四', 20, 'Java');//sayName已经存在,不会再向原型添加
//p1.sayName();//Uncaught TypeError: p1.sayName is not a function
p2.sayName();//李四
复制代码
发现p1.sayName()
报了不是一个函数的错误,如果把p1.sayName()
注释掉,p2.sayName()
可以正常输出李四,为什么会这样呢?要想解释清楚这个问题,我们先来看一下以下代码:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
}
var p1 = new Person('张三', 18, 'JavaScript');
console.log(Person.prototype);//{constructor: ƒ}
Person.prototype = {
constructor: Person,
sayName: function(){
console.log(this.name);
}
}
var p2 = new Person('李四', 20, 'Java');
console.log(Person.prototype);//{constructor: ƒ, sayName: ƒ}
// p1.sayName();//Uncaught TypeError: p1.sayName is not a function
p2.sayName();//李四
复制代码
也是同样的现象,p1.sayName()
不是一个函数。那么p1
、p2
的区别在哪儿呢?区别就在于通过new
关键字创建对象的先后顺序,是先于重写原型创建,还是后于重写原型创建。
我们知道,通过new
关键字创建一个对象,这个对象会有一个属性__proto__
指向相应函数的原型,这里代码中的p1正是指向了这个原型,在Chrome的开发工具中可以看到,如下图所示:
但是重写原型,就是创建了一个新对象,函数的指针Person.prototype
由引用旧的原型对象改为引用这个新对象,而旧的原型对象现在只被p1.__proto__
引用着,实例p1
和Person
原型之间的关系被切断了,所以调用p1.sayName()
就报了不是一个函数的错误,因为旧原型对象上没有sayName
方法。
再来看p2
,因为是先重写原型,所以当p2
被new
出来时,p2
的__proto__
属性指向的就是这个新原型,故而调用sayName
方法时,向上搜索原型可以找到sayName方法
,正常输出李四,下面的示意图可以直观地表示这种情况:
现在回过头来看一开始提出的问题,为什么动态原型模式不能用对象字面量的方式重写。第一次创建实例对象时,先new
,然后执行构造函数,重写原型,那么此时实例的__proto__
指向的还是原来的原型,不是重写后的原型。第二次创建实例,因为新原型对象已经创建好,所以实例的__proto__
指向的就是重写的这个原型。使用给原型添加属性的方式操作的一直是同一个原型,所以也就不存在先后的问题。
这就是动态原型模式,相比组合使用构造函数和原型模式而言,封装性更优秀,但是一个小缺点就是不能使用对象字面量的形式初始化原型,这是需要留意的。开发者在实际应用中可根据具体情况,灵活选择,确定使用哪种方式。
本文参考《JavaScript高级程序设计(第3版)》