JavaScript中的this、call和apply

在JavaScript编程中,this关键字总是让初学者感到迷惑,Function.prototype.call和Function.prototype.apply这两个方法也有着广泛的运用。我们有必要在学习设计模式之前先理解这几个概念。

this

跟别的语言大相径庭的是,JavaScript的this总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。

this的指向

除去不常用的with和eval的情况,具体到实际应用中,this的指向大致可以分为以下4种。

  • 作为对象的方法调用。

  • 作为普通函数调用。

  • 构造器调用。

  • Function.prototype.call或Function.prototype.apply调用。

下面我们分别进行介绍。

1. 作为对象的方法调用

当函数作为对象的方法被调用时,this指向该对象:

var obj = {
    a: 1,
    getA: function(){
        alert ( this === obj );    // 输出:true
        alert ( this.a );    // 输出: 1
    }
};

obj.getA();
2. 作为普通函数调用

当函数不作为对象的属性被调用时,也就是我们常说的普通函数方式,此时的this总是指向全局对象。在浏览器的JavaScript里,这个全局对象是window对象。

window.name = 'globalName';

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

console.log( getName() );    // 输出:globalName

或者:

window.name = 'globalName';

var myObject = {
    name: 'sven',
    getName: function(){
        return this.name;
    }
};

var getName = myObject.getName;
console.log( getName() );    // globalName

有时候我们会遇到一些困扰,比如在div节点的事件函数内部,有一个局部的callback方法,callback被作为普通函数调用时,callback内部的this指向了window,但我们往往是想让它指向该div节点,见如下代码:

<html>
    <body>
        <div id="div1">我是一个div</div>
    </body>
    <script>

    window.id = 'window';

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

    </script>
</html>

此时有一种简单的解决方案,可以用一个变量保存div节点的引用:

document.getElementById( 'div1' ).onclick = function(){
    var that = this;    // 保存div的引用
    var callback = function(){
        alert ( that.id );    // 输出:'div1'
    }
    callback();
};

在ECMAScript 5的strict模式下,这种情况下的this已经被规定为不会指向全局对象,而是undefined:

function func(){
    "use strict"
    alert ( this );    // 输出:undefined
}

func();
3. 构造器调用

JavaScript中没有类,但是可以从构造器中创建对象,同时也提供了new运算符,使得构造器看起来更像一个类。

除了宿主提供的一些内置函数,大部分JavaScript函数都可以当作构造器使用。构造器的外表跟普通函数一模一样,它们的区别在于被调用的方式。当用new运算符调用函数时,该函数总会返回一个对象,通常情况下,构造器里的this就指向返回的这个对象,见如下代码:

var MyClass = function(){
    this.name = 'sven';
};

var obj = new MyClass();
alert ( obj.name );     // 输出:sven

但用new调用构造器时,还要注意一个问题,如果构造器显式地返回了一个object类型的对象,那么此次运算结果最终会返回这个对象,而不是我们之前期待的this:

var MyClass = function(){
    this.name = 'sven';
    return {    // 显式地返回一个对象
        name: 'anne'
    }
};

var obj = new MyClass();
alert ( obj.name );     // 输出:anne

如果构造器不显式地返回任何数据,或者是返回一个非对象类型的数据,就不会造成上述问题:

var MyClass = function(){
    this.name = 'sven'
    return 'anne';    // 返回string类型
};

var obj = new MyClass();
alert ( obj.name );     // 输出:sven
4. Function.prototype.call或Function.prototype.apply调用

跟普通的函数调用相比,用Function.prototype.call或Function.prototype.apply可以动态地改变传入函数的this:

var obj1 = {
    name: 'sven',
    getName: function(){
        return this.name;
    }
};

var obj2 = {
    name: 'anne'
};

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

call和apply方法能很好地体现JavaScript的函数式语言特性,在JavaScript中,几乎每一次编写函数式语言风格的代码,都离不开call和apply。在JavaScript诸多版本的设计模式中,也用到了call和apply。

丢失的this

这是一个经常遇到的问题,我们先看下面的代码:

var obj = {
    myName: 'sven',
    getName: function(){
        return this.myName;
    }
};

console.log( obj.getName() );    // 输出:'sven'

var getName2 = obj.getName;
console.log( getName2() );    // 输出:undefined

当调用obj.getName时,getName方法是作为obj对象的属性被调用的,根据之前提到的规律,此时的this指向obj对象,所以obj.getName()输出'sven'。

当用另外一个变量getName2来引用obj.getName,并且调用getName2时,根据之前提到的规律,此时是普通函数调用方式,this是指向全局window的,所以程序的执行结果是undefined。

再看另一个例子,document.getElementById这个方法名实在有点过长,我们大概尝试过用一个短的函数来代替它,如同prototype.js等一些框架所做过的事情:

var getId = function( id ){
    return document.getElementById( id );
};

getId( 'div1' );

我们也许思考过为什么不能用下面这种更简单的方式:

var getId = document.getElementById;
getId( 'div1' );

现在不妨花1分钟时间,让这段代码在浏览器中运行一次:

<html>
    <body>
        <div id="div1">我是一个div</div>
    </body>
    <script>

    var getId = document.getElementById;
    getId( 'div1' );

    </script>
</html>

在Chrome、Firefox、IE10中执行过后就会发现,这段代码抛出了一个异常。这是因为许多引擎的document.getElementById方法的内部实现中需要用到this。这个this本来被期望指向document,当getElementById方法作为document对象的属性被调用时,方法内部的this确实是指向document的。

但当用getId来引用document.getElementById之后,再调用getId,此时就成了普通函数调用,函数内部的this指向了window,而不是原来的document。

我们可以尝试利用apply把document当作this传入getId函数,帮助“修正”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

call和apply

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

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

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()
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容

  • 参考《JavaScript设计模式与开发实践》 this 跟别的语言大相径庭的是,JavaScript的this总...
    16manman阅读 559评论 0 0
  • 函数和对象 1、函数 1.1 函数概述 函数对于任何一门语言来说都是核心的概念。通过函数可以封装任意多条语句,而且...
    道无虚阅读 4,351评论 0 5
  • 在JavaScript编程中,this关键字总是让初学者感到迷惑,Function.prototype.call和...
    白小虫阅读 384评论 0 1
  • 工厂模式类似于现实生活中的工厂可以产生大量相似的商品,去做同样的事情,实现同样的效果;这时候需要使用工厂模式。简单...
    舟渔行舟阅读 7,622评论 2 17
  • 再别四月天 一场盛宴 一壶百花酿 香醉云烟 再别四月天 怎么度过 都感觉可惜 不敢挥霍 再别四月天 浪漫的相约 笑...
    文山鹿阅读 333评论 34 42