一、字面量 / Object构造函数
这种方式相信大家都会,其实它们原理都是一样的,都是JS内置的对象Object
,但是它们都是暴露在外面,代码冗余度高,也不清晰,而且无法复用,所以就有了之后的工厂模式。
二、工厂模式
工厂模式是软件开发的一种设计模式,可以“批量生产”对象,对外封装创建对象的细节,只提供创建对象的接口。
2.1 步骤
主要是三个步骤:
- 调用
Object
构造函数创建一个对象 - 为这个对象添加属性和方法
- 返回这个对象
2.2 代码实现
function createObject(name, age) {
const obj = new Object();
obj.name = name;
obj.age = age;
obj.saiHi = function () {
console.log(`hello,${obj.name}`);
}
return obj;
}
let obj1 = createObject('laocao', 22);
let obj2 = createObject('laoliu', 23);
2.3 优缺点
优点:相对于字面量或者直接调用Object
,工厂模式解决了代码封装和冗余的问题,可以生成大量对象。
缺点:无法解决对象类型识别问题,因为都是由Object
创建的,类型根本无法判断
于是,便有了下面的构造函数模式。
三、构造函数模式
3.1 概念
构造函数和工厂模式的区别在于,它使用了new
运算符,在内部使用this
指向当前对象,而且解决了对象类型识别问题。
3.2 代码实现
function Person(name, age) {
this.name = name;
this.age = age;
this.sayHi = function() {
console.log(`hello,${this.name}`);
}
}
3.3 优缺点
优点:
- 使用
new
来调用了函数,使得这个函数变成了构造函数,可以区别其他构造函数,解决对象类型识别问题。可以使用instanceof
操作符来判断对象类型。 - 不用在函数内部显式创建对象了,交给
this
了,new
其实已经干好了。 - 没有
return
语句
(对比工厂模式,new的工作原理其实就出来了,面试常考哦
)
缺点:不同实例之间所访问构造函数其实不是同一块内存中存储,由于new
的作用,每次实例化对象都会在内存中重新创建一个新对象。这样一来,假如不同实例都需要访问一个功能相同的方法就很不合适,极大浪费了内存。
出现了这种问题,那么肯定会有解决方案的,比如原型模式。
四、原型模式
4.1 什么是原型
我们在创建一个函数(构造函数)的时候,内部会包含一个很特殊的属性:prototype
。这个属性存放着一个指针,指向的是这个构造函数的原型对象,里面包含了所有实例可以共享的属性和方法。
有了这个前提后,我们无论创建多个实例对象,都可以使用相同的属性和方法了,甚至在构造函数中什么都不写,全部放在原型对象prototype
中。
4.2 代码实现
function Person() {}
Person.prototype.name = 'laocao';
Person.prototype.age = 22;
Person.prototype.sayHi = function() {
console.log(`hello,${this.name}`);
}
let per = new Person();
我们创建两个实例对象:
可以看到,两个实例访问的是同一个sayHi
方法,这样就解决了构造函数模式无法共享属性或方法的局限了。
4.3 原型链
实例对象是怎么访问到原型对象里面的方法的呢?
简单来说就是通过一个非标准的__proto__
与构造函数中的prototype
来建立联系来形成原型链。实例对象会先在构造函数里面找有没有那个方法,如果没有会往原型链上找,直到找到为止。
要注意的是,实例的对象的__proto__
和构造函数是没有直接联系的,它指向的是构造函数的prototype
对象。而原型对象有一个constructor
属性,这个属性指向的是构造函数。有了这些关系,就形成了原型链。也就是说,也可以通过下面的方式访问原型方法:
在不支持__proto__
的浏览器里面可以使用继承自Object.prototype
下的一个isPropertyOf
方法来判断原型对象和实例对象的关系。
当然也可以使用Object.getPrototypeOf
方法(IE9以下不支持
)来直接访问或表示__proto__
与原型对象进行判断,可以看到,它们就是一样的:
现在如果构造函数和原型对象中有一个同名属性,那实例对象访问的是哪个呢?
我们可以先看看:
我给per
这个实例对象添加了一个name
属性值为laoliu
,再访问的时候先出现的就是laoliu
了,而原型上的属性只能通过__proto__
或Object.getPrototype(per)
访问了。那如何区分我所访问的这个属性是来自构造函数还是原型对象的呢?
这里我又要介绍一个方法了:Object.hasOwnProperty
,这个方法可以判断访问的是否是实例属性。
可见,name
是一个实例属性,是构造函数自有的属性,而不是原型对象上面的属性。
结合in
操作符,可以封装一个判断某个属性是来否来自原型对象:
function hasPrototypeProperty(object, property) {
return !object.hasOwnProperty(property) && (property in object)
}
4.4 问题
4.4.1 可读性差
现在有个情况,如果在prototype
上设置太多属性或方法会使得代码可读性较差。解决的方案就是把Person
的原型赋给一个新对象:
function Person() {}
Person.prototype = {
name: 'laocao',
age: 22,
sayHi () {
console.log(`hello,${this.name}`);
}
};
let per = new Person();
但此时带来一个新问题,Person
的原型对象中的constructor
重新被赋值了,指向的是Object
而不是Person
,从而导致无法通过contructor
属性来判断类型了:
解决方案就是在新对象加上一个constructor
属性。
4.4.2 代价高
通过上面的方法后,constructor
就变成了可枚举属性了,它的[[enumerabel]]
设置成了true
。我们可以通过以下方法来测试:
可问题是像contructor
自动生成的属性一般都是不可枚举的,所以我们还要手动来解决这个问题,我们使用Object.defineProperty
方法:
为了可读性,创建个对象这么麻烦,那还要可读性干嘛?
4.4.3 与原有原型冲突
假如我把实例对象放在原型对象重写之前,看会发生什么:
function Person() {}
let per = new Person();
Person.prototype = {
constructor: Person,
name: 'laocao',
age: 22,
sayHi () {
console.log(`hello,${this.name}`);
}
};
我访问name
属性的时候发现居然是undefined
,我后面不是在原型对象上加了这个属性吗?
这里就说明一个问题:切断了构造函数和最初原型对象之间的关系。
实例对象中的__proto__
一直指的是最初的原型对象,而原型对象又被重写了,相当于在堆内存中另起了一块内存。原型指针的指向也发生变化了,从最初的那一块堆内存指向了新的内存。但是实例对象中__proto__
这个指针还是指的是最初的那一块堆内存中的原型对象。
所以你可以在最后再加上这么一句:
per.__proto__ = Person.prototype;
实例对象也指向了新的那一块内存了,就可以访问到新原型对象中的属性和方法了。
借用高程P157
中的一个图来说明问题,friend
就是实例对象,和我定义的per
是相同的
4.5 总结
总结来说,原型模式创建的对象解决了属性或方法不能共享的问题,很大程度上解决了内存问题。
但也不是没有缺点:构造函数是空的没有传递参数,所有的属性和方法都在原型上,某个实例无法特有自己的属性或方法,而且,如果为了可读性把原型对象重新赋给一个新对象后会出现很多很多问题。所以,原型模式很少单独使用。结合构造函数模式和原型模式的优点,于是产出了新的方式:组合模式。
五、组合模式
5.1 概念
组合模式结合了前面两者的优点,它是构造函数模式和原型模式的组合。它可以给构造函数传递参数,也可以把需要共享的属性或者方法写在原型对象中。这种模式广为流传,十分受欢迎。
5.2 代码实现
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log(`hello,${this.name}`);
}
5.3 优缺点
唯一的缺点可能就是,JS没有类的概念,只能通过这种方式类模拟类,但是这种写法又不符合其他OO语言的方式,所以便有了ES6中的Class
,待会在下面介绍一下Class
是如何创建对象的。
六、动态原型模式
6.1 概念
组合模式看起来很完美,但是原型方法和构造函数好像是相互独立的,而动态原型模式可以把所有信息都封装在构造函数中,可以根据条件动态初始化原型。
6.2 代码实现
function Person (name, age) {
this.name = name;
this.age = age;
if (typeof this.sayHi != 'function') {
Person.prototype.sayHi = function () {
console.log(`hello,${this.name}`);
}
}
}
初次调用构造函数时才会执行原型初始化。此后,原型已经完成初始化,不需要再做什么修改了。
七、使用ES6中的class
关键字
首先要说明一件事情就是,任何新方法的出现都是解决不方便的问题,比如这个class
关键字,它就是ES5的语法糖。下面我们看一下如何使用吧:
class Person {
constructor (name, age) {
this.name = name;
this.age = age;
}
sayHi () {
console.log(`hello,${this.name}`);
}
}
在浏览器测试:
它实现了组合模式一样的效果,主要是书写很方便,也符合OOP
的写法。ES6模拟出了class类的概念,也有构造函数。在面向对象开发中,强烈推荐这种方式。
其实还有两种方式:稳妥构造函数模式
和寄生构造函数模式
,这两种方式创建的对象缺点太大,而且也没有什么意义,如果读者想要了解可以看看高程的P160。
八、参考
【1】《JavaScript高级程序设计》(第3版)