深入理解JavaScript面向对象编程(附源码解析)

发表时间: 2019-01-17 10:37

什么是面向对象

编程的世界中,有一种思想是非常重要的,那就是——面向对象思想。掌握了这种思想,意味着你不再是一个编程菜鸟,已经开始朝着开发者的目标迈进。

那么,到底什么是面向对象思想?说到这个可以给大家说说这个东西的由来。以前的编程,都是面向过程的。那么什么又是面向过程呢?以前在网上看到一个说法,觉得形容得很好,借用一下。

大体如下:如果现在有个人叫你把大象放到冰箱里,我们使用面向过程如何做呢?我们可以分成以下几个步骤:

1. 把冰箱门打开。

2. 把大象放到冰箱里面。

3. 把冰箱门关上。

我们程序员就会把这三个步骤写成三个函数:

1. openTheFridgeDoor()

2. putElephantIntoFridge()

3. closeTheFridgeDoor()

然后我们依次调用这几个函数就行了。好了你以为这就可以下班了,但是过两天你会发现,这个需求会衍变成很多个奇怪的走向,例如:

1. 请把狮子也放进冰箱。

2. 请把大象放进微波炉。

3. 请把猴子放进微波炉。

4. 把其他的动物也放进冰箱,但是门就别关了。

5. ……

诸如此类。此时要使用面向过程的方式实现这些需求,就要重新定义实现这些需求的函数。这无疑是让人抓狂的一件事。但是老板和客户给钱了,你要做啊!于是你就要加班了,于是你就牺牲在了工作岗位上了……

所以为你的生命着想,你得想出一个不需要每次来需求都要重新定义实现的函数的办法,那就是——面向对象。

我们的想法是:如果每次要变更需求,我们都不用自己去做这些过程,而是而是指挥别人去做,那不就万事大吉了吗?所以我们的面向对象的思想,第一个转变就是做一个执行者,变成一个指挥者。

如果使用面向的思想完成把大象放进冰箱的需求。我们的做法变成这样:

1. 找到冰箱,命令冰箱自己打开冰箱的门。

2. 找到大象,命令大象自己进入到冰箱里面。

3. 再次命令冰箱自己把门关上。

所以实现这个需求需要的实体有: 大象、冰箱。我们就把实现需求中出现的实体称为对象。大象要会自己进入到冰箱里,冰箱要会自己开门和关门。进入冰箱、开门和关门我们称为对象的能力,在编程中通常用方法表示。

所以做个总结:

1. 面向过程就是关注实现需求的第个步骤,任何的工作都需要自己去做。

2. 面向对象就是什么事都交给能做这件事的对象去做。

那么现在问题来了,如果需求变成了上文说的那些,面向对象要如何解决问题?现在我们要做的就是:分析需求中出现的对象(实体),然后分别赋予这对象相应的能力。

在新的需求中,要把大象、狮子、猴子这些[动物] 放进 冰箱、微波炉这些 [容器]中。此时这里面出现的对象(实体)就有:动物、容器。动物要有的方法(能力)是:进入容器,容器要有的方法(能力)是:开门和关门。

所以上述的需求都可以变成:

1. [容器]开门。

2. [动物]进入[容器]。

3. [容器]关门(或者有的需求要不关门的,这个步骤就可以不要了)

所以这样一来,我们就不用重复地定义函数来实现这些需求了。甚至将来需求又变成要把动物从容器中拿出来,我们也只要在动物对象上拓展动物从容器中出来的方法,就可以快速完成需求了。这个时候你牺牲在工作岗位上的机率就小很多了。

如何实现面向对象

说了那么多,大家也能大概理解什么是面向对象了。那么我们在js里面要怎么写代码才能实现面向对象?

在JavaScript中,我们用构造函数来创建对象。

function Elephant(){

}

大象这种对象会有一些特有的数据,如大象的体重、品种、年龄等等。我们称这些特有的数据为:属性。每头大象的这些数据都不一样,这种差异在代码中如何体现呢?

function Elephant(age,weight,type){

}

我们把每个数据都以形参的形式传入构造函数,然后在创建的时候再决定每头大象的实际数据。最终构造函数写成:

function Elephant(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

}

现在如果要一头大象,我们只要使用new的方式创建对象就行。

// 这是一头2岁的非洲象,体重是200kg

var ele1 = new Elephant(2,'200kg','非洲象');

// 这是一头3岁的美洲象,体重是250kg

var ele2 = new Elephant(3,'250kg','美洲象');

现在大象有,我们要教会大象进入容器的能力,这就是方法。初级的写法是把这些方法写到构造函数里面。

function Elephant(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

this.enterContainer = function(){}

}

大象这类对象已经构建完毕了,接下来要做的是把冰箱也变成对象。我们也给冰箱写一个构造函数。

function Fridge(){

}

同样冰箱这种对象也是有它独有的属性(数据)的,比如冰箱的高度、宽度等等。我们也把这些属性写到构造函数里面。

function Fridge(width,height){

this.width = width;

this.height = height;

}

而现在冰箱按照需求,要有一个开门和一个关门的方法,我们也写在构造函数上。

