JavaScript基于原型的面向对象编程

面向对象编程

JavaScript的面向对象,是一种基于原型(prototype based)的面向对象。这对于很多从传统面向对象语言(C++,Java等)语言转入JavaScript的学习者而言,一开始是难以接受的(笔者自己就是),但实际上,要想能写出好的JavaScript程序,对原型链(prototype chain)的理解是必不可少的。

本文并不打算详细介绍面向对象编程(Object-Oriented Programming)的基本概念(封装,继承,多态),而是通过对比传统的OOP,来引入JavaScript中的OOP。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// java code
class Worker extends Person{
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
public work() {
System.out.println("I'm working");
}
// setter ......
// getter ......
}
1
2
3
4
5
6
7
8
// java code
...
Person person = new Person("Charles", 25);
person.sayHello(); // this method is from Person class.
// hello world
person.work();
// I'm working
...

上面两段Java代码,代表了一般面向对象语言中类的定义方式和特性,我们通过class关键字声明一个类,描述它的属性,构造函数,父类,行为方法等。然后我们再在需要的地方定义一个相关类的变量并实例化,再进行具体的操作。

然而,在JavaScript中却有很大的出入,先入为主代入上面的观念将会对学习JavaScript的面向对象带来极大地误导。

JavaScript是一种基于原型(Prototype)的编程语言。基于原型的编程语言使用的是一种使用函数(function)作为类(class)的构造器(constructor)的面向对象编程语言。这句话也许听上去很绕口,没关系,因为想要理解JavaScript的面向对象,必须要先学习JavaScript中的原型。

原型

在JavaScript中,几乎所有对象都有一个原型对象(也有例外,如Object.prototype),当我们每定义一个对象,这个对象就有一个原型,指向他的父对象(Object.prototype指向null),我们可以通过对象的proto属性访问到自身的原型对象。

来看如下示例代码。

1
2
let o = {};
console.log(o.__proto__); // Object.prototype

事实上在JavaScript中,当访问一个对象的属性时,对象会先从自身的属性中查找,如果没有就往自身的原型对象中查找,再没在再上一级,直到找到该属性或者找到最顶层才停止。

1
2
3
4
5
6
let o1 = {foo: 'foo'};
let o2 = Object.create(o1);
o2.bar = 'bar';
console.log(o2.bar); // bar
console.log(o2.foo); // foo
console.log(o2.__proto__); // Object {foo: "foo"}

上面代码中首先需要解释的是Object.create()函数,该函数会以自己的参数对象为原型,创建一个新的对象并返回。也就是说我们的第二行代码创建了一个新的对象并赋值给了o2,且o2的原型对象是o1。实时上我们后面的输出结果也验证了这一点。

1
// o2 -> o1 -> Object.prototype -> null

o2的原型是o1,更进一步地,o1的原型是Object.prototype。此外对于函数,数组,字符串,数字等和其他特殊对象的原型对象,留待读者自己去进一步探索。

以上就是最基本的原型知识,即在JavaScript中,每一个对象都有一个自己的原型(Object.prototype除外)。

构造器

了解了原型之后,我们来涉及一点我们熟悉的面向对象的知识。在JavaScript中,一种定义对象的方式就是通过调用构造器方法。构造器就是一个普通的函数,不过当调用它时在前面加上new关键字,它就成了一个构造器函数。

1
2
3
4
5
6
7
8
let Person = function (firstName) {
this.firstName = firstName;
console.log('Person instantiated');
};
let person1 = new Person('Alice'); // Person instantiated
let person2 = new Person('Bob'); // Person instantiated
console.log('person1 is ' + person1.firstName); // person1 is Alice
console.log('person2 is ' + person2.firstName); // person2 is Bob

在上述代码中,先定义了一个构造器Person,该构造器接收一个参数,作为Person的名字。然后我们通过new关键字,调用了构造器,实例化了两个Person,分别传入了各自的参数。注意当我们使用new关键字时,构造器中的this指向新创建的对象。因此在得到的返回结果中,我们可以访问对象各自的属性。

但目前『Person类』只是有一个自己的『成员属性』,还没有方法,这个时候我们就要用到我们刚刚提到的原型了。上文提到过,当一个对象在不存在自有属性时,它就会向上一级询问。我们来看一下person1和person2的原型指向的是什么。

1
2
3
console.log(person1.__proto__); // Object {constructor: function(firstName) ...}
console.log(person1.__proto__ === person2.__proto__); // true
console.log(person1.__proto__ === Person.prototype); // true

从第一行可以看出person1的原型对象也是一个对象,从第二行可以看出person1和person2的两个对象的原型是同一个对象,从第三行可以看出这两个对象的原型就是Person.prototype。

这是JavaScript中的规定,当通过new关键字调用构造器(即这里的Person)创建出对象时,所有对象的原型都指向构造器.prototype(即这里的Person.prototype)。

我们完全可以把类的方法定义在Person.prototype上。

1
2
3
Person.prototype.sayHello = function() {
console.log(`Hello, I\'m ${this.firstName}`);
};

