面向对象编程
JavaScript的面向对象,是一种基于原型(prototype based)的面向对象。这对于很多从传统面向对象语言(C++,Java等)语言转入JavaScript的学习者而言,一开始是难以接受的(笔者自己就是),但实际上,要想能写出好的JavaScript程序,对原型链(prototype chain)的理解是必不可少的。
本文并不打算详细介绍面向对象编程(Object-Oriented Programming)的基本概念(封装,继承,多态),而是通过对比传统的OOP,来引入JavaScript中的OOP。
1 | // java code |
1 | // java code |
上面两段Java代码,代表了一般面向对象语言中类的定义方式和特性,我们通过class关键字声明一个类,描述它的属性,构造函数,父类,行为方法等。然后我们再在需要的地方定义一个相关类的变量并实例化,再进行具体的操作。
然而,在JavaScript中却有很大的出入,先入为主代入上面的观念将会对学习JavaScript的面向对象带来极大地误导。
JavaScript是一种基于原型(Prototype)的编程语言。基于原型的编程语言使用的是一种使用函数(function)作为类(class)的构造器(constructor)的面向对象编程语言。这句话也许听上去很绕口,没关系,因为想要理解JavaScript的面向对象,必须要先学习JavaScript中的原型。
原型
在JavaScript中,几乎所有对象都有一个原型对象(也有例外,如Object.prototype),当我们每定义一个对象,这个对象就有一个原型,指向他的父对象(Object.prototype指向null),我们可以通过对象的proto属性访问到自身的原型对象。
来看如下示例代码。
1 | let o = {}; |
事实上在JavaScript中,当访问一个对象的属性时,对象会先从自身的属性中查找,如果没有就往自身的原型对象中查找,再没在再上一级,直到找到该属性或者找到最顶层才停止。
1 | let o1 = {foo: 'foo'}; |
上面代码中首先需要解释的是Object.create()函数,该函数会以自己的参数对象为原型,创建一个新的对象并返回。也就是说我们的第二行代码创建了一个新的对象并赋值给了o2,且o2的原型对象是o1。实时上我们后面的输出结果也验证了这一点。
1 | // o2 -> o1 -> Object.prototype -> null |
o2的原型是o1,更进一步地,o1的原型是Object.prototype。此外对于函数,数组,字符串,数字等和其他特殊对象的原型对象,留待读者自己去进一步探索。
以上就是最基本的原型知识,即在JavaScript中,每一个对象都有一个自己的原型(Object.prototype除外)。
构造器
了解了原型之后,我们来涉及一点我们熟悉的面向对象的知识。在JavaScript中,一种定义对象的方式就是通过调用构造器方法。构造器就是一个普通的函数,不过当调用它时在前面加上new关键字,它就成了一个构造器函数。
1 | let Person = function (firstName) { |
在上述代码中,先定义了一个构造器Person,该构造器接收一个参数,作为Person的名字。然后我们通过new关键字,调用了构造器,实例化了两个Person,分别传入了各自的参数。注意当我们使用new关键字时,构造器中的this指向新创建的对象。因此在得到的返回结果中,我们可以访问对象各自的属性。
但目前『Person类』只是有一个自己的『成员属性』,还没有方法,这个时候我们就要用到我们刚刚提到的原型了。上文提到过,当一个对象在不存在自有属性时,它就会向上一级询问。我们来看一下person1和person2的原型指向的是什么。
1 | console.log(person1.__proto__); // Object {constructor: function(firstName) ...} |
从第一行可以看出person1的原型对象也是一个对象,从第二行可以看出person1和person2的两个对象的原型是同一个对象,从第三行可以看出这两个对象的原型就是Person.prototype。
这是JavaScript中的规定,当通过new关键字调用构造器(即这里的Person)创建出对象时,所有对象的原型都指向构造器.prototype(即这里的Person.prototype)。
我们完全可以把类的方法定义在Person.prototype上。
1 | Person.prototype.sayHello = function() { |
有思考过的读者可能会想:可是这又与我们的定义方法有什么关系呢?既然所有的对象访问的都是同一个原型,那岂不是方法也是共享的?就像一般的面向对象语言中的静态方法一样?
确实所有对象都访问的是同一个原型对象,但是JavaScript中还有另一个bug,就是this指针。
在JavaScript中,this指针指向当前的操作对象。
就这么简单地把问题都解决了,当我们访问Person.prototype中的方法时,this指针指向我们当前的对象。我们来看实验结果。
1 | person1.sayHello(); // Hello, I'm Alice |
继承
继承(Inheritance)是OOP的另一个重要性质。在JavaScript中,继承同样是基于原型来实现的。
这里直接来看MDN官网的示例。
1 | // Person构造器 |
上面代码很清晰,我们来逐行解释。
首先我们定义了一个Person的构造器,该构造器接收一个firstName的参数,且其原型拥有两个方法,分别是walk()和sayHello()。
现在我们需要一个Student类的构造器,它要继承自Person,很明显,继承需要将属性和方法都继承过来。
先来看属性的继承。创建当前的Student对象时,this指针即指向的新创建的对象,因此我们可以用call方法,将this指针传递给Person构造器,让Person构造器为我们当前创建的新的Student对象完成Person属性的初始化,这个过程相当于其他语言中的super方法。属性继承完成之后,我们再开始初始化自由属性。
再来看方法的继承。Person的方法都存在于Person.prototype当中,自然Student的方法也需要一套一模一样的方法过来。Student的方法肯定也是存在于Student.prototype之中的,Student需要继承Person,所以很明显,Student.prototype需要继承Person.prototype。
我们调用Object.create()方法,将创建一个新的继承自Person.prototype的对象,并赋值给Student.prototype。但这样做却把Student.prototype.constructor给覆盖掉了(一个构造器的prototype的constructor对象总是指向自身),很简单,我们再将其赋值回去就行了。
我们还可以重写sayHello()方法,从本文一开始的原型的知识我们可以知道,当Student.prototype中有sayHello属性时,Student类型的实例就不会访问到Person.prototype中的sayHello属性了。因此重写只需要在Student.prototype上添加sayHello方法就可以了。
当然我们还可以为Student.prototype添加新的其他方法。
实际上,如果读完本小节,我们脑中有一个完整的原型链模型,就说明我们已经彻底理解了。
1 | // person -> Person.prototype -> Object.prototype -> null |
封装和多态
在上一节中,Student不需要知道Person的walk方法是怎么实现的,但是Student类型的对象依然能调用walk方法,除非我们需要重写它,否则Student不需要显式地定义walk方法。这就是封装。(在其他语言中将属性经常设置为private或者protected,虽然在JavaScript中可以模拟实现,但是这并不是OOP的必要条件)
使用闭包实现私有属性
闭包能限定作用域,因此借用闭包来实现私有属性的效果。
1 | function Point(x, y) { |
使用Symbol实现私有属性
还可以借用之前在学习过的ES6的Symbol实现对象的私有属性。由于WeakMap对键只持有弱引用,且引用必须为对象,键值无法遍历,因此如果把当前对象当成键,把属性放入一个对象中作为值,存在WeakMap中,则这些属性只能通过当前对象本身去访问。
1 | let map = new WeakMap(); |
不同的类可以定义同名的属性和方法,方法的作用范围从属于某个具体定义它们的类,实际上,两个相同类型的对象也可以有不同方法的实现,JavaScript这种特指本身就是多态的体现。
总结
在刚结束的ES6系列总结中,提到class关键字只是个语法糖(实际上还是基于原型实现),虽然笔者强烈推荐使用ES6的class关键字,但是对于JavaScript的进阶者而言,底层的实现也是必不可少的。
本文可能有些地方讲述的不是很清楚,也许会给读者带来困惑,笔者强烈推荐下面的两篇参考,关于JavaScript的基于原型的面向对象,里面有更详尽的表述。
不过在学习JavaScript基于原型的面向对象之前,还是希望读者对原型链,this指针,Function.prototype.call()方法等基本知识有足够的理解,没有这些基础确实是无法理解JavaScript基于原型的OOP的。
参考
- Introduction to Object-Oriented JavaScript - MDN
- JavaScript权威指南(原书第6版) - Amazon
- [Private Properties - MDN][3]