function Fridge(width,height){

this.width = width;

this.height = height;

this.openDoor = function(){};

this.closeDoor = function(){};

}

此时我们要完成“把大象放进冰箱”这个需求就需要大概如下代码

// 1 找到一个冰箱对象,冰箱的宽高足够放进大象

var fridge = new Fridge('4m','4m');

// 2 给冰箱发布开门的指令

fridge.openDoor();

// 3 找到一个大象对象

var elephant = new Elephant(2,'200kg','非洲象');

// 4 给大象发布进入冰箱的指令

elephant.enterContainer();

// 5 给冰箱发布关门指令

fridge.closeDoor();

但是这个时候我们要现实把狮子放进冰箱里面这个需求的时候,我们又要写一段描述狮子的属性和方法的代码。并且这段代码和描述大象的代码几乎一模一样。

function Lion(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

this.enterContainer = function(){}

}

这个时候我们分析一个,不管是大象还是狮子和猴子,都有一样的属性(年龄、体重、各类)和方法(进入容器),这些是我们在需求里面的动物都有的,干脆我们直接写一段描述动物的代码算了。

function Animal(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

this.enterContainer = function(){}

}

当我们要把大象放进冰箱:

var ele = new Animal(2,'250kg','非洲象');

ele.enterContainer();

当我们要把狮子放进冰箱:

var lion = new Animal(2,'250kg','美洲狮');

lion.enterContainer();

此时就不需要重复地写代码来实现类似的需求了。但是这个时候有同学要说了,动物里面猴子会爬树,大象不会啊。如果是要做的需求是要猴子爬树,我们难道直接给Animal构造函数加个爬树的方法吗?这明显就来合理了啊!

当然不是!在解决这个同学的问题这前,我们先做个总结。刚才为国解决把动物放进冰箱的问题,我们把面向过程的做法变成了面向对象的做法。而以上代码我们只是用到了面向对象思想中的第一个特性:封装性。所谓的封装性,即为把对象(需求中的实体)的属性(特点)和方法(能力)都抽象出来,形成一个一个的分类,而在js中,在es6之前没有类的概念,所以我们把每个分类都使用构造函数表示。抽象出来的对象,只要你想要使用的时候,只要把构造函数使用new操作,新创建一份即可。

继承

接下来我们解决猴子要爬树的问题。当然,要解决这个问题,我们要用到面向对象思想的另一个特性:继承性。继承性是指子类可以享有父类的属性和方法(从这里开始,不再解释属性这种类似的基本概念了)。那么什么是子类和父类呢?上文为了解决把动物放进冰箱中的问题,我们定义了一个动物的构造函数,我们把这个理解为父类,后面提出的问题:不是所有的动物都有一样的方法。比猴子会爬树,而大象不会,这个时候我们需要重新定义猴子这个构造函数了,我们把这个猴子理解为子类。

function Monkey(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

this.climbingTree = function(){};

this.enterContainer = function () {}

}

猴子和大象一样有年龄、体重、各类这几个同样的属性,和大象一样会进入容器的方法,与此同时,猴子自己会一个爬树的方法。此时我们发现,在这个新的构造函数中,只有爬树这个代码是新的,其他的代码我们都写过一次了。如果每个动物的构造函数都这么写的话,会有很多重复的代码。此时我们就要用到继承的思想。

原型和继承

使用原型实现方法继承

在js中,使用原型来实现继承。

首先来说一个什么是原型。原型是js中实现继承的必要存在,是每个构造函数的一个属性,同时也是一个对象,他的作用就是在原型上的属性和方法可以构造函数的实例对象所分享。

我们先看看原型对象的原型。在js中,任何的构造函数都有一个属性: prototype。我们先在控制台中输出一个构造函数:

console.dir(Array);

此时在控制台中我们可以看到,Array构造函数是有个prototype属性的。这个属性就是我们所说的原型。

展开这个原型属性,我们发现平时使用的数组的方法都是从这个原型上来的。也就是说原型的方法是可以被实例对象所共享的。

那么接下来我们就用原型来解决猴子的代码重复太多的问题。我们发现,在Animal构造函数和Monkey构造函数中,都而要一个进入容器的函数enterContainer,为了去除这部分重复的代码,我们中在Animal这个相当父类的构造函数中声明,而把Monkey的原型指向Animal的实例即可。

function Animal(age, weight, type) {

this.age = age;

this.weight = weight;

this.type = type;

this.enterContainer = function () {

console.log('进入了容器');

}

}

function Monkey(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

this.climbingTree = function(){}

}

Monkey.prototype = new Animal();

此时我们new一个Monkey的实例,发现这个实例是可以调用进入容器方法的。

var monkey = new Monkey(2,'25kg','金丝猴');

monkey.enterContainer();

此时进入容器的方法enterContainer已经可以共享了。但是这种写法有个缺点,我们写的方法都是写在构造函数里面的,这会在我们每次new对象的时候都会在内存中声明一个函数,而这个函数的代码每次都是一样的。这就很没有必要。

var m1 = new Monkey(1,'15kg','长臂猴');

var m2 = new Monkey(2,'25kg','大马猴');

console.log(m1,m2);

我们仿照原生js的方式,把方法写到原型上解决这个问题

function Animal(age, weight, type) {

this.age = age;

this.weight = weight;

this.type = type;

}

Animal.prototype.enterContainer = function () {

console.log('进入了容器');

}

function Monkey(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

}

Monkey.prototype = new Animal();

Monkey.prototype.climbingTree = function(){

console.log('小猴子正在爬树');

}

首先从内存上来分析,在对象上已经没有对象的方法了

再从控制台中观察

var m1 = new Monkey(1,'15kg','长臂猴');

var m2 = new Monkey(2,'25kg','大马猴');

console.log(m1,m2);

m1.climbingTree();

m2.climbingTree();

这是因为我们把方法写在了原型上,而原型上的方法是可以实例所共享的。m1和m2这两个对象都是Monkey的实例,是可以调用爬树的方法的。

借用构造函数实现属性继承

那么到目前为止,我们已经解决了一部分代码的重用问题。我们发现还有一部分代码还是重复的,这部分代码是对象的属性。

在js中,我们可以使用借用构造函数实现属性的继承。

什么是借用呢?这其实是所有函数都可以调用的一个方法:call方法。其他作用是可以修改函数执行时的this指向。举个简单的例子:

function fn(){

console.log(this);

}

fn();

这个代码在正常情况下的结果是输出window对象。

但是如果我们使用了借用这个方法:

function fn(){

console.log(this);

}

fn.call({name:'小明'});

在控制台中输出的是:我们在使用call方法的第一个参数。利用这个特点,我们可以把构造函数借用下。

具体代码如下

function Animal(age, weight, type) {

this.age = age;

this.weight = weight;

this.type = type;

}

function Monkey(age,weight,type){

Animal.call(this,age,weight,type);

}

此时Monkey里的重复的属性代码就没有了。那么我们试试Monkey的实例是否会有这些属性。

var m1 = new Monkey(1,'15kg','长臂猴');

var m2 = new Monkey(2,'25kg','大马猴');

console.log(m1,m2);

所以最终我们的代码写在了这个样子

function Animal(age, weight, type) {

this.age = age;

this.weight = weight;

this.type = type;

}

Animal.prototype.enterContainer = function () {

console.log('进入了容器');

}

function Monkey(age,weight,type){

Animal.call(this,age,weight,type);

}

Monkey.prototype = new Animal();

Monkey.prototype.climbingTree = function(){

console.log('小猴子正在爬树');

}

此时如果是所有动物的方法,我们只要回到Animal.prototype上,如果是猴子自己独有的方法,就写到Mokey.prototype上。这就是在js中要实现面向的过程。以上的两个方式实现继承的方式我们称为:组合继承。

更简易的语法实现面向对象

上述的写法是我们在es5的标准下实现面向对象的过程。这个过程稍稍有点麻烦。在es6的新标准下,我们有更简易的语法实现面向对象。

Class关键字

首先了解下es6里面的一个新的关键字:class,这个关键字可以快速地让我们实现类的定义。语法如下:

class 类名{

}

然后在里面写该类的构造函数

class 类名{

constructor(){

}

}

比如我们定义一个动物类

class Animal{

constructor(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

}

}

当我而要一个动物实例的时候,也只要new即可。

var a1 = new Animal(2,'200kg','非洲象');

console.log(a1);

这个语法的本质也是使用函数和原型实现的,所以我要实现之前的动物和冰箱的问题,只要以这种新语法的方式实现就会更快了。

Extends关键字

在新语法的情况下,如果要实现继承,我们也只要使用一个新的关键字“extends”即可。语法如下:

class 子类 extends 父类{

constructor(){}

}

但是要流量的是,在这个新语法下实现的继承,要在子类的构造函数里面先调用父类的构造函数。

class 子类 extends 父类{

constructor(){

super();

}

}

所以现在要实现Mokey对Animal的继承,我们要这么写

class Animal{

constructor(age,weight,type){

this.age = age;

this.weight = weight;

this.type = type;

}

enterContainer(){

console.log('进入了容器');

}

}

class Mokey extends Animal{

constructor(age,weight,type){

super(age,weight,type);

}

climbingTree(){

console.log('小猴子正在爬树');

}

}

结语

面向对象这种思想其实并不难,基本所有的高级语言都遵循了这种思想。即为我们在使用语言的时候大部分都是调用语言的API,这些API基本都是用在调用对象的方法或属性。而要调用对象的方法或者属性,必然要先找到对象,其实找到对象,然后调用对象的方法和属性的行为就是我们在使用面向对象的思想解决问题。

所以我对面向对象的问题就是:所谓的面向对象,就是找到对象,然后使用对象的方法和属性。

而不同的语言实现的过程不一样,在js里面还有es5和es6两种不同的实现方式,这既是js的好的地方,也是js不好的地方。好处在于不断的有新的语法标准出来,证明这个语言还在蓬勃发展,不好的是这样对初学者不友好。但是值得肯定的是,js在未来的势头还非常好的。