学习JavaScript语言的过程中“面向对象”的概念绝对是一道坎。JS没有“类”这个概念,因此要用它来进行面向对象编程就需要下一番功夫。一方面,我们需要把握JavaScript语言自身的风格和特点;另一方面,我们需要重新理解面向对象的三大要素。把“语言特性”和“三大要素”牢牢的记在心中,然后再去理解JavaScript面向对象编程就要容易得多。这篇文章将对JS面向对象编程的核心机制进行梳理和总结。归纳了几种常见的定义类的方式以及如何建立类之间的继承关系。
一. 两个关键概念
“万物皆对象”是JS的一个很重要的特点,在JavaScript中,你定义的数组,键值对,整数,字符串和浮点数等其实都是对象,连函数都是一个对象,所不同的只是它可以被调用而已。这一点跟Python就很像,这就是为什么你可以在JS数组中放入各种不同类型的元素的原因,这在强类型语言中是不可能做到的。请注意,理解这个关键概念对后面理解JavaScript面向对象编程至关重要:
- 在JavaScript中,万物皆对象;
- 在JavaScript中,函数是一个可以被调用的特殊对象。
几乎所有的面向对象语言都有动态引用当前对象的方法,JS也不例外。这就是this关键字。this关键字与函数调用密切相关,所不同于其他语言的是JS中的this关键字比较灵活,有很多种绑定规则,容易把人绕晕。而且this关键字在类的定义中频繁用到,如果再加上JS因为没有“类”的概念而弄出的各种奇葩定义方式就更晕了。
要理解面向对象的JS就必须先理解this关键字,要理解this关键字就必须把握一个关键法则,我称它为“调用时法则”——直到函数调用的那一刻this关键字的绑定才被确定。有5种情况:
1. 函数作为方法
当函数作为对象方法被调用时,在调用的那一刻this关键字被绑定到当前对象上:
var obj = {
fn: function(a, b) {
console.log(this);
}
};
// this --> obj
obj.fn(3, 4);
2. 函数直接调用
当函数被直接调用时,this被绑定到global对象:
var fn = function(one, two) {
console.log(this, one, two);
};
var g={}, b={};
// this --> global
fn(g, b);
3. Functional.prototype.call
如果我们希望函数在直接被调用时显式指定this关键字的绑定,那么我们可以使用函数对象特有的call()方法:
var fn = function(one, two) {
console.log(this, one, two);
};
var r={}, g={}, b={};
// this --> r
fn.call(r, g, b);
4. 作为回调函数
作为回调函数传入其他函数中时,由于其他函数会直接调用该回调函数,因此this关键字仍然被绑定为global对象,就算传入的是某个对象的方法也是如此——还记得吗?this关键字的绑定只有在调用时才被确定:
// this --> global
setTimeout(r.method, 1000);
如果需要保留对象信息,可以采取这样的方式,用一个匿名函数将其“包裹”起来:
setTimeout(function() {
r.method();
}, 1000);
5. 与new操作符一起使用
与new操作符一起使用时,this关键字将被绑定为新创建的对象,在JS中定义类时会大量用到这样的方法:
var Car = function(loc) {
this.loc = loc;
}
// this --> a brand new object
var c = new Car(2);
二. 面向对象的JavaScript
关键概念铺垫完了,下面我们正式进入正题,看看JS是怎么定义类的。以上我们了解了语言特性,下面我们就来复习一下什么是“面向对象”。那么问题来了,当我们说“面向对象”的时候,我们究竟在说什么?
1. 三大要素
面向对象编程有三大要素:封装,继承和多态。“封装(encapsulation)”就是将数据和与数据有关的操作封装起来,定义成一个“类”,以实现对问题领域的建模。比如定义一个类Car代表汽车,封装一些常见的数据,比如速度,重量和长度等,形成属性,再封装一些操作,比如“启动”,“行驶”和“停止”等,形成方法。那么汽车有很多种,有货车,卡车,公交车和轿车等等,不同的类型有一些自己特有的属性,那么就需要通过“继承(inheritance)”来模拟这种关系,“继承”也是代码重用的一种有效的抽象方式。虽然都是汽车,都具有“启动”,“行驶”和“停止”等方法,但不同种类车辆有自己的操作方式,它们共享同样的“接口”(比如都具有“启动”的方法)但实现方式不一样,我们称这种多样的行为为“多态(polymorphism)”。
以上就是掌握JS面向对象编程的所有关键概念,再次强调,这些概念对于理解下面要介绍的内容至关重要。总结一下,有如下4点:
- 在JavaScript中,万物皆对象;
- 在JavaScript中,函数是一个可以被调用的特殊对象;
- this关键字直到调用时才被确定绑定到哪个对象;
- 面向对象三要素:封装,继承和多态;
2. JavaScript中定义类的模式
深刻理解这4个概念之后JS面向对象编程中诸多晦涩难懂的概念将迎刃而解。简单的说,对象其实就是键值对,类似与Python中的dict,Go语言中的map,因此最简单的情况下,如果我需要一个对象,那么其实我不需要定义类,直接定义对象就好了:
var amy = {loc: 1};
amy.loc++;
var ben = {loc: 9};
ben.loc++;
但是对象如果有成千上百个,这么定义也不是个事儿,要写很多重复代码。要实现最基本的代码重用,就需要把创建对象的过程抽象出来。在JS中这种抽象往往通过定义一个函数来实现,这也可以说是JS特有的一类构造型模式了。比如“对象修饰器模式(object decorator pattern)”。
1)对象修饰器模式
对象修饰器模式的一大特点是修饰器本身并不创建对象。我们将创建好的对象传递给修饰器,让修饰器为对象添加新的属性然后返回:
var carLike = function(obj, loc) {
obj.loc = loc;
obj.move = function() {
this.loc++; // *根据this关键字绑定规则,它将被绑定到被调用的对象上
};
return obj;
}
这种创建对象的方式还不够省心,如果我们要求函数自己创建对象并返回,那么它就不叫修饰器了,叫“函数类(functional class)”。
2)函数类模式
函数类模式定义的函数一般大写,以显示它的特殊性:它会创建对象,添加属性,然后返回:
var Car = function(loc) {
var obj = {};
obj.loc = loc;
obj.move = function() {
obj.loc++;
};
return obj;
};
如果将定义好的函数和new运算符结合起来使用,我们还可以把代码写的更简单些,这就诞生了一个新的模式,叫做“构造器模式(constructor pattern)”。
3)构造器模式
使用new运算符时,JS解释器会默默地在函数一头一尾处分别帮你添加一行代码,这样代码看起来就更简洁,更像一般意义上的构造函数定义:
var Car = function(loc) {
// this = Object.create(Car.prototype); <-- 解释器隐式添加
this.loc = loc;
this.move = function() {
this.loc++;
};
// return this; <-- 解释器隐式添加
};
以上三种模式都有个共同的弊端——每次创建新对象时都会创建新的函数对象move,但函数的功能是相同的,其实没必要反复拷贝,因此这两种模式只做到了代码重用,并没有做到真正的effective code reuse。创建的对象多了开销会很大。如果要让创建的对象共享属性和方法,可以通过“原型模式(prototype pattern)”实现。
4)原型模式
原型模式通过将属性和方法都定义到构造函数的prototype对象上来实现:
function Car() {}
Car.prototype.loc = 1;
Car.prototype.move = function() {
this.loc++;
};
var c = new Car();
var d = new Car();
此时对象c和对象d都共享同一套属性和方法:loc属性和move方法。确切地说是所有由Car()创建的对象都会拥有同一个loc属性和move方法,这就避免了重复拷贝同一个函数对象。原型模式分别与函数类模式、构造器模式组合形成了“原型类模式(prototype class)”和“伪类模式(pseudo class)”。
5)原型类模式
原型类模式是函数类模式和原型模式的结合。我们用函数类来创建实例并添加属性,然后用原型对象来存储所有的共享方法,并把所创建的实例委托给原型对象:
var Car = function(loc) {
var obj = Object.create(Car.prototype);
obj.loc = loc;
reurn obj;
};
Car.prototype.move = function() {
this.loc++;
};
这样一来,每个实例都有自己的loc属性,但它们都通过原型对象共享同一个函数对象move。如果你仔细对比上面这段代码和前面介绍的构造器模式里的代码,就会发现它们其实是大同小异的。只需要一点点改进和优化,原型类模式就变成了伪类模式。
6)伪类模式
使用原型类模式定义的类是这样的:
var Car = function(loc) {
var obj = Object.create(Car.prototype);
obj.loc = loc;
reurn obj;
};
Car.prototype.move = function() {
this.loc++;
};
而使用构造器模式定义的类是这样的:
var Car = function(loc) {
// this = Object.create(Car.prototype); <-- 解释器隐式添加
this.loc = loc;
this.move = function() {
this.loc++;
};
// return this; <-- 解释器隐式添加
};
注释是关键!可以看到在使用new运算符创建对象时,解释器自动帮我们生成了这两行代码,因此,我们可以把原型类模式的代码改写成下面这样:
var Car = function(loc) {
this.loc = loc;
};
Car.prototype.move = function() {
this.loc++;
};
当我们使用var car = new Car(5);
来定义新的实例时,该实例就自动将move方法委托给了Car.prototype对象,当我们调用car.move()
时,由于car本身不包括move()方法(还记得吗,对象只不过是键值对而已),此时就会到Car.prototype中去查找,找到了,于是便执行该函数。结合上面的讨论可以知道此时this关键字被绑定给了car对象,因此改变的就是对象car的loc属性。这种对象之间通过原型对象形成链式关系,逐步向上查找的机制就是JS中的“原型链(prototype chain)”。它就像一个链表,JS中所有对象都有一个顶层原型对象Object.prototype它包括了所有对象共享的方法,比如toString()。根据“万物皆对象”的特性,所有的数组都是特殊对象,数组共享的方法委托给了Array.prototype原型;所有的函数也都是特殊对象,函数共享的方法委托给了Function.prototype。
arr --> Array.prototype --> Object.prototype
fn --> Function.prototype --> Object.prototype
伪类模式是所有模式中最高效简洁的模式,因此推荐使用它来定义类,但是光有类,没有继承关系肯定也不行,下面我们就来重点看看伪类模式下如何建立类之间的继承关系。
3. 伪类模式下实现类之间的继承关系
比如现在我们要定义一个类Van表示货车,它是Car的子类,拥有自己特有的方法grab。可以通过4个分解步骤来实现:
- 继承超类的属性;
- 继承超类的方法;
- 恢复原型的构造函数属性;
- 定义自己特有的方法;
1)继承超类的属性
我们需要在子类的上下文环境中以一种特殊的方式去调用超类构造函数来继承超类的属性。方法就是使用Function.prototype.call():
var Van = function(loc) {
Car.call(this, loc);
};
根据上面介绍的绑定规则,执行new Van(5);
时this关键字被绑定到新创建的Van实例上,然后该实例被传给了Car函数,在Car函数中的this即Van实例,因此Van的实例从Car那里继承了loc属性。
2)继承超类的方法
回想一下,超类Car的方法被委托给了Car.prototype对象,因此才实现了所有Car的实例共享同一套函数对象,当调用实例方法时,会通过原型链机制链式查找函数对象并调用。因此如果Van想要继承Car的方法,那么它也需要将自己的原型对象委托给Car.prototype。怎么委托呢?通过Object.create()方法:
var Van = function(loc) {
Car.call(this, loc);
};
Van.prototype = Object.create(Car.prototype);
这样便建立了链式关系,Van的实例现在可以通过原型链机制调用move函数了,你可能会很好奇,到底Object.create()是怎么建立这层关系的呢?看一下Crockford大神的Object.create()实现你就知道了:
if (typeof Object.create !== 'function') {
Object.create = function (o) {
// 临时构造器
function F() {
}
// 所有F构造器创建的实例都将自己的原型委托给对象o
F.prototype = o;
// 返回F类的对象
return new F();
};
}
这下明白了,原来Van.prototype = Object.create(Car.prototype);
就是将Van.prototype替换成了一个新的对象f,而f将自己的原型委托给了Car.prototype对象。一旦有v.move()
这样的调用,就会先在v这个键值对中查找move对象,发现找不到,于是通过Van自己的prototype对象到Car.prototype中去查找,找到了!于是执行。
3)恢复原型的构造函数属性
上面这段代码有个小问题,仔细看看Object.create()的实现就会发现,此时我们的Van.prototype实际上是一个新对象f,而f除了有一个.prototype属性指向Car.prototype之外就什么都没有了,缺了什么?缺了构造函数!原来Van.prototype是有一个constructor属性的,该属性存储的就是Van本身——通过它我们可以知道Van的对象都是由哪个构造函数实现的。现在没有了,意味着根据原型链法则,我们会到Car.prototype中去寻找构造函数,是Car()函数本身,这是不对的,因此我们需要恢复自己的构造函数:
var Van = function(loc) {
Car.call(this, loc);
};
Van.prototype = Object.create(Car.prototype);
Van.prototype.constructor = Van;
4)定义自己特有的方法
关键步骤完成之后,我们终于可以定义自己特有的方法了:
var Van = function(loc) {
Car.call(this, loc);
};
Van.prototype = Object.create(Car.prototype);
Van.prototype.constructor = Van;
Van.prototype.grab = function() {
/**/
};
此时如果我们觉得Car原先实现的move函数不满足Van的实际情况,我们也可以定义一个move函数:
var Van = function(loc) {
Car.call(this, loc);
};
Van.prototype = Object.create(Car.prototype);
Van.prototype.constructor = Van;
Van.prototype.grab = function() {
/**/
};
Van.prototype.move = function() {
/* new implementation of move() */
};
这样一来,根据原型链法则,当调用v.move()
时会先在v身上查找,发现找不到,于是到Van.prototype中查找,找到了!就不会继续向上回溯到Car.prototype。这不就是“多态”吗?对了,JS的多态就是通过原型链机制和this关键字的调用时绑定实现的。与此相关的是instanceof运算符。你常常会在代码中看到car instanceof Car
这样的调用。instanceof运算符的工作机制是:它会检查Car.prototype有没有出现在car的原型链中,如果有就返回true,否则就返回false。
三. 也谈理解和把握复杂概念的方法
《传习录》中曾记录过一个著名的公案,叫做“岩中花”:
先生游南镇。一友指岩中花树问曰“天下无心外物。如此花树,在深山中自开自落,于我心亦何相关?”
先生曰:“你未看此花时,此花与汝心同归于寂。你来看此花时,则此花颜色一时明白起来。便知此花不在你的心外。”
你没有看到这朵花时,它对你是没有意义的,你甚至都不知道它的存在。而当你看到这朵花时,它的形状,颜色就一下子出现在你的心中了,你就知道,哦!这是一朵花。
陆王心学讲究“心外无物”,“心即理”,“圣人之道,吾性自足”,“吾心即宇宙,宇宙即吾心”。这讲的其实是一种“价值存在”而不是“物理存在”。我们当然知道,如果我们没有看到这朵花,或者没有接触前端开发的知识,那么这朵花或者说这些面向对象机制仍然是存在的,只是它的存在对我们没有价值意义而已。而一旦接触它们,它们就具有了价值,就不在我们的心外。所以我们要去“致良知”,要去弄懂它到底是怎么运作的,抓住它的关键和本质:JS其实就是用键值对去模拟面向对象的封装,继承和多态!一旦抓住这个中心,复杂的概念就迎刃而解了——所谓八仙过海各显神通,那些千奇百怪的实现方式只不过都是想方设法通过键值对来建立面向对象的机制罢了,万变不离其宗!
由此可以产生一个观念上的转变,即我们做任何事情,要坚持价值判断而不是利益判断,这样我们才能走的更远。拿打王者荣耀来举例,如果我们心里揣着的是利益判断,那么我们追逐的就是个人利益,就会纠结自己能拿对方多少个人头,会不会被杀死很多次,这局打完能不能获得MVP什么的,在玩游戏的时候要么过于冲动,要么不敢把握机会,最后反而输的可能性更大。而如果我们心里揣着的是价值判断,那么我们会更多地思考如何配合队友,怎样提高自己的技战术,进而多花心思了解对方,了解自己,做到知己知彼,反而可能赢的概率更高。这就是思维的转变,这就是心学的力量。
身在黑暗,心向光明。
四. 参考资料