YongSir

专业程序员伪装者

js拾遗之面向对象

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
8
var 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
3
var 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
11
function 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
5
Person.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
13
var 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
11
function 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
4
Person.prototype = {
...
constructor: Person
}

好吧,那到底该如何使用构造函数构造对象呢?一句话来说的就是,要共享的原型之,不共享的构造之,eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function 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
    22
    var 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
    30
    function 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
25
var 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代码: