"大哥,割草机借我用一下,我修整一下草坪。" ---- 谈谈this与JavaScript函数调用的不解之缘

在写上一篇有关apply和call的博文时(闲聊JS中的apply和call),起初我还是担心大家理解起来比较困难,因为要理解apply调用方式的前提是,至少先理解在JavaScript中函数调用是什么?this到底代表什么意思?等等。不过从大家的反馈来看,我的担心是多余的,诸位园友都是高手,理解这些基础的东东是小菜一碟。
话虽这样讲,不过今天我还是和大家聊聊JavaScript中与this相关的各种函数调用方式,可以把知识补充完整,日后回顾起来也比较方便。

【背景介绍】光明小区是一个别墅小区,家家户户门前屋后都有一块小草坪。小区的物业公司为了提高业主的满意度,为小区的业主提高了修整草坪的服务,并且还把割草机需要用的汽油列入每月的预算,确保随时都能提供优质的服务。

提供割草服务的割草机

接下来我们就结合具体的业务场景,来讲解JavaScript中的函数调用方式。

1. 方法调用

针对光明小区为业主提供修整草坪服务的情况,我们可以用如下代码描述:

var GuangMing = {
    oilVolume:5000,
    cutGrass:function( grass_num ){
        var oilConsumption = grass_num * 20 ;    //假设每平米草坪需要耗油 20 ml
        console.log( '正在割 ' + grass_num + ' 平米的草坪,需要用油:' +  oilConsumption + ' mL。' );
        console.log( '原来有油:' + this.oilVolume + ' mL' );
        this.oilVolume = this.oilVolume - oilConsumption ;
        console.log( '用完之后,剩余的油量为:' + this.oilVolume + ' mL' );
        return ;
    }
};
GuangMing.cutGrass( 15 );       //小区门口有15平米的草坪需要割
console.log( '>>光明小区中剩余的油:' + GuangMing.oilVolume );

在浏览器的控制台运行之后,输出结果为:

正在割 15 平米的草坪,需要用油:300 mL。
原来有油:5000 mL
用完之后,剩余的油量为:4700 mL
>>光明小区中剩余的油:4700

这就是所谓的方法调用,与我们预期中的一样(特别是之前搞过Java等静态语言的高手),毫无违和感。
"oilVolume 和 cutGrass 都是GuangMing这个对象的成员,在cutGrass这个方法中调用的this.oilVolume当然是指和它同属于一个对象(GuangMing)的oilVolume 。"
不过,我们上面的这种理解从本质上来讲,是错误的。
cutGrass函数体中引用的this.oilVolume指代了GuangMing.oilVolume这个变量,不是因为在GuangMing对象的'声明'时,cutGrass和oilVolume都是GuangMing这个对象的成员,而是因为,我们是通过GuangMing.cutGrass( 15 ); 的方式来使用这个cutGrass这个函数的
也就是说:函数体内的this指向谁,不是看声明时,而是看运行时。
完整的结论我们随着后面的讲解逐步来完善。

2. 函数调用

光明小区的这项服务推出之后,得到了广大业主的好评。小区所属的红旗街道办也知道了这事,街道办的同志觉得这项服务是提升单位形象的好举措,于是也宣称可以提供割草的服务。不过,红旗街道办没有去自己购买割草机设备,而是当有人要求服务的时候,直接通知光明小区的物业公司帮忙提供一下就可以了。

用代码表示如下:

 1 var oilVolume = 8000 ;
 2 
 3 var GuangMing = {
 4     oilVolume:5000,
 5     cutGrass:function( grass_num ){
 6         var oilConsumption = grass_num * 20 ;    //假设每平米草坪需要耗油 20 ml
 7         console.log( '正在割 ' + grass_num + ' 平米的草坪,需要用油:' +  oilConsumption + ' mL。' );
 8         console.log( '原来有油:' + this.oilVolume + ' mL' );
 9         this.oilVolume = this.oilVolume - oilConsumption ;
10         console.log( '用完之后,剩余的油量为:' + this.oilVolume + ' mL' );
11         return ;
12     }
13 };
14 
15 var hongQiCutgrass = GuangMing.cutGrass ;    //光明小区所在的红旗街道也说给大家提供割草服务
16                                              //但是既然光明小区已经有割草机,所以街道办就没有单独购买割草机,
17 
18 console.log( '>>光明小区中原来的油:' + GuangMing.oilVolume );                                             //而是直接使用光明小区的割草机。
19 hongQiCutgrass( 20 );
20 console.log( '>>光明小区中剩余的油:' + GuangMing.oilVolume );

