首先我们要明确一个概念,
JavaScript
是一门面向对象的语言。既然是面向对象的语言,那么面向对象的一些基本的属性就会有,比如继承。在谈到继承的时候,我们总会想到java
的extends
,在没有ES6
的年代里,我们应该怎么去实现继承呢?我总是把java
的继承的做法来套用到这里。后来想明白了一件事儿,那就是 思想是相同的。而具体的实现做法却不尽相同,后来碰到所有差异的部分,我都会用这个思想来指导自己。去记住那些思想,实现反而是次要。比如设计模式,比如算法,比如排序,换一门语言同样。而原型和原型链模式就是为了实现继承而生的。但是原型模式并不是继承,它们有很大的不同。要说原型,我们就要从JavaScript
的对象开始。那我们开始吧。
我错了,思想也不相同
创建对象
我们先从创建对象开始吧,创建对象的简单方式就是创建一个Object
的实例,然后为它添加属性和方法。
var person = new Object();
person.name = "张三";
person.age = 21;
person.job = "程序员";
person.sayName = function(){
alert(this.name)
}
上面我们创建了一个对象的实例,然后添加了三个属性和一个方法。后来我们把创建对象改为了更加简单的对象字面量的方式:
var person = {
name:"张三",
age:21,
job:"程序员",
sayName:function(){
alert(this.name)
}
}
上面创建的对象虽然简单但是不够服用,如果我要造很多类似的对象呢?那就需要用到工厂模式了!
工厂模式来造对象
工厂模式是常见设计模式之一,它把构造的细节做了抽象,让我们直接调用就好,无序关注细节。
function createPerson(name,age,job){
var person = new Object();
person.name = name;
person.age = age;
person.job = job;
person.sayName = function(){
alert(this.name)
}
return person
}
工厂模式虽然解决了创建对象的问题,但是它却没有办法识别一个对象的类型。怎么办呢?那就是使用构造函数模式。
是构造函数模式
废话不多说,上代码:
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name)
}
}
var person1 = new Person("张三",21,"程序员");
var person2 = new Person("李四",21,"程序员");
构造函数模式和普通的函数其实没有什么什么不同。只是为了区分是构造函数模式,把函数的首字母大写。
同时它还和之前的工厂模式有一下的不同:
- 没有显示地创建对象
- 直接将属性和方法付给了this对象
- 没有return 语句
当我们需要创建对象的时候使用new
操作符来创建一个。(程序员,没有对象怎么办,自己new
一个呗。)它的具体步骤如下:
- 创建一个新对象
2.这个新对象内部的[[Prototype]]特性被赋值为构造函数prototype属性 - 将构造函数的作用域付给新对象(因此this就指向了这个新对象)
- 执行构造函数中的代码(为这个对象添加属性)
- 返回新对象
在上面的例子里,两个对象都有一个constructor
(构造函数)属性,该属性指向了Person
.这个属性一开始是用来标识对象类型的。但是,它并不靠谱,为啥不靠谱?后面聊,但是还是instanceof
更加靠谱一点:
alert(person1.constructor === Person);//true
alert(person2.constructor === Person);//true
alert(person1 instanceof Person)//true
alert(person1 instanceof Object)//true
创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数胜过工厂模式的地方。
将构造函数当作函数
大家不要以为构造函数和函数是两个物种,其实他们一样,只是调用的方式不同而已。构造函数也是函数,并没有定义特殊的构造函数语法。
任何函数通过new
操作符来调用,那么它就是构造函数。
任何函数是不通过new
来调用,都是普通函数。
//当作构造函数使用
var person = new Person("张三",21,"程序员");
person.sayName(); //张三
//当作普通函数调用
Person("张三",21,"程序员"); //添加到window中
window.sayName();//张三
//在另一个对象的作用域中调用
var o = new Ojbect();
Person.call(o,"张三",21,"程序员");
o.sayName();//张三
上面例子我们谈一下下面的两点:
当作普通函数时候时,函数中的this
指向window
,所以属性和函数都添加到了全局对象中
通过call()
或者apply()
两个方法在特殊的对象的作用域中调用的Person
函数,则特殊对象就拥有了这些属性和方法。
但是构造函数也有问题:
构造函数的问题
构造函数的问题其实非常明显,就是每个方法都要在每一个对象重实例上重新创建一遍。在前面的例子中就是这样。person1
和person2
在创建的时候,sayName
会重新创建一遍。为啥?因为JavaScript
中的函数其实是一个对象。
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("alert(this.name)");//与声明函数在逻辑上是等价的
}
不同的实例包含了不同的函数实例。这有啥问题,这样会导致不同的作用域链和标识符解析。也就是不同实例上的同名函数是不相等的。
alert(person1.sayName === person2.sayName) //false
那我们可以通过引用外部的函数来实现呀。
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
alert(this.name)
}
这样确实是解决了上面的问题,但是这个函数却放在了全局里,那你的方法还封装不封装了,全都暴露在外面了,那,这个时候就到了原型模式出场了.
原型模式
说一下概念,这个概念就已经说清楚了什么是原型模式:
我们创建的每个函数都有一个prototype
(原型属性),这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。
那么按照字面意思来理解,那么prototype
就是通过调用构造函数而创建的那个对象实例的原型对象。
使用原型对象的好处是可以让所有对象实例共享它包含的属性和方法。这样我们不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中。
function Person() {
}
Person.prototype.name = "张三";
Person.prototype.age = 29;
Person.prototype.job = "程序员";
Person.prototype.sayName = function () {
console.log(this.name);
};
let person1 = new Person();
person1.sayName(); //张三
1.理解原型对象
对照我们一开始给出的概念,
只要创建了一个新的函数,就会根据一组特定的规则为该函数创建一个prototype
属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个constructor
(构造函数)属性.这个属性是一个指向prototype
属性所在函数的指针。就比如前面的例子
Person.prototype.constructor === Person
通过这个这个构造函数,我们还可以继续为原型对象添加其他的属性和方法。
我们看上图,我们创建了一个普通函数,可以看到在初始化的时候,这个函数就已经有了prototype
这个属性,这个属性指向函数的原型对象,而原型对象有一个constructor
属性,这个属性指向了prototype
所在函数的指针,也就是在哪个函数上,可以看到上面是指向f
函数。其他的那些属性都是继承于Object
。
而使用构造函数new
出来的实例则有一个属性可以查看原型对象。官网说法是[[prototype]]
,而我们可以再浏览器里通过__proto__
这个属性来查看,如上图。
具体的原理可以通过上图来查看,Person
的原型对象prototype
指向原型对象,原型对象的constructor
又指向了Person
。实例的[[Prototype]]
则指向原型对象。可以发现这个[[Prototype]]
标识的是实例和原型对象之间的关系,而不是实例和构造函数之间的关系。
剩下的一些api
这里简略的说一下:
-
hasOwnProperty
来检查一个属性是存在于实例中还是存在于原型中,只有属性存在于实例中时,才会返回true
. -
in
操作符会在通过对象能顾访问给定属性时返回true
,无论该属性存在于实例中还是原型中。 -
Object.getOwnPropertyNames
,通过该方法可以得到所有实例属性,无论是否都可以枚举。
2.更简单的原型方法
每一次我们添加属性和方法时都要敲一遍Person.prototype
。可以这样操作:
Person.prototype = {
name:"张三",
age:21,
job:"程序员",
sayName:function(){
alert(this.name)
}
};
我们把Person.prototype
指向了一个新的对象,那么,这个新的对象的constructor
就不会在指向Person
,我们都知道,当我们创建一个函数时,这个函数就会同时初始化它的Prototype
,这个对象会自动获得constructor
属性,这个属性指向Person
。现在我们的做法,就相当于完全重写了prototype
属性。也就造成它的constructor
不在指向Person
。因为它是新的对象啊。此时,尽管instanceof
操作符还能返回正确的结果,但是通过constructor
已经无法确定对象的类型了。这就是为啥constructor
不靠谱的原因。
但是这样会切断原型链之间的关系。
var friend = new Person();
console.log(friend instanceof Object); //true
console.log(friend instanceof Person);//true
console.log(friend.constructor == Person); //false
console.log(friend.constructor == Object);//true
如果constructor
的值很重要,可以这样玩。
Person.prototype = {
constructor:Person,
name:"张三",
age:21,
job:"程序员",
sayName:function(){
alert(this.name)
}
};
可是这样操作的话会造成[[Enumerable]]
设置为true
。造成可枚举,所以我们可以这样:
function Person(){
}
Person.prototype = {
constructor:Person,
name:"张三",
age:21,
job:"程序员",
sayName:function(){
alert(this.name)
}
};
Object.defineProperty(Person.prototype,"constructor",{
enumerable:false,
value:Person
})
3.原型的动态性
知道为啥实例的[[Prototype]]
是指向原型对象,而不是构造函数吗?
因为构造函数是死的,而原型对象时活的,对象会改变,而构造函数却不会去改变。
比如我们创建了一个实例,然后修改了它的原型对象,立马会反映出来。
const friend = new Person();
Person.prototype.sayHi = function(){
alert("Hi");
}
friend.sayHi()//Hi
没有毛病。当我们创建了一个实例,然后接着修改它的原型对象,为其添加新的方法sayHi
。当我们调用这个sayHi
时,首先会从自己作用域里找,然后发现没有找到,接着就向上找,去它的原型对象里找,然后找到了,进行调用。
实例和原型对象之间是指针的关系,可是当我们把他们之间的关系切断时,也就找不到了。
function Person(){
}
const friend = new Person();
Person.prototype = {
constructor:Person,
name:"张三",
age:21,
job:"程序员",
sayName:function(){
alert(this.name)
}
};
friend.sayName(); //error
在这个例子里,我们先创建了一个Person
的实例,然后又重写了其原型对象,然后在调用friend.sayName
时发生了错误,因为friend
指向的原型中不包含以该名字命名的属性。
我们可以从上图看出,重写原型对象之后,实例中保存原型的指针被打断了,指向了一个新的。从而造成它找不到sayName
这个方法。
原型我们就聊这么多,想要了解更多大家可以翻JavaScrit高级程序设计之面向对象的程序设计这一章
,下面我们说一下继承。
原型链
继承是面向对象语言一个非常重要的概念,许多OO
语言都支持两种继承方式:接口继承和实现继承,接口继承只继承方法前面,而实现继承则继承实际的方法。而我们的JavaScript
由于函数没有签名,无法实现接口继承,所以只支持实现继承。而且继承主要是通过原型链来实现的。
实际上原型链并没有和我们前面的原型脱节。它的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。
简单的回顾一下构造函数,原型和实例的关系:每一个构造函数都有一个原型对象(prototype
),原型对象都包含一个指向构造函数的指针(constructor
),而实例都包含一个指向原型对象的内部指针([[prototype]]
)。那么,假如,让我们的原型对象指向另一个类型的实例,结果会是什么呢?此时的原型对象将包含一个指向另一个原型对象的指针[[prototype]]
,相应的,另一个原型中也包含着一个指向两一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念
来,看个代码:
//现有父类
function SuperType(){
this.property = true;
}
//给父类添加原型方法
SuperType.prototype.getSuperValue = function () {
return this.property;
}
//创建子类
function SubType() {
this.subproperty = false;
}
//实现继承
SubType.prototype = new SuperType();
//给原型对象添加方法
SubType.prototype.getSubValue = function () {
return this.subproperty;
}
const instance = new SubType();
console.log(instance.getSuperValue()) //true
console.log(instance.getSubValue()) //false
上面的代码定义两个类:SuperType
和subType
.他们实现了继承的关系,而继承是通过把SubType
的原型对象完全重写为新的SuperType
的实例,这样存在于SuperType
的原型对象中的方法同样也存在于它的实例中,也就是存在于SubType.prototype
中,在建立了继承关系之后,我们为SubType.prototype
添加了新的方法getSubValue
。
通过上面的图我们可以直观的看到原型链的实现。
我们从下往上看,instance
指向的原型对象和构造函数的原型当然是一个。由于我们没有使用自带的,而是把它完全重写,所以这个原型对象SubType
的[[prototype]]
同样也指向了它自己的原型对象也就是SuperType
。
我们在调用instance.getSuperValue()
的时候,依据从近到远的法则,层层递进的向上找,自己没有去原型里找,然后原型里也没有,那么就去原型的原型,一直找到了SuperType
中的getSuperValue
和它对应的值。
我们中间给SubType.prototype
添加了一个方法记得不,因为这个时候我们已经把它指向了SuperType
的实例,所以这个方法添加到了这个实例对象上了。
还记得默认原型不?
还记得默认原型不,当我们建立函数时都会有一个默认原型对象,这个原型对象就是Object
的实例,当我们把SubType
的原型对象完全重写为另一个对象时,原来的默认实例就已经被切断,但是我们新继承的实例的默认实例是Object
的实例,或者是它的上一个是,这样的话,我们原型链的顶点就永远是Object
的实例。而那些toString
,valueOf
等方法,你永远都能找到,下图可以直观的看出来。
确定原型和实例的关系
有两种方法可以判断:instanceof
和isPrototypeOf
instance instanceof Object;//true
instance instanceof SuperType;//true
instance instanceof SubType;
Object.prototype.isPrototypeOf(instance); //true
SuperType.prototype.isPrototypeOf(instance); //true
SubType.prototype.isPrototypeOf(instance); //true
原型和原型链模式我们就到这里吧...
over...