在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(){}