1.JavaScript工厂模式
虽然使用Object构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
1.1 什么是工厂模式?
工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。工厂模式是一种创建型模式,简单来说,工厂模式就是创建对象的一种方式。
1.2 工厂模式有什么用?
作用:(1)创建对象;(2)降低代码冗余度。
应用场景:当你想要批量生产同种类的对象的时候;比如,你想生成一个班级的40个学生,
每个学生都有姓名,年龄等特征。这时候你创建一个“工厂”,把信息丢到工厂里,工厂就给你造一个人出来,非常方便
1.3 为什么用工厂模式
从工厂模式的作用出发来看,工厂模式的主要作用就是用来产生对象的。那么别的创建对象的模式有什么缺点?
1.3.1用字面量的方式创建对象
字面量就是用来描述变量的;一般来说,给变量赋值时,等号右边的都可以看作是字面量
(因为等号右边的都是用来描述这个变量的,比如描述一个变量为字符串、数组、对象等)
var person = {
name:'zhangsan',
age:18,
gender:'male',
sayName:function(){
console.log(this.name);
}
}
缺点:用字面量的方式创建对象,最大的缺点就是,这个对象是一次性的,如果有40个同学,这个代码就要写40次,有点小麻烦。
1.3.2 new Object()创建对象
Object是JavaScript提供的构造函数;new Object()就是利用JavaScript提供的构造函数实例化了一个对象;
var person = new Object();
// 为这个实例化的对象添加属性
person.name = 'name';
person.age = 18;
person.gender = 'male';
person.sayName = function(){
console.log(this.name);
}
缺点:可以发现它是先实例化了一个对象,然后再为对象添加属性,这样就看不出来是个整体
(像上面的用字面量来创建,属性都包再一个大括号里面,这样子就很好看出这是个整体)
因此,我们为了使创建对象更加方便(不像字面量创建那样一次性),也为了写的代码更像个整体,就可以交给工厂模式来做。
1.4 使用工厂模式创建对象
// 将创建对象的代码封装在一函数中
function createPerson(name,age,dender){
var person = new Object();
person.name = name;
person.age = age;
person.gender = gender;
person.sayName = function(){
console.log(this.name);
}
return person;
}
// 利用工厂函数来创建对象
var person1 = createPerson('zhangsan',18,'male');
var person2 = createPerson('lisi',20,'female');
优点:只要我们往工厂函数里面塞参数,工厂函数就会像生产产品一样造个人出来
缺点:这种方式本质上是将创建对象的过程进行了封装,本质并没有改变,我们创建一个student时无法知道其具体的数据类型,只知道这是一个对象,往往实际开发中我们需要确定这个对象到底时个Person的实例还是Dog的实例。
所以,我们可以使用自定义构造函数模式。
2.构造函数模式
ECMAScript中的构造函数是用于创建特定类型对象。像Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用。
当然也可以自定义构造函数,以函数的的形式为自己的对象类型定义属性和方法。
JavaScript中可以自定义构造函数,从而自定义对象类型的属性和方法,构造函数本身也是函数,只不过可以用来创建对象。
2.1 自定义构造函数
前面的案例使用构造函数可以这样写
// 自定义函数
function Person(name,age,gender){
this.name = name;
this.age = age;
this.gender = gender;
this.sayName = function(){
console.log(this.name);
}
}
//利用工厂函数来创建对象
var person1 = new Person('zhangsan',18,'male');
var person2 = new Person('lisi',19,'female');
person1.sayName();//zhangsan
person2.sayName();//lisi
在这个案例中,Person()构造函数代替了createPerson()工厂函数。实际上,Person()内部的代码跟createPerson()基本是一样的,只是有如下区别。
(1)没有显式地创建对象
(2)属性和方法直接赋值给了this。
(3)没有return
(4)另外,要注意函数名Person的首字母大写了。按照惯例,构造函数名称首字母都是要大写的,非构造函数则以小写字母开头。
2.2 创建Person实例
要创建Person的实例,应使用new 操作符。以这种方式调用构造函数会执行如下操作
var person1 = new Person('zhangsan',29,'male');
var person2 = new Person('lisi',18,'female');
(1)在内存中创建一个新对象
(2)这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性
(3)构造函数内部的this被赋值为这个新对象(即this指向新对象)
(4)执行构造函数内部的代码(给新对象添加属性)
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
person1和person2分别保存这Person的不同实例。所有对象都会从它的原型上继承一个constructor属性,这两个对象的constructor属性指向Person。
2.3 instanceof
(1)constructor本来是用于标识对象类型的。不过,一般认为instanceof操作符是确定对象类型更可靠的方式
(2)instanceof运算符用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。或者说判断一个对象是某个对象的实例
(3)定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。在这个案例中,person1和person2之所以也被认为是Object的实例,是因为所有自定义对象都继承自Object。
2.4 使用函数表达式自定义构造函数
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
var Person = function(){
this.name = name;
this.age = age;
this.gender = gender;
this.sayName = function(){
console.log(this.name);
};
}
var person1 = new Person('zhangsan',12,'male');
var person2 = new Person('lisi',23,'female');
person1.sayName();//zhangsan
person2.sayName();//lisi
console.log(person1 instanceof Object);//true
console.log(person1 instanceof Person);//true
console.log(person2 instanceof Object);//true
console.log(person2 instanceof Person);//true
补充
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只有有new 操作符,就可以调用相应的构造函数:
function Person(){
this.name = 'larry';
this.sayName = function(){
console.log(this.name);
}
}
var person1 = new Person();
var person2 = new Person;
person1.sayName();//larry
person2.sayName();//larry
console.log(person1 instanceof Object);//true
console.log(person1 instanceof Person);//true
console.log(person2 instanceof Object);//true
console.log(person2 instanceof Person);//true
2.5构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。
并没有把某个函数定义为构造函数的特殊语法任何函数只要使用new 操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。
比如,前面的案例中定义的Person()可以像下面这样调用:
var Person = function(name,age,gender){
this.name = name;
this.age = age;
this.gender = gender;
this.sayName = function(){
console.log(this.name);
}
}
//作为构造函数
var person = new Person('jacky',22,'male');
person.sayName();//jacky
//作为函数调用
Person('lisi',27,'female');//添加到全局对象node global浏览器 window
2.6 构造函数的问题
构造函数虽然也有用,但也并不是没有问题的。构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。
因此对于前面的案例而言,person1和person2都有名为sayName()的方法,但这两个方法不是同一个Function实例。
我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会初始化一个对象。
逻辑上讲,这个构造函数实际上是这样的:
function Person(name,age,gender){
this.name = name;
this.age = age;
this.gender = gender;
this.sayName = new Function('console.log(this.name)');//逻辑等价
}
这样理解这个构造函数就可以更清楚地知道,每个Person实例都会有自己的Function实例用于显示name属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。
但创建新Function实例的机制是一样的。因此不同实例上的函数虽然同名却不相等。如下所示:
console.log(person1.sayName === person2.sayName);//false
因为都是做一样的事,所以没有必要定义两个不同的Function实例。况且,this对象可以把函数与对象的绑定推迟到运行时。
要解决这个问题,可以把函数定义转移到构造函数外部:
function Person(name,age,gender){
this.name = name;
this.age = age;
this.gender = gender;
this.sayName = sayName;
}
function sayName(){
console.log(this.name);
}
var person1 = new Person('zhangsan',23,'male');
var person2 = new Person('lisi',22,'female');
person1.sayName();//zhangsan
person2.sayName();//lisi
在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName属性等于全局sayName()函数。因为这一次sayName属性中包含的只是一个指向外部函数的指针
所以person1和person2共享了定义在全局作用域上的sayName()函数。
这样虽然解决了相同逻辑的函数冲定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能只能一个对象上调用。如果这个对象需要多个方法,
那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好的聚集在一起。这个新问题可以通过原型模式来解决。
3 原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜素开始对于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。
如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。
因此,在调用person1.sayName()时描绘发生两步搜索。
1.JavaScript引擎会问:person1实例有sayName()属性吗?答案是没有
2.继续搜索并问:person1的原型有sayName()属性吗?答案是有。
于是就返回了保存在原型上的这个函数。在调用person2.sayName()时,会发生同样的搜索过程,而且也会返回相同的结果。这就是原型用于在多个对象实例间共享属性和方法的原理。
看下面的案例:
function Person(){
Person.prototype.name = 'zhangsan';
Person.prototype.age = 20;
Person.prototype.gender = 'male';
Person.prototype.sayName = function(){
console.log(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.name = 'lisi';
console.log(person1.name);//lisi 来自实例
console.log(person2.name);//zhangsan,来自原型
在这个案例中,person1的name属性遮蔽了原型对象上的同名属性。虽然person1.name和person2.name都返回了值,但前者返回的时'lisi'(来自实例),
后者返回的是"zhangsan"(来自原型)。当console.log()访问person1.name时,会先在实例上搜索这个属性。
因为这个属性在实例上存在,所以就不会再搜搜原型对象了。而再访问person2.name时,并没有在实例上找到这个属性,所以会继续搜索原型对象并使用定义在原型上的属性。
我们也可以通过hasOwnProperty()可以查看访问的是实例属性函数原型属性。
只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为null,也不会恢复它和原型的联系。不过,使用delete操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
function Person(){
Person.prototy.name = 'zhangsan';
Person.prototype.age = 20;
Person.prototype.gender = 'male';
Person.prototype.sayName = function(){
console.log(this.name)
}
};
var person1 = new Person();
var person2 = new Person();
//通过hasOwnProperty()可以查看访问的是实例属性还是原型属性
console.log(person1.hasOwnProperty('name'));//false
person1.name = 'lisi';
console.log(person1.name)//lisi,来自实例
//只在重写person1上,name属性的情况下才会返回true,表明此时name是一个实例属性,不是原型属性.
console.log(person1.hasOwnProperty('name'));//true
delete person1.name;
console.log(person1.name);//zhangsan,来自原型
console.log(person1.hasOwnProperty('name'));//false
这个修改后的案例中使用了delete删除person1.name,这个属性之前以'lisi'遮蔽了原型上的同名属性,然后原型上name属性的联系就恢复了,因此再访问person1.name时,就会返回原型对象上这个属性的值
3.1 原型与in操作符
function Person(){
Person.prototype.name = "zhangsan";
Person.prototype.age = 23;
Person.prototype.gender = 'male';
Person.prototype.sayName = function(){
console.log(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
//无论属性是在实例上还是原型上,都可以检测到
console.log('name' in person1);//true
console.log('name' in person2);//true
//判断一个属性是否是原型属性
function hasPrototypeProperty(object,name){
//不在实例中但是可以访问到的属性属于原型属性
return !object.hasOwnProperty(name)&&(name in object);
}
console.log(hasPrototypeProperty(person1,'name'));//true
3.2 原生对象的原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。
所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法。比如,数组实例的sort()方法就是Array.prototype上定义的,而字符串包装对象的substring()方法也是在String.prototype上定义的,如下所示:
console.log(typeof Array.prototype.sort);//'function'
console.log(typeof String.prototype.substring);//'function'
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。
可以像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。比如,下面的代码就给 String原始值包装类型的
实例添加了一个 last()方法:
//给字符串添加属性或方法 要写到对应的包装对象的原型下才行
var str = 'hello';
String.prototype.last = function () {
// 返回指定位置的字符
return this.charAt(this.length - 1);
};
console.log(str.last()); // o
如果给定字符串调用 last()方法,那么该方法会返回 给定字符串的最后一个字符。因为这个方法是被定义在 String.prototype 上,所以当前环境下所有的字符串都可以使用这个方法。
str是个字符串,在读取它的属性时,后台会自动创建 String 的包装实例,从而找到并调用 last()方法。
注意:尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成误会,而且可能引发命名冲突。另外还有可能意外重写原生的方法。
3.3 更简单的原型模式
在前面的案例中,每次定义一个属性或方法都会把 Person.prototype 重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法,如下面的案例所示:
function Person() {}
Person.prototype = {
name: "zhangsan",
age: 29,
gender: "male",
sayName() {
console.log(this.name);
}
};
// 在这个案例中,Person.prototype 被设置为等于一个通过对象字面量创建的新对象。最终结果是一样的,只有一个问题:这样重写之后,Person.prototype 的 constructor 属性就不指向 Person了。在创建函数时,也会创建它的 prototype 对象,同时会自动给这个原型的 constructor 属性赋值。而上面的写法完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同的新对象(Object 构造函数),不再指向原来的构造函数。
var person1 = new Person()
console.log(person1.constructor === Person); //false
console.log(person1.constructor === Object); //true
那怎么解决这个问题呢?
可以在重写原型对象时,专门设置constructor的值;
但是,以这种方式恢复 constructor 属性会创建一个[[Enumerable]]为 true 的属性。而原生 constructor 属性默认是不可枚举的。
因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty()方法来定义 constructor 属性:
function Person() { }
Person.prototype = {
//这种方式恢复 constructor 属性会创建一个[[Enumerable]]为 true 的属性
//constructor: Person,
name: "zhangsan",
age: 29,
gender: "male",
sayName() {
console.log(this.name);
}
};
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
var person1 = new Person()
console.log(person1.constructor == Person); //true
console.log(person1.constructor == Object); //false
3.4原型的问题
(1)原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。
(2)我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,如前面案例中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。来看下面的案例:
function Person() { }
Person.prototype = {
constructor: Person,
name: "zhangsan",
friends: ["lisi", "wangwu"],
sayName() {
console.log(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("zhaoliu");
console.log(person1.friends); // [ 'lisi', 'wangwu', 'zhaoliu' ]
console.log(person2.friends); // [ 'lisi', 'wangwu', 'zhaoliu' ]
console.log(person1.friends === person2.friends); // true
这里,Person.prototype 有一个名为 friends 的属性,它包含一个字符串数组。然后这里创建了两个Person 的实例。
person1.friends 通过 push 方法向数组中添加了一个字符串。由于这个friends 属性存在于 Person.prototype 而非 person1 上,新加的这个字符串也会在(指向同一个数组的)
person2.friends 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。
4.组合模式
组合使用构造函数模式和原型模式。构造函数用于定义实例属性,原型模式用于定义方法和共享属性。这种模式是目前在ECMAScript中使用最广泛,认同度最高的一种创建自定义类型的方法
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
this.firends = ['zhangsan', 'lisi'];
}
Person.prototype = {
constructor: Person,
sayName: function () {
console.log(this.name);
}
};
var p1 = new Person('larry', 44, 'male');
var p2 = new Person('terry', 39, 'male');
p1.firends.push('robin');
console.log(p1.firends); // [ 'zhangsan', 'lisi', 'robin' ]
console.log(p2.firends); // [ 'zhangsan', 'lisi' ]
console.log(p1.firends === p2.firends); // false
console.log(p1.sayName === p2.sayName); // true