运行后的输出结果如下:

>>光明小区中原来的油:5000
正在割 20 平米的草坪,需要用油:400 mL。
原来有油:8000 mL
用完之后,剩余的油量为:7600 mL
>>光明小区中剩余的油:5000

关键代码解析如下:

第1行:为了解释函数调用方式中,函数体内的this会指向全局对象(浏览器中就是window),增加一个全局变量oilVolume。

第15行:声明了一个全局变量hongQiCutgrass指向GuangMing对象的成员函数cutGrass。

第19行:运行全局函数hongQiCutgrass。

从最后输出的结果来看:cutGrass()中的this已经不是指代GuangMing对象了,而是指向'全局对象'。
我们再回顾一下前面得出的结论:
"函数体内的this指向谁,不是看声明时,而是看运行时。"
而刚才的场景中,运行时的代码是:hongQiCutgrass( 20 );变量hongQiCutgrass指向一个函数,变量hongQiCutgrass没有隶属于任何的其他对象,所以,变量hongQiCutgrass是一个全局变量,隶属于全局对象。
这也就意味着,运行hongQiCutgrass( 20 );时,
1. cutGrass函数体中的this是指向'全局对象',
2. this.oilVolume 就指代另外一个全局变量oilVolume。运行前的值是8000,运行之后变成7600。

讲到这里,有些同学可能会问,hongQiCutgrass不是指向了GuangMing.cutGrass了吗?这可能需要理一下JavaScript中'变量'和'变量所指的对象'的关系。

(具体JavaScript引擎如何实现,我无从考证,下图是我对在JavaScript中'变量'和'变量所指的对象'之间的关系的理解,不对之处,欢迎各位大侠指出。)

我们知道,在JavaScript中:

1. 对象传递,都是引用传递(包括:赋值、参数传递等)。

2. 函数也是对象。

代码执行到第13行时,状态如(a)所示,对象GuangMing的成员cutGrass指向具体的函数对象F008。

当代码执行到第15行时,因为赋值的过程其实传递的也是引用,所以,GuangMing.cutGrass 和 hongQiCutgrass都指向了F008这个函数对象。

当代码执行到第19行时,因为hongQiCutgrass不属于某个具体的对象,所以,函数体内的this就指代全局对象,这就是所谓的'函数调用方式'。

如果把全局变量hongQiCutgrass理解为'全局对象'的成员,把全局变量oilVolume也理解为'全局对象'的成员,那么,其实'方法调用方式'和'函数调用方式'的原理是一致的。

某一天,旁边的东方小区见光明小区提供的'割草服务'很好,所以,它也向业主宣称可以提供割草服务。但是,东方小区也没有自己去购买一台割草机,而是配了一把'光明小区'的割草机的钥匙。如果用代码表示如下:

//...前面的代码不变,增加dongfang小区的声明部分
var DongFang = {
    oilVolume:6000,
    cutGrass:GuangMing.cutGrass
};
DongFang.cutGrass( 6 );

运行后的输出结果如下:

正在割 6 平米的草坪,需要用油:120 mL。
原来有油:6000 mL
用完之后,剩余的油量为:5880 mL

这样做,业务功能是达到了,但是存在的缺点也很明显,一方面比较繁琐,另一方面与常识不太相符,容易导致混乱,不好理解。
相当于小明家有两个小孩,小红家也有两个小孩。

>>开始时小明的妈妈跟小明交代说,你去买一个冰淇淋给你弟弟吃。(相当于声明时的样子)
>>后来,小明的妈妈远远地看到有人在喂冰淇淋。心里想着:'应该是小明在给他弟弟小亮喂冰淇淋吧。'
    而实际上呢,是小明在给小红的弟弟(小蓝)喂冰淇淋。(相当于运行时的样子)
>>这和'小明妈妈'的预期是不一样的!!
就是说,设计 DongFang 这个对象的设计师的这种搞法,GuangMing这个对象的设计师是预期不到的!某天GuangMing.cutGrass里面的功能变化了,DongFang.cutGrass的行为也会跟着变化,而这种关系,在代码中表现得并不明显。

所以,如果把某个'方法f'是'声明'在一个'对象A'里的,那么,这个对象的设计者当然是希望这个方法中的this,就是指向'对象A',其他的对象如果要使用这个'方法f'也可以,但是,不能偷偷摸摸的'引用',而是应该明确指出:'对象B借用了对象A的方法f'。'对象B'借用'对象A'的方法f,没错,这就是我们接下来马上要介绍的apply调用。
(看来JavaScript的设计者也是用心良苦哈,为了可能会'乱用'对象方法的熊孩子操碎了心^_^~~)

有人也许会问,JavaScript对this的处理方式,确实是不好理解哈,除了容易引起逻辑混乱之外,难道有什么好处吗?好处其实也是有的,我们这里先留一个悬念,等在讲解构造器调用时再细讲。

3. apply调用

关于apply调用模式的细节,前一篇博文"兄台息怒,关于arguments,您的想法和大神是一样一样的----闲聊JS中的apply和call" 已经讲得比较详细,这里就顺着今天我们的'割草机'的例子,展现一下相关的代码,一起回顾一下。

var GuangMing = {
    oilVolume:5000,
    cutGrass:function( grass_num ){
        var oilConsumption = grass_num * 20 ;    //假设每平米草坪需要耗油 20 ml
        console.log( '正在割 ' + grass_num + ' 平米的草坪,需要用油:' +  oilConsumption + ' mL。' );
        console.log( '原来有油:' + this.oilVolume + ' mL' );
        this.oilVolume = this.oilVolume - oilConsumption ;
        console.log( '用完之后,剩余的油量为:' + this.oilVolume + ' mL' );
        return ;
    }
};

var DongFang = {
    oilVolume:6000
};
//光明小区是全国文明小区,你要借用就光明正大的借用,偷偷配把钥匙放在自己的办公室就不应该了嘛
//正确的'借用'姿势
//因为我们只想割6平米的草坪,相当于只有一个'参数列表',而不是一个参数数组,所以用call,而不是apply 
GuangMing.cutGrass.call( DongFang , 6 ); 

运行后的输出结果如下:

正在割 6 平米的草坪,需要用油:120 mL。
原来有油:6000 mL
用完之后,剩余的油量为:5880 mL

4. 构造器调用

这种方式也许是应用最广泛的调用方式,特别是当我们刚刚学习用JavaScript写面向对象的应用时。尽管用这种方式存在没有私密性等诸多问题,大神Douglas Crockford也极力反对用这种方式创建对象,但是,因为这种方式"太方便"了,与传统的静态语言(Java等)的对象建模方式比较像,所以,似乎非常好'理解'。
闲话休说,我们还是回到我们的场景。据说光明小区物业配备'割草机'为广大业主割草的事获得了红旗街道办的好评,于是红旗街道办就给出了规定,辖区内每个小区的物业都应该配备这样的设备,提供割草服务,并指定相关的规章制度。详细的规章制度由小明来制定。于是,小明开始挑灯夜战,写下了如下的代码:

var Area = function( area_name ){
    this.name = area_name ;
    this.oilVolume = 5000 ;        //每个小区默认配备5000mL的油用于割草服务
}
Area.prototype.cutGrass = function( grass_num ){
        console.log( '**'+ this.name + '小区**正在提供割草服务:' );
        var oilConsumption = grass_num * 20 ;    //假设每平米草坪需要耗油 20 ml
        console.log( '正在割 ' + grass_num + ' 平米的草坪,需要用油:' +  oilConsumption + ' mL。' );
        console.log( '原来有油:' + this.oilVolume + ' mL' );
        this.oilVolume = this.oilVolume - oilConsumption ;
        console.log( '用完之后,剩余的油量为:' + this.oilVolume + ' mL' );
        return ;
}

//创建一个东方小区
var dongfang = new Area( '东方' );
dongfang.cutGrass( 2 );
//创建一个劳动小区
var laodong = new Area( '劳动' );
laodong.cutGrass( 10 );

运行后的输出结果如下:

**东方小区**正在提供割草服务:
正在割 2 平米的草坪,需要用油:40 mL。
原来有油:5000 mL
用完之后,剩余的油量为:4960 mL
**劳动小区**正在提供割草服务:
正在割 10 平米的草坪,需要用油:200 mL。
原来有油:5000 mL
用完之后,剩余的油量为:4800 mL

在这里,我们做了如下的动作:
1. 构建构造器函数Area,以便可以用来创建不同的小区。
2. 为构造器函数Area的原型对象(prototype)增加割草服务的方法:cutGrass。因为我们知道,即使是不同的小区,提供割草服务的作业流程是一样的,所以把方法定义到原型对象中。
3. 使用new运算符,依次创建了'东方小区'(dongfang)和'劳动小区'(laodong),并分别调用了它们的割草服务(cutGrass)。
发现运行的结果和我们预期的一样。

现在,我们就来看一下new运算符(构造器调用)的原理,以var dongfang = new Area( '东方' ); 为例说明:

1. 执行new运算时,JavaScript引擎会生成一个全新的对象,这个对象的原型链,会'链向'构造函数的原型对象(prototype)。例子中的构造函数就是Area。
   注意:是一个全新的对象哦,就是JavaScript引擎会单独开辟一块空间给新建的对象。
   ('变量'和'变量所引用的对象'的关系,我们前面有介绍,这里就不再赘述。)
2. 在将新建的'全新对象'返回之前,JavaScript引擎会执行'构造函数'(Area)函数体内的代码,
   这时候,函数体内的this就是指代这个'新建的全新的对象'。 (注意:这就是构造器调用与其他调用方式的不同所在。)
   显然,如果没有这个new运算符而直接调用构造器函数,那么,依据前面我们分析的规则,函数体中的this将会指向'全局对象',

   而这种情况发生时,JavaScript不会报任何的错误!这也是许多大神为什么对'构造器调用'方式创建对象比较诟病的原因。
3. 构造器函数执行到最后,
    如果用return语句返回一个不是对象类型的值,或者没有执行return语句,则返回前面创建的全新的对象(这种情况是符合我们预期的)。
    如果用return语句返回一个是'对象类型'的值,那么,返回的就是指定的这个'对象',而不一定是之前创建的全新的对象。
    (后面这种情况是我们需要避免的,试想,辛辛苦苦创建了一个对象,JavaScript引擎为其开辟了空间,但是被没有返回,没有变量引用它,意味着在瞎搞。)

关于构造器调用方式,整个过程就是这样。

我们再回去分析一下在讲解'函数调用'时留下的悬念,当时经过我们的分析,JavaScript的this动态绑定特性(函数体内的this指向谁,不是看声明时,而是看运行时。)似乎除了把问题搞复杂,没有带来什么好处。现在,我们来剖析一下例子中的dongfang.cutGrass( 2 );这个语句的来龙去脉,就能体会到JavaScript中的这个特性还是有它的意义的。
我们知道:

1. dongfang这个对象是前面通过new运算符创建的全新对象(约定:'变量'和'变量所指的对象'在不影响语义理解的情况下,我们就不作区别)。
2. 我们并没有为dongfang这个对象增加cutGrass的成员属性。

那么,为什么我们能够这样执行dongfang.cutGrass( 2 );呢?
答案是:JavaScript的原型继承机制。

当JavaScript引擎发现dongfang并没有cutGrass的成员属性时,它就会顺着'原型链'去找,而根据前面的new运算符的特点,dongfang这个全新的对象所'链向'的'原型对象正是Area.prototype这个对象,在Area.prototype对象中发现有cutGrass这个函数,于是就调用了Area.prototype中的cutGrass这个函数。
(注意:我们的行文中用'链向'这个词,以便于区别'指向'或'引用'的含义,关于原型链的前世今生,我们有空再聊。)
整个过程的来龙去脉已经有点眉目了。
我们再综合后面的语句laodong.cutGrass( 10 ),从执行结果来看,laodong 和 dongfang 是两个独立的对象,它们都调用了Area.prototype中定义的cutGrass。
正是因为(函数体内的this指向谁,不是看声明时,而是看运行时。)这个特性,才确保了:
- 执行 dongfang.cutGrass( 2 ); 时,cutGrass()中的this指向 dongfang 这个对象。
- 执行 laodong.cutGrass( 10 ); 时,cutGrass()中的this指向 laodong 这个对象。
如果JavaScript不是采用这种'动态'的机制,cutGrass中的this就只能指向Area.prototype,那么,'函数'这种重要的数据类型,在'原型继承'的这种机制中就很难发挥强大的作用。

用闭包实现业务建模

4种调用模式已经讲解完毕,我们举了一个'小区提供割草服务'这样的例子,显然,前面的例子主要是为了讲解特性的方便而设计的,从'业务建模'的角度来看,是存在很多不足的。
如果真的要求创建一个小区这样的对象,然后对外提供割草服务该如何搞呢?请看代码:

var createArea = function( area_name ){
    var _name = area_name ;
    var _oilVolume = 5000 ;    //默认还是配备5000mL的油
    
    var cutGrass = function( grass_num ){
        console.log( '**'+ _name + '小区**正在提供割草服务:' );
        var oilConsumption = grass_num * 20 ;    //假设每平米草坪需要耗油 20 ml
        console.log( '正在割 ' + grass_num + ' 平米的草坪,需要用油:' +  oilConsumption + ' mL。' );
        console.log( '原来有油:' + _oilVolume + ' mL' );
        _oilVolume = _oilVolume - oilConsumption ;
        console.log( '用完之后,剩余的油量为:' + _oilVolume + ' mL' );
        return ;
    }
    var new_area = {};
    new_area.cutGrass = cutGrass ;
    new_area.setOilVolume = function( oil_volume ){
        _oilVolume = oil_volume ;
        console.log( _name + '小区中割草机用的汽油配备到:' + oil_volume + 'mL' );
        return _oilVolume;
    }
    new_area.getOilVolume = function( ){
        return _oilVolume;
    }
    return new_area ;
};

var dongfang = createArea( '东方' );
dongfang.cutGrass( 2 );
console.log( '还剩余可用的油:' + dongfang.getOilVolume() );
dongfang.setOilVolume( 9000 );
dongfang.cutGrass( 7 );

运行后的输出结果如下:

**东方小区**正在提供割草服务:
正在割 2 平米的草坪,需要用油:40 mL。
原来有油:5000 mL
用完之后,剩余的油量为:4960 mL
还剩余可用的油:4960
东方小区中割草机用的汽油配备到:9000mL
**东方小区**正在提供割草服务:
正在割 7 平米的草坪,需要用油:140 mL。
原来有油:9000 mL
用完之后,剩余的油量为:8860 mL

代码中展示的业务模型,基于如下的业务理解:
1. 小区的名称,仅仅在创建小区的时候通过参数传入设置一下,之后就不允许修改了。
    就像在现实生活中,小区的门口会搬一块大石头放到那里,上面刻上"爱情湾畔"几个大字,整好之后就不会去改了。
2. 小区对外提供割草服务(cutGrass)。
    可以看看小区现在还有多少割草机的储备油(getOilVolume),以便判断是否够这次割草作业。
    也可以给小区添加储备的油(setOilVolume),例如:物业公司规定,到了月底,储备的油要增加到8000mL,于是可以执行dongfang.setOilVolume( 8000 );。

这是一个采用'闭包'的方式构建的对象,我们发现,构建出来的dongfang对象,似乎也能满足我们的业务要求,创建好对象之后,不能改变小区的名称,只能通过setOilVolume和getOilVolume操作变量_oilVolume。更重要的特点时,居然'没有用到this'!
把这个例子补充在这里作为对比,我们不难发现:"在应用开发的过程中,一切应该以业务目标为导向,语言的各种特性只是一种手段"。
因为例子中的小区对象是一个非常具体的'业务对象',所以可以不使用this。如果你是在写框架性质的对象,那你一定要理解this,一定要理解apply调用方式。"
闭包是一个"简单但很有内涵"的特性,前面我们聊过'闭包'与'原型继承'的使用,后续如果有时间,我们可以聊聊闭包的其他故事。

【总结】

JavaScript中函数的多种调用方式,是它作为动态语言,具有强大表现力的基础,此外,不对函数的参数类型进行校验、可以把函数作为对象到处传递,也是JavaScript的动态语言特性的体现。我们可以与其他静态语言作一个对比。

1. 类型检查

静态语言中,对传入的参数会做类型检查。而动态语言中,则不会对传入的参数类型以及个数做检查。
在Java社区,一天熊孩子小东去跟社区的王叔叔请求帮助。
小东:"张叔叔,今天我和小杰想去'卫星农场'割草,你可不可以帮我?"
老张:"好啊,你在这里登记一下,这是割草机的钥匙,让王叔叔跟你们一起去。"
小东找到老王,让他帮忙去'卫星农场'去割草,老王问了一下情况,跟小东说:"'卫星农场'哪里有草坪啊?那时麦地!"。

同样的场景,在JavaScript社区就会发生不一样的情况,
小东:"张叔叔,今天我和小杰想去'卫星农场'割草,你可不可以帮我?"
老张:"好啊,这是割草机的钥匙,你拿去用吧。"
到了'卫星农场',小东就启动"割草机",不一会儿功夫,就把一块麦地里的小麦放倒了。
回来之后的日记是这么写的:"今天,天气不错,万里无云的天空飘着朵朵白云......这真是有意义的一天啊!"
【分析】
在静态语言(例如:Java)中,在编译阶段就会对方法的参数类型以及参数数量做检查,发现不对劲就提示'编译错误',相当于每个方法边上都站着一个'老王',负责对方法的类型和数量进行检查。
在动态语言(例如:JavaScript)中,则不会对方法做这方面的检查,如果把方法比作'割草机',那么对于JavaScript引擎来说,它才不管你割的是'小草'还是'小麦',不管你往'割草机'的油箱中加入的是'汽油'还是'酱油',等运行的时候,发现错误才给你指出这里出错了。
虽然JavaScript在函数这个层面没有提供类型检查的服务,作为补充,它也提供了typeof, instanceof等检查类型的方式,所以,我们经常看到一些函数的函数体的开始部分,是一堆的if-else,对传入的参数进行检查。
想起了高考考场的门卫保安:'有没有带准考证?没带的出去。有没有带手机?带了的也出去。脱一下....'
没办法,毕竟'考生'不是'保安','保安'也不是'考生'。
"小李,这是杜老板的儿子。"
"..."

2. 方法调用

在静态语言(Java)中,当我们需要使用某个对象(类)的方法的时候,我们至少是要通过这个对象(类)来调用的,或者继承这个类,然后在子类中使用父类的方法,或者将这个类的对象作为当前对象的一个成员,然后通过这个成员调用。相当于,要使用社区的'割草机',至少是应该打声招呼登记一下的。
但是在动态语言(JavaScript)中,函数在内存中似乎是独立存在的(我们知道:在JavaScript中,函数也是对象),尽管声明时好像这个函数是'属于'对象A的,但只有真正使用的时候(运行时)才表现出来它是'属于'谁的,函数体内的this是指代谁的。
并且,引用某个函数并没有太多的限制,就像你有割草机的钥匙,你就可以启动这台割草机,使用这台割草机。

很显然,JavaScript的某些特性(原型链、apply调用方式、高阶函数属性、闭包等等)使JavaScript变得非常灵活,当然也容易出错。这跟现实生活一样,太多的条条框框,你会觉得不自由,行为受到束缚,但是,没有了一些规则约束,没有了父母的唠叨和提醒,有时也确实会犯错误。
所以,在使用JavaScript开发应用时,一方面我们通过编程规范(共同的约定)来减少错误的发生。比如:在社区中,大家约定从'业主之家'借的梯子,用完之后要还回去。另一方面,我们也可以利用JavaScript中的某些特性(例如:闭包),来构建封装性、稳定性更好的具有弹性的应用。比如:我们担心熊孩子在使用社区的割草机时,可能会把'酱油'当做'汽油'倒到割草机的油箱中,这时候我们可以把割草机的油箱锁起来,不让随便往里面加东西。
显然熊孩子是玩不好JavaScript的,在更多的时候,我们要使用JavaScript,是因为我们要开发的应用只能用它来开发,既然没得其他的选择,那就只有'好好学习'一条路了。正如某位前辈所言,只有我们理解了它的精华,也明白它的糟粕,才能真正用好它。
好像有人在敲门,应该是网上预定的宵夜送到了.....
好,今天就和大伙聊到这里,感谢诸位捧场。

 

posted @ 2016-05-25 01:00  阿来之家  阅读(1090)  评论(2编辑  收藏  举报