js作为一门松散的语言,如果有严格class_base
面向对象语言的经验,如java,C#的,经常会有WTF的感受,不是说好了面向对象吗,怎么类不是类,继承不好好继承,多态到混乱的感觉,也写了不短时间的js,最感慨的就是:前端程序员们,竟然使用一门如此简陋的语言创造了如此异彩纷呈的互联网世界
,这比js本身更WTF,不信去那个著名的诡异js的网站测测,看能做对几个题💔。好了不在吐槽引战了,本篇就来彻底说说js中的面向对象
在说明之前,特别需要指出的是,不要因为对js这种prototype_base
不了解,就按照java等的标准去有色的去看js,殊不知从面向对象这个层面上来说,只要满足对操作和消息的封装,不管是class_base
还是prototype_base
,即便实现方式差异巨大,但无疑都是最纯正的面向对象
在js中它本身并没有明确的类的概念,这是最让很多人WTF的地方,面向对象居然没有类,但是如果从OOP的本意出发,只要可以用引用类型抽象出具有相同特性的结构,对操作和消息进行黑盒实现,那么就是面向对象,js中这个封装结构就是Object对象
,在ECMA-262
中这样给对象
定义:无序属性的集合就是对象,其属性可以包含基本值,对象和函数
,所以看起来就是一组没有特定顺序的键值对集合。对象的每个属性或者方法都有一个名字,而每个名字都会有映射的值。所以从另一角度来说,ECMAScript的对象就是散列表
–一组键值对,值可以是基本值或函数。当然想所有的面向对象语言一样,对象是基于引用类型创建的
从属性说起
回顾一下对象的创建:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// Object实例创建
var person = new Object()
person.name = 'Tony stark'
person.age = 42
person.job = 'Iron man'
person.sayName = function() {
alert(this.name)
}
// 更加常见的 字面量 形式
var person = {
name: 'Tony',
age: 42,
job: 'Iron man'
sayName: function() {
alert(this.name)
}
}
当创建一个对象的时候,除了我们所定义的属性,对象本身还有为实现JavaScript引擎,还有为属性规定其特征的内部属性
,也称之为特征属性
,通常这些内部值是不能访问的,为了区分将其规范到2对方括号中,而特征属性可分为2种:
1 表征数据的特征属性1
2
3
4[[Configurable]] // 表示能否通过delete删除属性,能否修改,默认为true
[[Enmuerable]] // 表示是否可通过 for-in 遍历属性,默认为true
[[Writable]] // 可写的
[[value]] // 就是具体的属性数据值
以上这种属性的属性
,大家都能嗅出权限控制
的味道来,实时就是如此,并且js提供了Object.defineProperty()
方法来控制,如:1
2
3
4
5
6
7
8var person = {}
Object.defineProperty(person, "job", {
configurable: false,
value: "Iron man"
})
delete person.job
alert(person.job) // Iron man
事实以上在严格模式之下,是会抛出异常的,这就是特征属性
对属性数值的规范,并且一旦规定configurable: false
,无法使用Objcet.defineProperty()
方法修改除writable
之外的任何特征,在使用中尤其重要的一点是,Object.defineProperty()方法,如果不指定,会默认设置enumerable,configurable,writable为false的
,即便没有人真的用这个方法来创建属性,这一点也要足够审慎
2 访问器特指属性
这一种特征属性不包含数值,只包含了一对儿setter 和 getter
,更具体一些就是:1
2
3
4[[Configurable]] // 表示能否通过delete删除属性,能否修改,默认为true
[[Enmuerable]] // 表示是否可通过 for-in 遍历属性,默认为true
[[Get]] // 默认值是undefined
[[Set]] // 默认值是undefined
对于访问器属性,也只能通过Object.defineProperty()/defineProperties()
设置,用法就不多说了,绝大多数就是用以设置一些对象默认的值
而对于一个已有属性的特征属性,可以通过Object.getOwnPropertyDescriptor()
得到
对象创建和扩展
看完了属性,让我们注意力转回到对象的创建上,毕竟我们的开发就是在频繁的创建或者扩展对象,先直接看代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// 工厂模式创建
function createPerson(name, age, job) {
var obj = {
name: 'Tony',
age: 42,
job: 'Iron man'
sayName: function() {
alert(this.name)
}
}
return obj
}
// 构造函数创建
function Person(name, age, job) {
this.name= name;
this.age= age;
this.job= job;
this.sayName= function() {
alert(this.name)
};
}
var tony = new Person('tony', 29, 'Iron man')
第一种方式很好理解,但一个突出的问题是,我们虽然创建了对象,但并不显示的知道对象的类型,这是不能接受的;而第二种方式,显然很明确,并且使用了 new 操作符,new实际上包含了:创建对象 -> 构造函数作用域赋给创建的对象 -> 执行构造函数 -> 返回新对象
的完整过程
然后集中到构造函数中,向Java等靠拢,习惯性的大写,但它之中并没有显示的创建对象的操作,更没有直接把属性值赋给this对象,也没有return, 这是因为 new操作符都做了
对于一个对象,构造函数本身,就对应于constructor
属性,可以这样验证:1
2
3var p1 = new Person('p1', 12, xxx)
var p2 = new Perosn('p2', 22, yyy)
p1.constructor == p2.constructor == Person // true
但使用构造函数并不是没有缺陷,如上述的p1和p2, 其sayName
由于是函数,我们知道本身也是对象,所以各自实例花了自己的Function,换个角度会更明白一些:this.sayName: new Function('alert(this.name)')
可见p1和p2的sayName的值,的确是各自分别地实现化了,那么问题随之而来:
第一:上述很明显不是我们通常意义上所理解的面向对象,虽然不排除有现实需求
第二:更严重的是在使用中,会导致不同的作用域链和标识符解析,出现了同名函数实际上不相等的事儿,eg:1
p1.sayName == p2.sayName // false
解决方式就是避免每个实例都创建,所以使用this对象单独绑定函数就行,like this:1
2
3
4
5
6
7
8
9
10// 构造函数创建
function Person(name, age, job) {
this.name= name;
this.age= age;
this.job= job;
this.sayName= sayName;
}
function sayName() {
alert(this.name)
}
这明显是一种妥协的处理方式,为了让各个实例共享单独的函数对象,把函数放到了全局,且不说有很多的函数,造成全局命名是灾难,但是alert
中的this
只表示Person,从语义上就不脱,所以我们需要救助于原型模式
使用原型模式创建和扩展对象
接着上述,如果使用原型模式,我们这样创建对象:1
2
3
4
5
6
7
8
9
10
11function Person() {}
Person.prototype.name = 'tony'
Person.prototype.age = 29
Person.prototype.job = 'Iron man'
Person.prototype.sayName= function() {
alert(this.name)
}
var p1 = new Person()
var p2 = new Person()
p1.sayName == p2.sayName // true
这样所有的实例都会共享属性和方法了,同时注意到构造函数中什么也没有,但它是怎么做到的呢?这就涉及到ECMAScript中原型对象的性质
涉及到js中对象的结构,那就使用console.dir(obj)
查看一下,下图分别是使用最普通的字面量创建
,构造函数
以及原型模式
的对象结构:
可见:
- 任何时候,只要创建一个新对象,那么该对象就会有一个自动的
prototype
属性,这一句__proto__:Object
告诉我们,这是一个指向对象的原型对象
的指针 – 该对象包含了所有实例共享的属性和方法 - 创建的对象本身的属性可以是从原型对象获取,也可以由自己创建,通过上图属性出现在不同的层能明显的看到
- ㊙️特别注意,
原型对象
中才存在constructor
属性,它会指向对象的构造函数。上图可见,它默认会指向Object,但一旦自定义了构造函数,就指向构造函数本身,但不管怎样,构造函数是实例本身没有的,只有实例的原型对象才有
更加直接一些,我们可以通过isPrototypeOf()
方法来确定对象之间是否是同一个原型对象,通过getPrototypeOf()
来回去原型对象
的属性值比如:1
2
3
4
5Person.prototype.isPrototypwOf(p1) // true
var personProtoObj = Object.getPrototypeOf(preson)
console.log(personProtoObj == Person.prototype) // true
personProtoObj.name // 'tony'
我们可以拿到了原型对象及其name
属性值,从实例角度来说,获取一个属性,首先从本身的属性中去查找,如果本身没有,就去原型对象中查找,这就解释了按上述方法,不管有多少实例,最终都会向上追溯到同一个原型对象的相同属性,这就实现了共享
更进一步,如果实例拥有一个相同的属性,那么会怎样呢?使用hasOwnProperty()
会检测属性是否存在于当前实例,如:1
2
3
4
5
6
7
8
9
10
11
12
13var p3 = new Person()
p3.name = 'hulk'
p3.hasOwnProperty('name') // true
p1.hasOwnProperty('name') // false
console.log(p3.name) // 'hulk'
console.log(p1.name) // 'tony'
// 当delete之后
delete p3.name
p3.hasOwnProperty('name') // fasle
console.log(p2.name) // 'tony' -- 重新找到原型中的属性值
同名属性如果自有,就而不会去影响原型的属性,也就是上述没有,才上溯到原型
的原则。此外对于for - in
循环,也只会在实例本身中去循环而不会去管原型中的属性
上边的写法很麻烦,不妨把各个属性用字面量组合写到一起:1
2
3
4
5
6
7
8
9
10
11function Person() {}
Person.prototype = {
name: 'tony',
age: 42,
job: 'Iron man'
sayName: function() {
alert(this.name)
}
}
var stark = new Person()
使用字面量把各个属性写到一起,如果输出stark的结构的话,还是能看到不同之处,如图:
可见比较两者,可看出这种字面量写在一起时,其原型对象并没有contructor
,而是完全重写了一个新的原型对象,这个重写也切断了实例和原本prototype.constructor
之间的连接关系,这是最大的不同之处,解决方式就是显示的添加一个constrictor
并让其指向对象本身,这里就是在重写的原型中添加:1
2
3
4Person.prototype = {
...
constructor: Person
}
好吧,那到底该如何使用构造函数构造对象呢?一句话来说的就是,要共享的原型之,不共享的构造之
,eg:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function Man(name, age, job, wife) {
this.name = name
this.age = age
this.job = job
this.wife = wife
}
Man.prototype = {
constructor: Person,
sex: male,
sayName: function() {
alert(this.name)
}
eat: function() {
alert(this.name + 'is eatring!')
}
}
var tony = Man('tony', 42, 'Iron man', 'xxx')
var hulk = Man('hulk', 32, 'Hulk and scientist', 'yyy')
如上所示,tony和hulk的名字,工作,老婆是不能共享的,那就放到构造函数中,而都作为男性,性别是一样的,都能报出自己的名字,都能吃饭,所以这些共享的放到原型对象中
综上所属,可看出通过原型对象,js实现了各实例属性的共享
,这就是原型对象的秘密
js对象的继承
通过上述,所有的对象都有原型对象的指针,并且原型对象本身也有一个原型对象,都是来自于Objcet这个基本对象,大致能猜到继承的影子了,对于Java等OO语言,继承可以有2种:接口继承和实现继承,接口只继承方法签名儿实现继承则继承实际方法,在js中是没有接口集成的,只有实现继承,如前边的揣测,使用过原型对象组成的链条来实现的
- 基本的原型链式继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22var SuperType = function() {
this.property = "super"
}
SuperType.prototype.getSuperValue = function() {
return this.property
}
var SubType = function() {
this.subProperty = "sub"
}
/* 后代对象的原型是父对象的实例 */
SubType.prototype = new SuperType()
SubType.prototype.getSubproperty = function() {
return this.subProperty
}
var father = new SuperType()
var son = new SubType()
console.log(instance.getSuperValue()) // 'super'
console.log(father)
console.dir(son)
最关键的一句就是SubType.prototype = new SuperType()
,也就是重写子对象的原型对象让后代对象的原型对象 = 父对象的实例
,由此实例的属性和实例能找的它原型的属性,就都可以让子对象顺着它的原型对象而收缩到,从效果上就实现继承,如上述代码他们的继承图就是:
很显然,这样单纯的让原型对象==父对象的实例以后,回引出一下几个问题:
1 如果需要为向子对象的原型对象中添加新方法,是不能写成字面量的形式的。因为这样一来就等同于重现了实例对象,会覆盖掉最初的原型对象,自然链接父对象的原型链会被切断,而失去继承的效果,这一点是尤其需要注意的
2 基于同样的道理当子对象为他的原型添加属性,这个属性会被共享到所有的子对象实例,所以不应该把子对象的构造函数留空
3 此外还有一个问题是,再也无法单独为超对象的构造函数传递参数,因为这势必会影响到继承自他的子对象
所以单纯使用原型链只能算是粗暴的继承
- 伪经典继承(也称组合继承 combination inheritance)
这的将构造函数 + 原型链组合的一种方式,其思路是:原型链实现对原型对象的继承,而实例属性的继承通过构造函数实现,如此便是:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30function SuperType(name) {
this.name = name
this.colors = ['red', 'green', 'bule`]
}
SuperType.prototype.sayName = function() {
alert(this.name)
}
var SubType = function(name, age) {
// 继承父对象的实例属性
Supertype.call(this, arguments)
this.age = age
}
// 继承父对象的原型属性
SubType.prototype = new SuperType()
// 可以选择纠正一下构造函数
<!--SubType.prototype.constructor = SubType-->
SubType.prototype.sayAge = function() {
alert(this.age)
}
var son1 = new SubType('son1', 22)
son1.colors.push('yellow')
son1.sayName() // son1
son1.sayAge() // 22
var sonOther = new SubType('other', 44)
console.log(sonOther.colors) // ['red', 'green', 'bule`]
sonOther.sayName() // other
sonOther.sayAge() // 44
看图更加清晰一些:
由于父对象的实例属性,在子对象的构造函数中完成继承,所以等同于子实例属性的创建和覆盖,同时只属于子实例实例如age不在被共享,而共享的只是来自于父实例的属性,这解释了son1.colors 和 sonOther.colors
的不同;来自于父类原型的属性及方法以及被继承。可见没有了上面只使用原型链的问题,但这样就完美了吗?
上述这就用js实现了java类一样的继承了,但在js中绝大多数时候,是不需要这样完全模拟类的继承的,并且并不完美:至少SuperType就被调用了2次,一次在构造函数中,一次在链接原型链时,所以还需要改进
这就是js更常见的继承了,它并不模拟类的实现,虽然看起来不够模版化,数据耦合性很高,但却是见的比较多的,使用extendObj简单的封装了一下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25var extendObj = function(superObj) {
var obj = new Object(superObj.prototype)
obj.constructor = obj
return obj
}
var fa = {
name: '老王',
surname: '王',
familyName: function() {
console.log(this.surname)
}
}
console.log(fa.familyName())
console.dir(fa)
// 不使用构造函数,不模拟类
var sequela1 = extendObj(fa)
sequela1.name = '小王'
sequela1.course = '数学'
sequela1.projectWork = function() {
console.log(this.course)
}
console.dir(sequela1)
console.log(sequela1.projectWork())
综上,就是js的继承,可见js即便他现在使用很广泛,也还有很多脚本化的身影,继承写起来并不入java等的容易,但也的确实现了继承,何况能理解到另一种形式的OOP,也很助于理解其他OOP语言
附上一个测试的demo代码: