JavaScript设计模式与开发实践
上QQ阅读APP看书,第一时间看更新

2.2 call和apply

ECAMScript 3给Function的原型定义了两个方法,它们是Function.prototype.call和Function. prototype.apply。在实际开发中,特别是在一些函数式风格的代码编写中,call和apply方法尤为有用。在JavaScript版本的设计模式中,这两个方法的应用也非常广泛,能熟练运用这两个方法,是我们真正成为一名JavaScript程序员的重要一步。

2.2.1 call和apply的区别

Function.prototype.call和Function.prototype.apply都是非常常用的方法。它们的作用一模一样,区别仅在于传入参数形式的不同。

apply接受两个参数,第一个参数指定了函数体内this对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply方法把这个集合中的元素作为参数传递给被调用的函数:

        var func = function( a, b, c ){
            alert ( [ a, b, c ] );    // 输出 [ 1, 2, 3 ]
        };

        func.apply( null, [ 1, 2, 3 ] );

在这段代码中,参数1、2、3被放在数组中一起传入func函数,它们分别对应func参数列表中的a、b、c。

call传入的参数数量不固定,跟apply相同的是,第一个参数也是代表函数体内的this指向,从第二个参数开始往后,每个参数被依次传入函数:

        var func = function( a, b, c ){
            alert ( [ a, b, c ] );    // 输出 [ 1, 2, 3 ]
        };

        func.call( null, 1, 2, 3 );

当调用一个函数时,JavaScript的解释器并不会计较形参和实参在数量、类型以及顺序上的区别,JavaScript的参数在内部就是用一个数组来表示的。从这个意义上说,apply比call的使用率更高,我们不必关心具体有多少参数被传入函数,只要用apply一股脑地推过去就可以了。

call是包装在apply上面的一颗语法糖,如果我们明确地知道函数接受多少个参数,而且想一目了然地表达形参和实参的对应关系,那么也可以用call来传送参数。

当使用call或者apply的时候,如果我们传入的第一个参数为null,函数体内的this会指向默认的宿主对象,在浏览器中则是window:

        var func = function( a, b, c ){
            alert ( this === window );    // 输出true
        };

        func.apply( null, [ 1, 2, 3 ] );

但如果是在严格模式下,函数体内的this还是为null:

        var func = function( a, b, c ){
            "use strict";
            alert ( this === null );     // 输出true
        }

        func.apply( null, [ 1, 2, 3 ] );

有时候我们使用call或者apply的目的不在于指定this指向,而是另有用途,比如借用其他对象的方法。那么我们可以传入null来代替某个具体的对象:

        Math.max.apply( null, [ 1, 2, 5, 3, 4 ] )    // 输出:5

2.2.2 call和apply的用途

前面说过,能够熟练使用call和apply,是我们真正成为一名JavaScript程序员的重要一步,本节我们将详细介绍call和apply在实际开发中的用途。

1.改变this指向

call和apply最常见的用途是改变函数内部的this指向,我们来看个例子:

        var obj1 = {
            name: 'sven'
        };

        var obj2 = {
            name: 'anne'
        };

        window.name = 'window';

        var getName = function(){
            alert ( this.name );
        };

        getName();    // 输出: window
        getName.call( obj1 );    // 输出: sven
        getName.call( obj2 );    // 输出: anne

当执行getName.call( obj1 )这句代码时,getName函数体内的this就指向obj1对象,所以此处的

        var getName = function(){
            alert ( this.name );
        };实际上相当于:
        var getName = function(){
            alert ( obj1.name );        // 输出: sven
        };

在实际开发中,经常会遇到this指向被不经意改变的场景,比如有一个div节点,div节点的onclick事件中的this本来是指向这个div的:

        document.getElementById( 'div1' ).onclick = function(){
            alert( this.id );        // 输出:div1
        };

假如该事件函数中有一个内部函数func,在事件内部调用func函数时,func函数体内的this就指向了window,而不是我们预期的div,见如下代码:

        document.getElementById( 'div1' ).onclick = function(){
            alert( this.id );            // 输出:div1
            var func = function(){
              alert ( this.id );        // 输出:undefined
            }
            func();
        };

这时候我们用call来修正func函数内的this,使其依然指向div:

        document.getElementById( 'div1' ).onclick = function(){
            var func = function(){
              alert ( this.id );        // 输出:div1
            }
            func.call( this );
        };

使用call来修正this的场景,我们并非第一次遇到,在上一小节关于this的学习中,我们就曾经修正过document.getElementById函数内部“丢失”的this,代码如下:

        document.getElementById = (function( func ){
            return function(){
              return func.apply( document, arguments );
            }
        })( document.getElementById );

        var getId = document.getElementById;
        var div = getId( 'div1' );
        alert ( div.id );    // 输出: div1

2. Function.prototype.bind

大部分高级浏览器都实现了内置的Function.prototype.bind,用来指定函数内部的this指向,即使没有原生的Function.prototype.bind实现,我们来模拟一个也不是难事,代码如下:

        Function.prototype.bind = function( context ){
            var self = this;        // 保存原函数
            return function(){        // 返回一个新的函数
              return self.apply( context, arguments );    // 执行新的函数的时候,会把之前传入的context
                                                      // 当作新函数体内的this
            }
        };

        var obj = {
            name: 'sven'
        };

        var func = function(){
            alert ( this.name );    // 输出:sven
        }.bind( obj);

        func();

我们通过Function.prototype.bind来“包装”func函数,并且传入一个对象context当作参数,这个context对象就是我们想修正的this对象。

在Function.prototype.bind的内部实现中,我们先把func函数的引用保存起来,然后返回一个新的函数。当我们在将来执行func函数时,实际上先执行的是这个刚刚返回的新函数。在新函数内部,self.apply( context, arguments )这句代码才是执行原来的func函数,并且指定context对象为func函数体内的this。

这是一个简化版的Function.prototype.bind实现,通常我们还会把它实现得稍微复杂一点,使得可以往func函数中预先填入一些参数:

        Function.prototype.bind = function(){
            var self = this,    // 保存原函数
              context = [].shift.call( arguments ),    // 需要绑定的this上下文
              args = [].slice.call( arguments );    // 剩余的参数转成数组
            return function(){    // 返回一个新的函数
              return self.apply( context, [].concat.call( args, [].slice.call( arguments ) ) );
                  // 执行新的函数的时候,会把之前传入的context当作新函数体内的this
                  // 并且组合两次分别传入的参数,作为新函数的参数
              }
            };

        var obj = {
            name: 'sven'
        };

        var func = function( a, b, c, d ){
            alert ( this.name );        // 输出:sven
            alert ( [ a, b, c, d ] )    // 输出:[ 1, 2, 3, 4 ]
        }.bind( obj, 1, 2 );

        func( 3, 4 );

3.借用其他对象的方法

我们知道,杜鹃既不会筑巢,也不会孵雏,而是把自己的蛋寄托给云雀等其他鸟类,让它们代为孵化和养育。同样,在JavaScript中也存在类似的借用现象。

借用方法的第一种场景是“借用构造函数”,通过这种技术,可以实现一些类似继承的效果:

        var A = function( name ){
            this.name = name;
        };

        var B = function(){
            A.apply( this, arguments );
        };

        B.prototype.getName = function(){
            return this.name;
        };

        var b = new B( 'sven' );
        console.log( b.getName() );  // 输出: 'sven'

借用方法的第二种运用场景跟我们的关系更加密切。

函数的参数列表arguments是一个类数组对象,虽然它也有“下标”,但它并非真正的数组,所以也不能像数组一样,进行排序操作或者往集合里添加一个新的元素。这种情况下,我们常常会借用Array.prototype对象上的方法。比如想往arguments中添加一个新的元素,通常会借用Array.prototype.push:

        (function(){
            Array.prototype.push.call( arguments, 3 );
            console.log ( arguments );    // 输出[1,2,3]
        })( 1, 2 );

在操作arguments的时候,我们经常非常频繁地找Array.prototype对象借用方法。

想把arguments转成真正的数组的时候,可以借用Array.prototype.slice方法;想截去arguments列表中的头一个元素时,又可以借用Array.prototype.shift方法。那么这种机制的内部实现原理是什么呢?我们不妨翻开V8的引擎源码,以Array.prototype.push为例,看看V8引擎中的具体实现:

        function ArrayPush() {
            var n = TO_UINT32( this.length );    // 被push的对象的length
            var m = %_ArgumentsLength();     // push的参数个数
            for (var i = 0; i < m; i++) {
              this[ i + n ] = %_Arguments( i );   // 复制元素     (1)
            }
            this.length = n + m;      // 修正length属性的值    (2)
            return this.length;
        };

通过这段代码可以看到,Array.prototype.push实际上是一个属性复制的过程,把参数按照下标依次添加到被push的对象上面,顺便修改了这个对象的length属性。至于被修改的对象是谁,到底是数组还是类数组对象,这一点并不重要。

由此可以推断,我们可以把“任意”对象传入Array.prototype.push:

        var a = {};
        Array.prototype.push.call( a, 'first' );

        alert ( a.length );    // 输出:1
        alert ( a[ 0 ] );    // first

这段代码在绝大部分浏览器里都能顺利执行,但由于引擎的内部实现存在差异,如果在低版本的IE浏览器中执行,必须显式地给对象a设置length属性:

        var a = {
            length: 0
        };

前面我们之所以把“任意”两字加了双引号,是因为可以借用Array.prototype.push方法的对象还要满足以下两个条件,从ArrayPush函数的(1)处和(2)处也可以猜到,这个对象至少还要满足:

❏ 对象本身要可以存取属性;

❏ 对象的length属性可读写。

对于第一个条件,对象本身存取属性并没有问题,但如果借用Array.prototype.push方法的不是一个object类型的数据,而是一个number类型的数据呢?我们无法在number身上存取其他数据,那么从下面的测试代码可以发现,一个number类型的数据不可能借用到Array.prototype. push方法:

        var a = 1;
        Array.prototype.push.call( a, 'first' );
        alert ( a.length );      // 输出:undefined
        alert ( a[ 0 ] );    // 输出:undefined

对于第二个条件,函数的length属性就是一个只读的属性,表示形参的个数,我们尝试把一个函数当作this传入Array.prototype.push:

        var func = function(){};
        Array.prototype.push.call( func, 'first' );

        alert ( func.length );
        // 报错:cannot assign to read only property ‘length' of function(){}