博客内容:
- 什么是面向对象
- 为什么要面向对象
- 面向对象编程的特性和原则
- 理解对象属性
- 创建对象
- 继承
什么是面向对象
面向对象程序设计即OOP(Object-oriented programming),其中两个最重要的概念就是对象和类。
JS中的对象都是基于一个引用类型创建的,这个引用类型可以是原生类型,也可以是开发人员定义的类型。
对象是无序属性的集合,由若干个“键值对”(key-value)构成,属性包含基本值、对象或函数。类是具备了某些功能和属性的抽象模型,实际应用中需要对类进行实例化,类在实例化之后就是对象。
而JavaScript语言没有“类”,而改用构造函数(constructor)作为对象基本结构的模板。构造函数专门用来生成对象,一个构造函数可生成多个对象,这些对象都有相同的结构。
为什么要面向对象
为什么要用面向对象,面向对象因为封装,继承,多态的特征使程序更易于扩展,维护,复用,原因很多。
比如我们画了一个三角形,在另外一个环境中我们也要画这个三角形,那么我们只需要将三角形这个对象及形状父级对象引入,剩下关于三角形的操作都是三角形这个对象的内部实现。维护起来也只是去查看该对象的该方法,比在整个环境中找三角形函数要好很多。
就前端开发来说我个人觉得有两个优点:
- 将松散的JS代码进行整合,便于后期的维护。
- 让我们的代码适应更多的业务逻辑。
面向对象编程的特性和原则
前面说过,面向对象有三大特征,封装,继承,多态。
- 封装性:将一个类的使用和实现分开,隐藏对象的属性和实现细节,仅对外提供公共访问方式,提高代码复用性和安全性。
- 继承性:子类自动继承其父级类中的属性和方法,并可以添加新的属性和方法或者对部分属性和方法进行重写,继承增加了代码的复用性,让类与类之间产生了联系,提供了多态的前提。
- 多态性:子类继承了来自父级类中的属性和方法,并对其中部分方法进行重写。(比如函数的length和数组的length都继承自对象但作用不同),提高了代码的扩展性和可维护性。
面向对象原则:
- 开闭原则:
对扩展开放:应用的需求改变时我们可以对模块进行扩展,使其具有满足改变的新行为。
对修改封闭:对模块行为进行扩展是,不必改变模块的源码或二进制代码。 - 接口隔离:
不要依赖用不到的接口。
看到这里觉得概念很模糊,没有关系,后面会讲清楚。
理解对象属性
属性类型
首先创建一个对象,对象字面量是创建对象的首选模式,简单直观。
【例1】:
var person = {
name: 'dot',
sex: 'female',
age: 2,
sayName: function () {
console.log(this.name)
}
}
person对象有3个属性,分别是name,sex,age,有一个sayName方法,JavaScript通过各自的属性值来定义它们的行为。
ECMAScript中有两种属性:数据属性和访问器属性。
- 数据属性:
数据属性包含数据值的位置,在这个位置可以读写值,有四个描述其行为的特性:
- Configurable:表示能否通过delete删除属性从而重新定义属性
- Enumberable:表示能否通过for-in循环枚举属性
- Writable:表示能否修改属性的值
- Value:包含这个属性的数据值,从这个位置读取属性或将新值写入这个位置,默认值是undefined。
直接在对象上定义的属性,其Configurable、Enumberable、Writable默认值均为true,在调用Object.defineProperty()
方法创建新属性时默认值均为false,如果调用Object.defineProperty()
方法只是为了修改已定义的属性的这三个特性,就没有这个限制。
【例1】中,以name属性为例,name是直接在person对象上定义的属性,它的Configurable、Enumberable、Writable值均为true,而Value特性被设置为'dot',对name值做的任何修改都将反映在这个位置上。
Object.defineProperty()
方法用于修改属性默认的特性,这个方法接收3个参数:属性所属对象,属性名,描述符对象,其中,描述符对象是由一组花括号包含的键值对,键是Configurable、Enumberable、Writable、Value中的一个或多个,设置对应值可以修改相应的特性值。
以Configurable为例做个演示,如下所示:
【例2】:
var person = {}
//-----1-----
Object.defineProperty(person, 'name', {
configurable: true,
value: 'dot'
})
console.log(person.name)//dot
//-----2-----
Object.defineProperty(person, 'name', {
value: 'dotttttttt'
})
console.log(person.name)//dotttttttt
//-----3-----
Object.defineProperty(person, 'name', {
configurable: false,
value: 'dooooot'
})
console.log(person.name)//dooooot
delete person.name
console.log(person.name)//dooooot
//-----4-----
Object.defineProperty(person, 'name', {
configurable: true,
value: 'dolby'
})
console.log(person.name)//TypeError: Cannot redefine property: name
按第1-第4部分依次解析代码:
- 首先定义了一个空对象person,因调用
Object.defineProperty()
方法创建新属性时,person对象的name属性的configurable默认值为false,所以先设置configurable值为true,表示name的值是可以被重写的,接着value: 'dot'
将name属性赋值为'dot',所以控制台打印person.name
为dot
- 前面已经设置过
configurable: true
,所以继续重写name的值也是生效的,控制台打印person.name
为dotttttttt
- 设置configurable值为false,并将name重写为 'dooooot',实际上是先将name值重写为'dooooot',再来执行
configurable: false
,表明name的值不可以再被重写了,重写实际上分为两步:删除旧的属性值,添加新的属性值,所以控制台第一次打印person.name
得到'dooooot',接着delete person.name
,不能被重写了即不能再删除和添加,所以delete操作不生效,再次打印person.name
仍旧得到'dooooot' - 这里要说明一个概念,一旦把属性定义为不可配置的,就不能再将其定义为可配置的了。第三步中设置configurable值为false,这里再将其设置为true,是无效的且会抛出错误,说明name属性是不可重新定义的。
多数情况下可能都没有必要用到Object.defineProperty()
方法,但理解这些概念对理解JavaScript对象非常有用。
- 访问器属性:
访问器属性不包含数据值,包含一对可选的get和set函数。
读取访问器属性时调用get函数,get函数返回有效值;写入访问器属性时调用set函数并传入新值,set函数负责处理数据。
访问器属性包含四个特性:
- Configurable:表示能否通过delete删除属性从而重新定义属性
- Enumberable:表示能否通过for-in循环枚举属性
- Get:读取属性时调用的函数,默认值为undefined
- Set:写入属性时调用的函数,默认值为undefined
访问器属性必须用Object.defineProperty()
来定义
【例3】:
var person = {
_year: 2017,
age: 2
}
Object.defineProperty(person, 'year', {
get: function () {
return this._age
},
set: function (newValue) {
if (newValue > 2017) {
this._year = newValue
this.age += newValue - 2017
}
}
})
person.year = 2019
console.log(person.age)//4
以上代码创建了一个person对象并有_year
和age
属性,_year
前的下划线表示只能通过对象方法访问,访问其属性year
包含一个get函数和一个set函数,get函数返回_year
的值2017,set函数接收新值2019并通过计算得到新的年龄。因此,通过修改年份year会导致_year
变为2019,age
变为4,这就是使用访问器属性的常见方式——设置一个属性的值导致其他属性发生变化。
不一定非要同是指定get和set函数,只设置get表示属性只读,只设置set表示属性只写。
定义多个属性
顾名思义,用Object.defineProperties()
方法,可通过描述符一次定义多个属性,与Object.defineProperty()
方法接收的参数上稍有差别,本质没区别。
Object.defineProperties()
接收两个参数,一个是要添加和修改的属性所属对象,一个是描述符。
【例4】:
var person = {}
Object.defineProperties(person, {
_year: {
writable: true,
value: 2017
},
age: {
writable: true,
value: 2
},
year: {
get: function () {
return this._year
},
set: function (newValue) {
if (newValue > 2017) {
this._year = newValue
this.age += newValue - 2017
}
}
}
})
person.year = 2019
console.log(person.age)//4
以上代码在person对象上同时定义了两个数据属性(_year和age),一个访问器属性year。
读取属性的特性
使用Object.getOwnPropertyDescriptor()方法可取得给定属性的描述符对象。这个方法接收两个参数:属性所属对象,要读取其描述符的属性名称。返回的是一个对象,如果是数据属性,这个对象的属性有configurable、enumberable、writable和value,如果是访问其属性,这个对象的属性有configurable、enumberable、get和set。
【例5】:
var person = {}
Object.defineProperties(person, {
_year: {
value: 2017
},
age: {
value: 2
},
year: {
get: function () {
return this._year
},
set: function (newValue) {
if (newValue > 2017) {
this._year = newValue
this.age += newValue - 2017
}
}
}
})
var descriptor = Object.getOwnPropertyDescriptor(person, '_year')
console.log(descriptor.value)//2017
console.log(descriptor.configurable)//false
console.log(typeof descriptor.get)//undefined
var descriptor = Object.getOwnPropertyDescriptor(person, 'year')
console.log(descriptor.value)//undefined
console.log(descriptor.enumerable)//false
console.log(typeof descriptor.get)//function
- 首先读取person对象中
_year
属性的描述符,打印值为初始值2017,通过Object.defineProperties()方法创建的属性其Configurable、Enumberable、Writable特性也都是false,所以打印descriptor.configurable
值为false,_year
为数据属性,没有get方法,所以typeof descriptor.get
打印undefined - 接着读取person对象中
year
属性的描述符,打印值为初始值undefined,通过Object.defineProperties()方法创建的属性其Configurable、Enumberable、Writable特性也都是false,所以打印descriptor.enumerable
值为false,year
是访问器属性且有get方法,所以typeof descriptor.get
是一个函数
创建对象
前面已经讲过,JavaScript语言没有“类”,而改用构造函数Constructor作为对象基本结构的模板。我们可以采用下列模式创建对象。
- 工厂模式:
使用简单函数创建对象并为对象添加属性和方法,最终返回对象,工厂模式已被构造函数模式取代。 - 构造函数模式:
可创建自定义引用类型,可以像创建内置对象实例一样使用new操作符,这种模式的缺点是无法实现复用,也没有封装性可言,而函数与对象具有松散耦合的关系,不能复用的话将面向对象就没有什么意义。 - 原型模式:
使用构造函数的prototype属性来指定共享的属性和方法 - 组合使用构造函数模式与原型模式
使用构造函数定义实例属性,使用原型定义共享的属性和方法
还有几种创建对象的方式如动态原型模式、寄生构造函数模式、稳妥构造函数模式等,因为用得不多所以在此不做介绍。
工厂模式
工厂模式是创建对象,为其添加属性和方法并返回对象的一种设计模式。
【例6】:
function createPerson(name, sex, age) {
var o = new Object()
o.name = name
o.sex = sex
o.age = age
o.sayName = function () {
console.log(this.name)
}
return o
}
var person1 = createPerson('dot', 'female', 2)
var person2 = createPerson('dolby', 'male', 3)
看代码会发现,每一次调用createPerson函数,每次都会返回包含三个属性一个方法的对象,无法做到对象识别,于是出现了构造函数模式。
构造函数模式
构造函数可以创建特定类型的对象,Object、Array、RegExp等是原生构造函数,运行时会自动出现在执行环境中并拥有相应的方法,如下所示:
此外我们也可以创建自定义的构造函数,从而自定义对象类型的属性和方法,可用构造函数模式将【例6】重写如下:
【例7】:
function Person(name, sex, age) {//这里的Person就是构造函数
this.name = name//运行时才知道this指向什么,定义的时候永远不清楚
this.sex = sex//运行时才知道this指向什么,定义的时候永远不清楚
this.age = age//运行时才知道this指向什么,定义的时候永远不清楚
this.sayName = function () {//运行时才知道this指向什么,定义的时候永远不清楚
console.log(this.name)
}
}
var person1 = new Person('dot', 'female', 2)//person1就是Person的实例
var person2 = new Person('dolby', 'male', 3)//person2也是Person的实例
这个例子中,Person()函数取代了createPerson()函数,不同之处在于:
- 没有显示地创建对象
- 直接将属性和方法赋给了this对象
- 没有return语句
此外,函数名Person使用的是大写字母P,按照惯例,构造函数始终都应以大写字母开头,而普通函数应以小写字母开头,这样做没有什么特殊作用,只是为了区分普通函数与构造函数,构造函数本身也是函数,只是能用于创建对象而已。
要创建 Person构造函数的实例,必须使用new操作符,以这种形式调用构造函数实际上会经历一下四个阶段:
- 创建一个新对象
- 将构造函数的作用域赋给新对象,这样this就指向了这个新对象
- 执行构造函数中的代码为这个新对象添加属性
- 返回新对象
可以实现一个create函数,模拟原生的new操作符,有兴趣可以看看:
【例8】:
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype = {
prototype: 'type'
}
function create(constructor) {
var args = Array.prototype.slice.call(arguments, 1)//将传进来的参数转化为数组并借用数组的slice方法获取索引为1(包括索引1的项)的项开始的所有项,索引为0的项是传进来的constructor
var obj = {}//创建一个空对象
obj.__proto__ = constructor.prototype//将新创建的对象的原型指向构造函数的原型
var res = constructor.apply(obj, args)//apply接受两个参数,第一个是this,第二个是参数数组,res是调用Person后得到的结果对象
if (typeof res === 'object' && res !== null) {
return res
}
return obj
}
var test = create(Person, 'dot', 2)
console.log(test)//{name:'dot,age:2}
console.log(test.prototype)//type
【例7】中,person1和person2分别保存着Person的一个不同的实例,这两个对象都有一个constructor属性,指向Person,如下所示:
console.log(person1.constructor === Person)//true
console.log(person2.constructor === Person)//true
constructor属性最初是用来标识对象类型的,但提到检测对象类型,还是instanceof操作符靠谱,我们在【例7】中创建的对象既是Object的实例,同时也是Person的实例,这一点通过instanceof操作符可以验证:
console.log(person1 instanceof Object)//true
console.log(person1 instanceof Person)//true
console.log(person2 instanceof Object)//true
console.log(person2 instanceof Person)//true
创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型,这正是构造函数模式取代了工厂模式的原因,person1与person2之所以同时是Object的实例,是因为所有对象均继承自Object,后面会讲到,以构造函数模式定义的构造函数是定义在浏览器的window对象上的。
必须要提到的是this,【例7】中的this.name
,this并不是指Person对象,而是调用构造函数Person的新实例,然而this远远不止这里的那么简单,实际运用过程中会猜到无数的坑,这里不多讲,而且我也不太懂。
构造函数也是函数,不存在定义构造函数的特殊语法,任何函数只要通过new操作符来调用,那它就可以作为构造函数,而任何函数如果不通过new操作符调用,那它就是普通函数,【例7】中定义的函数可以通过下列几种方式来调用:
【例9】:
//方法1
var person = new Person('dot', 'female', 2)
person.sayName()//dot
//方法2
Person('dolby', 'male', 3)
window.sayName()//dolby
//方法3
var o = new Object()
Person.call(o, 'dooot', 'female', 4)
o.sayName()//dooot
- 方法1中展示了构造函数Person的典型用法,用new操作符创建新对象。
- 方法2展示的就是调用普通函数得到的结果,在浏览器全局中调用一个函数时,this对象总是指向window对象,因此调用函数之后可通过window对象来调用sayName()而且返回了结果,其实没有那么难以理解,分析一下
Person('dolby', 'male', 3)
,在全局中调用,改写代码为Person.call(context, 'dolby', 'male', 3)
,this 是你call一个函数时传的context。
浏览器里有一条规则:
如果你传的 context 是 null 或者 undefined,那么 window 对象就是默认的 context(严格模式下默认 context 是 undefined),因此上面的this对象指向window对象。
可能我没有讲清楚,方方老师的this 的值到底是什么?一次说清楚这篇文章非常浅显易懂,可以经常阅读、经常阅读、经常阅读,重要的事说三遍。 - 方法3就是将我以上说的context换乘对象o,所以this指向了对象o,于是调用函数后可通过对象o来调用sayName()并返回了结果。
构造函数模式存在的问题:
每个方法都要在每个实例上重新创建一遍,前面的例子中,person1和person2都有一个sayName()方法,但不是同一个Function的实例,因为每定义一个函数,就是实例化一个对象,不同实例上的同名函数是不相等的
console.log(person1.sayName === person2.sayName)//false
有this对象在,创建两个一模一样的Function实例没有必要,我们可以把sayName方法转移到构造函数外来解决重复实例化的问题。
【例9】:
function Person(name, sex, age) {
this.name = name
this.sex = sex
this.age = age
this.sayName = sayName
}
function sayName() {
console.log(this.name)
}
var person1 = new Person('dot', 'female', 2)
var person2 = new Person('dolby', 'male', 3)
person1.sayName()//dot
以上代码解决了两个函数做同一件事的问题,但这样一来,全局作用域定义的函数实际上只能被某个特定的对象调用,这让全局作用域变得名不副实,而且如果对象需要定义很多个方法,我们就要创建很多个全局函数,于是我们自定义的引用类型就没有丝毫封装性可言了,不符合面向对象的设计原则,于是出现了下面的原型模式。
原型模式
我们创建的每一个函数都有一个prototype(原型)属性,这个属性是一个指向某个对象的指针,这个对象的用途是可以包含由特定类型的所有实例共享的属性和方法。换句话说,我们不必在构造函数中定义对象实例的信息,而是直接将这些信息添加到原型对象中
【例10】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)//dot
}
var person1 = new Person()
person1.sayName()//dot
var person2 = new Person()
person2.sayName()//dot
console.log(person1.sayName() === person2.sayName())//true
我们将构造函数Person变成了空函数,与构造函数模式不同的是,person1余person2访问的是同一组属性和同一个sayName()函数了。
要理解原型模式的工作原理,必须先理解原型对象的性质。
- 理解原型对象:
- 只要创建了一个新函数,就会为该函数创建一个prototype属性,这个属性指向函数的原型对象,默认情况下,所有的原型对象都会自定获得一个constructor属性,指向它的构造函数。
拿【例10】来说,Person.prototype.constructor
指向Person
,通过这个构造函数,我们可以继续为原型对象添加属性和方法。 - 创建了自定义的构造函数之后,其原型对象默认只会有constructor属性,其他方法都是由Object继承得来的,继承后面会讲。
- 当调用构造函数创建一个新实例后,该实例的内部将包含一个
__proto__
指针指向构造函数的原型对象。
要明确最重要的一点是,这个连接存在于实例与原型对象之间,而不是实例与构造函数之间。
以【例10】为例可以画出以上文字解析对应的原型图
从图中可看出,person1和person2都不包含属性和方法,但我们为什么可以调用sayName()方法,这是通过查找对象属性的过程来实现的。
我们可以通过两个方法来验证实例的与原型对象之间是否存在某种联系。
- isPrototypeOf(),直译过来就是“是***的原型吗”
console.log(Person.prototype.isPrototypeOf(person1))//true
console.log(Person.prototype.isPrototypeOf(person2))//true
- Object.getPrototypeOf(),取得对象的原型,此方法在利用原型实现继承中很重要
console.log(Object.getPrototypeOf(person1) === Person.prototype)//true
console.log(Object.getPrototypeOf(person2).name)//dot
每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性,搜索遵循从内向外的原则,首先从对象实例本身开始,找到了就返回该属性值,没有找到就继续搜索指针指向的原型对象,找了了就返回该属性值。也就是说上例中,当我们调用person1.sayName()方法时,会先后执行两次搜索,通过person2调用sayName()方法也是这样,这正是多个实例共享原型的属性和方法的基本原理。
我们可以通过实例去访问但不能重写原型对象中的属性和方法,在实例中定义与原型对象上相同的属性或方法只会存在于实例中,并且会覆盖原本取得的原型中的属性。
【例11】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var person1 = new Person()
var person2 = new Person()
person1.name = 'dolby'
console.log(person1.name)//dolby
console.log(person2.name)//dot
上例体现了属性在原型中的搜索机制。
在实例中添加同名属性只会阻止该实例访问原型中的同名属性,但不会修改原型中的同名属性。使用delete操作度可以完全删除实例属性以恢复其指向原型的连接。
【例12】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var person1 = new Person()
var person2 = new Person()
person1.name = 'dolby'
console.log(person1.name)//dolby,来自实例
delete person1.name//删除实例属性,恢复实例与原型的连接
console.log(person1.name)//dot,来自原型
console.log(person2.name)//dot,来自原型
使用hasOwnProperty()方法可以检测一个属性到底是存在于原型中还是存在于实例中,这个方法从Object继承得来,只在属性存在于对象实例中才返回true。
【例13】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var person1 = new Person()
var person2 = new Person()
console.log(person1.hasOwnProperty('name'))//false
person1.name = 'dolby'
console.log(person1.name)//dolby,来自实例
console.log(person1.hasOwnProperty('name'))//true
console.log(person2.name)//dot,来自原型
console.log(person2.hasOwnProperty('name'))//false
delete person1.name//删除实例属性,恢复实例与原型的连接
console.log(person1.name)//dot,来自原型
console.log(person1.hasOwnProperty('name'))//false
通过使用hasOwnProperty()方法,什么时候访问的是实例属性,什么时候访问的是原型属性就一清二楚了。
- 原型与in操作符
有两种使用方式使用in操作符,单独使用和在for-in循环中使用。
- 单独使用时,in操作符会在通过对象能够访问到给定属性时返回true,无论该属性存在于实例还是原型中。
【例13】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var person1 = new Person()
var person2 = new Person()
console.log(person1.hasOwnProperty('name'))//false
console.log('name' in person1)//true
person1.name = 'dolby'
console.log(person1.name)//dolby,来自实例
console.log(person1.hasOwnProperty('name'))//true
console.log('name' in person1)//true
console.log(person2.name)//dot,来自原型
console.log(person2.hasOwnProperty('name'))//false
console.log('name' in person2)//true
delete person1.name//删除实例属性,恢复实例与原型的连接
console.log(person1.name)//dot,来自原型
console.log(person1.hasOwnProperty('name'))//false
console.log('name' in person1)//true
以上代码执行过程中,name属性要么是直接在对象上访问到的,要么是通过原型访问到的,所以调用'name' in person
始终返回true,同时使用hasOwnProperty()和in操作符就可以确定属性到底存在于原型还是实例中
【例14】:
function hasPrototypeProperty(object, name) {
return !object.hasOwnProperty(name) && (name in object)
}
in操作符只要能访问到属性就返回true,而hasOwnProperty()只有在属性存在于实例中才返回true,所以只要in操作符返回true,hasOwnProperty()返回false就可以确定属性存在于原型中。
所以以上hasPrototypeProperty(object, name){}
函数的意思是,object上的属性name来自原型吗?
用法如下:
【例15】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var person = new Person()
console.log(hasPrototypeProperty(person, 'name'))//true
person.name = 'dolby'
console.log(hasPrototypeProperty(person, 'name'))//false
- 使用for-in循环时,返回的是所有能够通过对象访问的可枚举的属性,其中既包括实例中的属性,也包括原型中的属性,屏蔽了原型中的不可枚举的属性(将enumberable标记为false的属性)的实例属性也会在for-in循环中被返回。
要去的对象上所有的额可枚举属性,可以使用Object.keys()方法,这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组
【例16】:
function Person() {
}
Person.prototype.name = 'dot'
Person.prototype.sex = 'female'
Person.prototype.age = 2
Person.prototype.sayName = function () {
console.log(this.name)
}
var keys=Object.keys(Person.prototype)
console.log(keys)//[ 'name', 'sex', 'age', 'sayName' ]
var person1 = new Person()
person1.name = 'dolby'
person1.age=3
var person1Keys=Object.keys(person1)
console.log(person1Keys)//[ 'name', 'age' ]
如果想得到所有的实例属性,无论其是否可枚举,可以使用Object.getOwnPropertyNames()方法
【例17】:
var keys=Object.getOwnPropertyNames(Person.prototype)
console.log(keys)//[ 'constructor', 'name', 'sex', 'age', 'sayName' ]
其中,constructor属性是不可枚举的,Object.keys()方法和Object.getOwnPropertyNames()方法都可以替代for-in循环。
- 更简单的原型语法
前面写的例子代码量都太多了,我们可以从视觉上更好的封装原型的功能,用对象字面量形式重写整个原型对象
【例18】:
function Person() {
}
Person.prototype = {
name: 'dot',
sex: 'female',
age: 2,
sayName: function () {
console.log(this.name)
}
}
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属性不再指向其构造函数了,前面讲过,每创建一个函数都会同时创建它的prototype对象,这个对象也会自动获得constructor属性,我们在这里用对象字面量的方式重写了对象,也完全重写了默认的prototype对象,因此constructor属性也就变成了新对象的constructor属性,指向Object构造函数。尽管instanceof操作符还能返回正确结果,但通过constructor已经无法确定对象的类型了。
如果constructor的值很重要,可以将其显式地设置为适当值。
【例19】:
function Person() {
}
Person.prototype = {
constructor: Person,
name: 'dot',
sex: 'female',
age: 2,
sayName: function () {
console.log(this.name)
}
}
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
})
var friend = new Person()
console.log(friend instanceof Object)//true
console.log(friend instanceof Person)//true
console.log(friend.constructor === Person)//true
- 原型的动态性
先看一下以下两个例子:
【例20】:
function Person() {
}
var friend = new Person()
Person.prototype.sayHi = function () {
console.log('hi')
}
friend.sayHi()//hi
【例21】:
function Person() {
}
var friend = new Person()
Person.prototype = {
constructor: Person,
name: 'dot',
sex: 'female',
age: 2,
sayName: function () {
console.log(this.name)
}
}
friend.sayName()//TypeError:friend.sayName is not a function
从【例20】可以得出的结论是,我们对原型对象所做的任何修改都能立即从实例上反映出来,即便是先创建了实例后修改原型也是如此,原因可归结为实例与原型间的松散连接关系,实例与原型间的连接不过是一个指针,而不是一个副本,所以可以动态地查找。
那么【例21】为什么会报错呢?因为【例21】实际上是重写了整个原型对象,从而切断了构造函数与最初原型之间的联系,构造函数指向新的原型对象,而实例仍指向原本的原型对象,而原本的原型对象中不存在sayName()方法,所以调用会报错。
什么样的情况下调用sayName()方法会返回我们希望看到的值呢?
只要在重写原型对象之后再定义实例就好了
【例22】
function Person() {
}
Person.prototype = {
constructor: Person,
name: 'dot',
sex: 'female',
age: 2,
sayName: function () {
console.log(this.name)
}
}
var friend = new Person()
friend.sayName()//dot
可以画图演示重写原型对象前后的指针指向
- 原生对象的原型
原生的引用类型也是采用原型模式创建的,所有原生引用类型(Array、String等)都在其构造函数的原型上定义了方法,可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法,
通过原生对象的原型不仅可以取得默认方法的引用,也可以定义新方法
【例23】
Array.prototype.shuffle = function () {
var arr = this
for (var i = arr.length - 1; i >= 0; i--) {
var randomIdx = Math.floor(Math.random() * (i + 1))
var itemAtIdx = arr[randomIdx]
arr[randomIdx] = arr[i]
arr[i] = itemAtIdx
}
return arr
}
var tempArr = [1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(tempArr.shuffle())//[ 5, 9, 6, 8, 4, 7, 3, 1, 2 ]
以上代码中创建了一个shuffle方法,用于随机排列数组中的元素,我们将该方法挂载到Array对象的原型下,当前环境下的任何数组都可以调用该方法。如:
var tempArr=[1, 2, 3, 4, 5]
console.log(tempArr.shuffle())//[3, 2, 1, 4, 5]
尽管可以这样做,但不推荐修改原生对象的原型,如果因为某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码是,可能导致命名冲突,而且这样做也可能意外导致重写原生方法。
- 原型对象的问题:
原型模式省略了为构造函数初始化参数这一环节,导致所有的实例在默认情况下都取得相同的属性值,但这不算什么,最大的问题是由其共享的本质导致的。
【例24】:
function Person() {
}
Person.prototype = {
constructor: Person,
name: 'dot',
sex: 'female',
age: 2,
fav: ['milk', 'orange', 'apple'],
sayName: function () {
console.log(this.name)
}
}
var person1 = new Person()
var person2 = new Person()
person1.fav.push('meat')
console.log(person1.fav)//[ 'milk', 'orange', 'apple', 'meat' ]
console.log(person2.fav)//[ 'milk', 'orange', 'apple', 'meat' ]
console.log(person1.fav === person2.fav)//true
问题还是很突出的,这里的结果让人瞬间想起了最基本的概念,基本类型值按值传递,引用类型值按引用传递。因为person1.fav与person2.fav指向的是内存中的同一地址,所以对person1.fav的修改在person2.fav中也会反映出来,而实例一般都要拥有属于自己的全部属性,所以原型模式还是存在比较严重的缺陷。
组合使用构造函数模式与原型模式
组合使用构造函数模式与原型模式是创建自定义类型的最常见方式。
分工:构造函数模式用于定义实例属性,原型模式用于定义共享的属性和方法。
结果:每个实例都有自己的一份实例属性副本,但同时又可以共享对方法的引用,最大限度节省了内存,集两种模式之长,是使用最广泛,认同度最高的一种创建自定义类型的方法。
用组合模式改写前面的代码
【例25】:
function Person(name, age) {
this.name = name
this.age = age
this.fav = ['milk', 'orange', 'apple']
}
Person.prototype = {
constructor: Person,
sayName: function () {
console.log(this.name)
}
}
var person1 = new Person('dot', 2)
var person2 = new Person('dolby', 3)
person1.fav.push('meat')
console.log(person1.fav)//[ 'milk', 'orange', 'apple', 'meat' ]
console.log(person2.fav)//[ 'milk', 'orange', 'apple' ]
console.log(person1.fav === person2.fav)//false
console.log(person1.sayName === person2.sayName)//true
上例中,实例属性都在构造函数Person中定义,所有实例的共享属性和方法都是在原型中定义的,修改了person1.fav后并不会影响到person2.fav,因为它们分别引用了不同的数组。这种方法用于创建对象可以说是非常完美了。
现在可以深究一下,prototype 是什么?有什么特性?
- 每个函数都有
prototype
这个属性,对应值是原型对象,prototype被设计出来就是用来公用的,它是js继承机制的灵魂。 - 每个对象都有个内部属性
__proto__
,每个实例的__proto__
指向创建它的构造函数的prototype
- 一切函数都是由 Function 这个函数创建的,所以
Function.prototype === 被创建的函数.__proto__
- 一切函数的原型对象都是由 Object 这个函数创建的,所以
Object.prototype === 一切函数.prototype.__proto__
那么来画上面代码的原型图练手吧~
继承
JavaScript主要通过原型链实现继承,原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的,这样一来,子类型就可以访问超类型的所有属性和方法,原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。
解决方法是在子类型构造函数内部调用超类型构造函数,可以做到每个实例都拥有一套自己的属性,同时保证只使用构造函数模式来定义类型。
有六种继承模式:
- 构造函数继承:
在子类型构造函数的内部调用超类构造函数,通过使用call()和apply()方法可以在新创建的对象上执行构造函数。
优点:子类的每个实例都有自己的属性(name),不会相互影响。
缺点:没有实现父类方法的复用。 - 原型链继承:
每个构造函数都有一个原型对象,原型对象包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。让一个构造函数的原型对象等于另一个构造函数的实例,以此类推实现原型链继承
优点:父类的方法(getName)得到了复用。
缺点:所有的实例会共享通过原型链继承的属性,在一个实例中改变了,会在另一个实例中反映出来 - 组合继承:
使用原型链实现对原型属性和方法的继承,借用构造函数实现对实例属性的继承。
优点:既实现了函数复用,又保证每个实例具有自己的属性。
缺点:无论什么情况下都会调用两次超类型的构造函数(SuperType),一次在创建子类型原型的时候,另一次是在子类型构造函数内部。 - 原型式继承:
借助原型基于已有的对象创建新对象,同时在创建的过程中加以修改,达到了继承原有属性和方法并可以添加新属性和方法的目的。
优点:不用创建自定义类型,新对象上具有原来的属性和方法,又可以增加新的属性和方法且不会影响到原来的对象。
缺点:如果原有对象包含有引用类型值,值会被所有实例共享,改变一个就全变了 - 寄生式继承:
与原型继承很相似,也是基于某个对象或某些信息创建一个对象,然后增强并返回对象
优点:可解决组合继承模式多次调用超类型构造函数导致的低效率问题
缺点:不能做到函数复用 - 寄生组合式继承:
不必为了制定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。
本质上就是使用寄生式继承来继承超类型的原型,再将结果指定给子类型的原型。
构造函数继承
基本思想:在子类型构造函数的内部调用超类构造函数,通过使用call()和apply()方法可以在新创建的对象上执行构造函数。
function Parent() {
this.name = 'parent'
this.sayName = function () {
console.log(this.name)
}
}
function Child() {
Parent.call(this)
this.type = 'child'
}
var qinghua = new Parent()
console.log(qinghua)//Parent { name: 'parent', sayName: ƒ }
var dot = new Child()
console.log(dot)//Child {name: "parent", sayName: ƒ, type: "child"}
console.log(dot.fn())//parent
原型链继承
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
}
function subType() {
this.property = false;
}
//继承了SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function (){
return this.property;
}
var instance = new SubType();
console.log(instance.getSuperValue());//true
原型式继承
基本思想:借助原型可以基于已有的对象创建新对象,还不必因此创建自定义类型。
【例26】:
function object(o){
function F(){}
F.prototype=o
return new F()
}
var person={
name:'dot',
friends:['a','b','c']
}
var anotherPerson=object(person)
anotherPerson.name='dolby'
anotherPerson.friends.push('d')
var anotherPerson2=object(person)
anotherPerson2.name='dooot'
anotherPerson2.friends.push('e')
console.log(person.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
console.log(anotherPerson.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
console.log(anotherPerson2.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
原型式继承要求必须有一个对象作为另一个对象的基础,如果有这么个对象,就将它传递给object()函数,然后根据具体需求得到的对象加以修改,上例中,person.friends不仅属于person所有,而且也会被anotherPerson和anotherPerson2共享,他们因用的是内存中的同一片地址,所以改变其中一个,另两个的值也会动态改变。
ECMAScript5新增了Object.create()方法规范化了原型式继承,这个方法接收两个参数:一个用作新对象原型的对象和可选的为新对象定义额外属性的对象。Object.create()方法实际上就是创建一个接收的参数的副本。
在只传入一个参数的情况下,Object.create()方法与【例26】中的object()函数行为相同。
【例27】:
var person = {
name: 'dot',
friends: ['a', 'b', 'c']
}
var anotherPerson = Object.create(person)
anotherPerson.name = 'dolby'
anotherPerson.friends.push('d')
var anotherPerson2 = Object.create(person)
anotherPerson2.name = 'dooot'
anotherPerson2.friends.push('e')
console.log(person.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
console.log(anotherPerson.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
console.log(anotherPerson2.friends)//[ 'a', 'b', 'c', 'd', 'e' ]
Object.create()的第二个参数与Object.defineProperties()的第二个参数格式相同,每个属性都是通过自己的描述符定义的,以这种方式指定的任何属性都会覆盖原型对象上的同名属性。
【例28】:
var person = {
name: 'dot',
friends: ['a', 'b', 'c']
}
var anotherPerson = Object.create(person, {
name: {
value: 'dolby'
}
})
console.log(anotherPerson.name)//dolby
寄生式继承
寄生式继承是与原型式继承紧密相关的一种思路。创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像是真的是它做了所有工作一样返回该对象。
【例29】:
function object(o){
function F(){}
F.prototype=o
return new F()
}
function createAnother(original) {
var clone = object(original)//通过调用函数创建一个新对象
clone.sayHi = function () {//增强对象
console.log('hi')
}
return clone//返回这个对象
}
var person={
name:'dot',
friends: ['a', 'b', 'c']
}
var anotherPerson=createAnother(person)//基于person返回了一个新对象anotherPerson
anotherPerson.sayHi()//新对象anotherPerson不仅有person对象的所有属性和方法,还有自己的sayName()方法
在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也也是一种有用的模式,任何能够返回新对象的函数都适用于此模式。
而使用寄生式继承来为对象添加函数也不能做到函数复用。
组合继承
也叫做经典继承,指的是将原型链和借用构造函数技术组合从而发挥二者之长的一种继承模式,主要思路是:使用原型链实现对原型属性和方法的继承,借用构造函数实现对实例属性的继承,这样既实现了函数复用,又保证每个实例具有自己的属性。
【例30】:
function SuperType(name) {
this.name = name
this.friend = ['a', 'b', 'c']
}
SuperType.prototype.sayName = function () {
console.log(this.name)
}
function SubType(name, age) {
SuperType.call(this, name)//继承属性,第二次调用SuperType
this.age = age
}
SubType.prototype = new SuperType()//第一次调用SuperTy
SubType.prototype.constructor = SubType
SubType.prototype.sayAge = function () {
console.log(this.age)
}
var instance1 = new SubType('dot', 2)
instance1.friend.push('d')
console.log(instance1.friend)//[ 'a', 'b', 'c', 'd' ]
instance1.sayName()//dot
instance1.sayAge()//2
var instance2 = new SubType('dolby', 3)
console.log(instance2.friend)//[ 'a', 'b', 'c']
instance2.sayName()//dolby
instance2.sayAge()//3
上例中,SuperType构造函数定义了两个属性,name和friend,SuperType的原型定义了一个方法sayName(),SubType构造函数通过调用SuperType构造函数传入了参数name属性,接着定义了自己的age属性,然后将SuperType的实例赋给SubType的原型,又在该原型上定义了sayAge()方法。这样就可以让两个不同的SubType实例分别用有自己的属性,包括friend属性,又可以使用相同的方法。
组合继承是JS中最常用的继承模式,但它有一个缺点就是,无论什么情况下都会调用两次超类型的构造函数(SuperType),一次在创建子类型原型的时候,另一次是在子类型构造函数内部。
还是看【例30】中的代码,里面有标识两次调用SuperType的时机,第一次调用SuperType构造函数时,SubType.prototype会得到SuperType的实例属性name和friend;第二次调用发生在调用SubType构造函数时,这一次在新对象上创建了实例属性name和friend,接着这两个属性就屏蔽了原型中的两个同名属性。解决调用两次超类型构造函数而导致的效率低下的问题的办法就是寄生组合式继承。
寄生组合式继承
寄生组合式继承的思路是:不必为了制定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。
本质上就是使用寄生式继承来继承超类型的原型,再将结果指定给子类型的原型。
【例31】:
function Person(name, sex) {
this.name = name
this.sex = sex
}
Person.prototype.sayName = function () {
console.log(this.name)
}
function Male(name, sex, age) {
Person.call(this, name, sex)
this.age = age
}
Male.prototype = Object.create(Person.prototype);
Male.prototype.sayAge = function () {
console.log(this.age)
}
var dot = new Male('dot', 'male', 2)
dot.sayName()//dot
上例貌似没问题了,但有个问题,我们知道prototype对象有一个属性constructor指向其类型,而我们复制的SuperType的prototype,这时候constructor属性指向是不对的,导致我们判断类型出错
console.log(Male.prototype.constructor)//[function: Person]
console.log(dot.constructor)//[function: Person]
console.log(Male.prototype.constructor === dot.constructor)//true
所以我们还需要修改一下constructor的指向,通过一个inherit函数可以做到这一点,看以下代码:
【例32】:
function inherit(superType, subType) {
var _prototype = superType.prototype
_prototype.constructor = subType//修改constructor指向
subType.prototype = _prototype
}
function Person(name, sex) {
this.name = name
this.sex = sex
}
Person.prototype.sayName = function () {
console.log(this.name)
}
function Male(name, sex, age) {
Person.call(this, name, sex)
this.age = age
}
inherit(Person, Male)//Male继承Person
// 在继承函数之后写自己的方法,否则会被覆盖
Male.prototype.sayAge = function () {
console.log(this.age)
}
var dot = new Male('dot', 'male', 2)
dot.sayName()//dot
console.log(Male.prototype.constructor)//[function: Male]
console.log(dot.constructor)//[function: Male]
console.log(Male.prototype.constructor === dot.constructor)//true
现在就没有问题了,可以说很完美。
那么来试一下画出以下代码继承的原型图:
Object.__proto__ === Function.prototype
Function.prototype.__proto__ === Object.prototype
Function.__proto__ === Function.prototype
Object.prototype.__proto__ === null
【例33】
//定义一个继承的函数,确保指针指向正确
function inherit(SuperType, SubType) {
var _prototype = Object.create(SuperType.prototype)
_prototype.constructor = SubType
SubType.prototype = _prototype
}
//定义一个爷爷辈构造函数,有一个实例属性name
function Person(name) {
this.name = name
}
//爷爷辈构造函数的原型上有一个sayName方法
Person.prototype.sayName = function () {
console.log(`i am ${this.name}`)
}
//定义一个爸爸辈构造函数,有两个实例属性name和skill
function Developer(name, skill) {
Person.call(this, name)
this.skill = skill
}
//调用继承函数,让爸爸继承爷爷的属性和方法
inherit(Person, Developer)
//爸爸在继承之后定义自己的saySkill方法,以免被覆盖
Developer.prototype.saySkill = function () {
console.log(`i have a skill ${this.skill}`)
}
////定义一个儿子辈构造函数,有三个实例属性name、skill、frontendSkill
function FEDeveloper(name, skill, frontendSkill) {
Developer.call(this, name, skill)
this.frontendSkill = frontendSkill
}
//调用继承函数,让儿子继承爸爸的属性和方法
inherit(Developer, FEDeveloper)
//儿子在继承之后定义自己的sayFESkill方法,以免被覆盖
FEDeveloper.prototype.sayFESkill = function () {
console.log(`i have a frontendSkill ${this.frontendSkill}`)
}
//定义一个爷爷的实例并传入实参,将这个实例赋值给变量person1
var person1 = new Person('dooot')
//定义一个爸爸的实例并传入实参,将这个实例赋值给变量developer
var developer = new Developer('dot', 'node')
//定义一个儿子的实例并传入实参,将这个实例赋值给变量fe
var fe = new FEDeveloper('dolb', 'program', 'css')
console.log(fe.__proto__ === FEDeveloper.prototype)//true
console.log(fe.constructor === FEDeveloper)//true
console.log(FEDeveloper.prototype.constructor === FEDeveloper)//true
console.log(FEDeveloper.prototype.__proto__ === Developer.prototype)//true
console.log(developer.constructor === Developer)//true
console.log(Developer.prototype.__proto__ === Person.prototype)//true
注:画图不易,本文所有图片,禁止转载,谢谢。
参考资料: