在JavaScript编程中,
this
关键字总是让初学者感到迷惑,Function.prototype.call
和Function.prototype.apply
这两个方法也有着广泛的运用。我们有必要在学习设计模式之前先理解这几个概念。
- <a href="#no1">2.1 this</a>
- <a href="#no2">2.2 call 和 apply</a>
<a name="no1">2.1 this</a>
跟别的语言大相径庭的是,
JavaScript
的this
总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。
2.1.1 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
。
2.1.2 丢失的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
对象的属性被调用的,根据2.1.1节提到的规律,此时的this
指向obj
对象,所以obj.getName()
输出'sven'
。
当用另外一个变量getName2
来引用obj.getName
,并且调用getName2
时,根据2.1.2节提到的规律,此时是普通函数调用方式,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
<a name="no2">2.2 call和apply</a>
Function.prototype.call
和Function.prototype.apply
都是非常常用的方法。它们的作用一模一样,区别仅在于传入参数形式的不同。
2.2.1 call和apply的区别
apply
接受两个参数,第一个参数指定了函数体内this
对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply
方法把这个集合中的元素作为参数传递给被调用的函数:
var func = function( a, b, c ){
console.log( [ 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 ){
console.log ( [ 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(){}