有思考过的读者可能会想:可是这又与我们的定义方法有什么关系呢?既然所有的对象访问的都是同一个原型,那岂不是方法也是共享的?就像一般的面向对象语言中的静态方法一样?

确实所有对象都访问的是同一个原型对象,但是JavaScript中还有另一个bug,就是this指针。

在JavaScript中,this指针指向当前的操作对象。

就这么简单地把问题都解决了,当我们访问Person.prototype中的方法时,this指针指向我们当前的对象。我们来看实验结果。

1
2
person1.sayHello(); // Hello, I'm Alice
person2.sayHello(); // Hello, I'm Bob

继承

继承(Inheritance)是OOP的另一个重要性质。在JavaScript中,继承同样是基于原型来实现的。

这里直接来看MDN官网的示例。

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
31
32
33
34
35
36
37
38
39
40
// Person构造器
var Person = function(firstName) {
this.firstName = firstName;
};
// 为Person.prototype添加一些方法
Person.prototype.walk = function(){
console.log('I am walking!');
};
Person.prototype.sayHello = function(){
console.log(`Hello, I'm ${this.firstName}`);
};
// 定义Student构造器
function Student(firstName, subject) {
// 调用父构造器,保证父构造器执行时this指针当前构造器中的当前对象。
Person.call(this, firstName);
// Student特有属性
this.subject = subject;
}
// 创建Student.prototype对象,该对象应该继承自Person.prototype
// 这里一个常见的错误就是将其继承自new Person(),请注意应该继承自Person.prototype。
Student.prototype = Object.create(Person.prototype);
// 设置Student.prototype.constructor指向为Student。
// 这里原本如此,但是上一步把这个给覆盖掉了,这里再修改回去
Student.prototype.constructor = Student;
// 『重写』sayHello方法。
Student.prototype.sayHello = function(){
console.log(`Hello, I'm ${this.firstName}. I'm studying ${this.subject}.`);
};
// 为Student类添加sayGoodBye方法。
Student.prototype.sayGoodBye = function(){
console.log('Goodbye!');
};
// 示例用法。
var student1 = new Student('Janet', 'Applied Physics');
student1.sayHello(); // Hello, I'm Janet. I'm studying Applied Physics.
student1.walk(); // I am walking!
student1.sayGoodBye(); // Goodbye!
// 检查一下实例类型是否正确。
console.log(student1 instanceof Person); // true
console.log(student1 instanceof Student); // true

上面代码很清晰,我们来逐行解释。

首先我们定义了一个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
2
// person -> Person.prototype -> Object.prototype -> null
// student -> Student.prototype -> Person.prototype -> Object.prototype -> null

封装和多态

在上一节中,Student不需要知道Person的walk方法是怎么实现的,但是Student类型的对象依然能调用walk方法,除非我们需要重写它,否则Student不需要显式地定义walk方法。这就是封装。(在其他语言中将属性经常设置为private或者protected,虽然在JavaScript中可以模拟实现,但是这并不是OOP的必要条件)

使用闭包实现私有属性

闭包能限定作用域,因此借用闭包来实现私有属性的效果。

1
2
3
4
5
6
7
8
function Point(x, y) {
let _x = x, _y = y;
this.setX = x => _x = x;
this.setY = y => _y = y;
this.getX = () => _x;
this.getY = () => _y;
}
let p = new Point(1, 2);

使用Symbol实现私有属性

还可以借用之前在学习过的ES6的Symbol实现对象的私有属性。由于WeakMap对键只持有弱引用,且引用必须为对象,键值无法遍历,因此如果把当前对象当成键,把属性放入一个对象中作为值,存在WeakMap中,则这些属性只能通过当前对象本身去访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let map = new WeakMap();
function interval(obj) {
if(!map.has(obj)) {
map.set(obj, {});
}
return map.get(obj);
};
function Point(x, y) {
interval(this).x = x;
interval(this).y = y;
}
Point.prototype.setX = function(x) { interval(this).x = x };
Point.prototype.setY = function(y) { interval(this).y = y };
Point.prototype.getX = function() { return interval(this).x };
Point.prototype.getY = function() { return interval(this).y };
let p = new Point(1, 2);

不同的类可以定义同名的属性和方法,方法的作用范围从属于某个具体定义它们的类,实际上,两个相同类型的对象也可以有不同方法的实现,JavaScript这种特指本身就是多态的体现。

总结

在刚结束的ES6系列总结中,提到class关键字只是个语法糖(实际上还是基于原型实现),虽然笔者强烈推荐使用ES6的class关键字,但是对于JavaScript的进阶者而言,底层的实现也是必不可少的。

本文可能有些地方讲述的不是很清楚,也许会给读者带来困惑,笔者强烈推荐下面的两篇参考,关于JavaScript的基于原型的面向对象,里面有更详尽的表述。

不过在学习JavaScript基于原型的面向对象之前,还是希望读者对原型链,this指针,Function.prototype.call()方法等基本知识有足够的理解,没有这些基础确实是无法理解JavaScript基于原型的OOP的。

参考

  1. Introduction to Object-Oriented JavaScript - MDN
  2. JavaScript权威指南(原书第6版) - Amazon
  3. [Private Properties - MDN][3]