JS學習理解之閉包和高階函式

那片星空發表於2019-02-16

一、閉包

對於 JavaScript 程式設計師來說,閉包(closure)是一個難懂又必須征服的概念。閉包的形成與
變數的作用域以及變數的生存週期密切相關。下面我們先簡單瞭解這兩個知識點。

1.1 變數的作用域

變數的作用域,就是指變數的有效範圍。我們最常談到的是在函式中宣告的變數作用域。

當在函式中宣告一個變數的時候,如果該變數前面沒有帶上關鍵字 var,這個變數就會成為 全域性變數,這當然是一種容易造成命名衝突的做法。
另外一種情況是用 var 關鍵字在函式中宣告變數,這時候的變數即是區域性變數,只有在該函 數內部才能訪問到這個變數,在函式外面是訪問不到的。程式碼如下:

var func = function(){ var a = 1;
    alert ( a ); // 輸出: 1 
};
func();
alert ( a ); // 輸出:Uncaught ReferenceError: a is not defined

在 JavaScript 中,函式可以用來創造函式作用域。此時的函式像一層半透明的玻璃,在函式 裡面可以看到外面的變數,而在函式外面則無法看到函式裡面的變數。這是因為當在函式中搜尋 一個變數的時候,如果該函式內並沒有宣告這個變數,那麼此次搜尋的過程會隨著程式碼執行環境 建立的作用域鏈往外層逐層搜尋,一直搜尋到全域性物件為止。變數的搜尋是從內到外而非從外到 內的。

下面這段包含了巢狀函式的程式碼,也許能幫助我們加深對變數搜尋過程的理解:

var a = 1;
var func1 = function(){ 
    var b = 2;
    var func2 = function(){ 
        var c = 3;
        alert ( b ); // 輸出:2
        alert ( a ); // 輸出:1
    }
    func2(); 
    alert ( c );// 輸出:Uncaught ReferenceError: c is not defined
}; 
func1(); 

1.2 變數的生存週期

除了變數的作用域之外,另外一個跟閉包有關的概念是變數的生存週期。

對於全域性變數來說,全域性變數的生存週期當然是永久的,除非我們主動銷燬這個全域性變數。

而對於在函式內用 var 關鍵字宣告的區域性變數來說,當退出函式時,這些區域性變數即失去了 它們的價值,它們都會隨著函式呼叫的結束而被銷燬:

var func = function(){
var a = 1; // 退出函式後區域性變數 a 將被銷燬 alert ( a );
}; func();

現在來看看下面這段程式碼:

var func = function(){ 
    var a = 1;
    return function(){ 
        a++;
        alert ( a );
    } 
};
var f = func();
f(); // 輸出:2
f(); // 輸出:3
f(); // 輸出:4
f(); // 輸出:5

1.3閉包的作用

1.3.1 封裝變數

閉包可以幫助把一些不需要暴露在全域性的變數封裝成“私有變數”。假設有一個計算乘積的
簡單函式:

var mult = function(){ var a = 1;
for ( var a = a
}
return a; };
i = 0, l = arguments.length; i < l; i++ ){ * arguments[i];

mult 函式接受一些 number 型別的引數,並返回這些引數的乘積。現在我們覺得對於那些相同 的引數來說,每次都進行計算是一種浪費,我們可以加入快取機制來提高這個函式的效能:

var cache = {};
var mult = function(){
    var args = Array.prototype.join.call( arguments, `,` ); 
    if ( cache[ args ] ){
        return cache[ args ]; 
    }
    var a = 1;
    for ( var i = 0, l = arguments.length; i < l; i++ ){
        a = a * arguments[i]; 
    }
    return cache[ args ] = a; 
};
alert ( mult( 1,2,3 ) ); // 輸出:6
alert ( mult( 1,2,3 ) ); // 輸出:6

我們看到 cache 這個變數僅僅在 mult 函式中被使用,與其讓 cache 變數跟 mult 函式一起平行 地暴露在全域性作用域下,不如把它封閉在 mult 函式內部,這樣可以減少頁面中的全域性變數,以 4 避免這個變數在其他地方被不小心修改而引發錯誤。程式碼如下:

var mult = (function(){
    var cache = {}; 
    return function(){
        var args = Array.prototype.join.call( arguments, `,` ); 
        if ( args in cache ){
            return cache[ args ]; 
        }
        var a = 1;
        for ( var i = 0, l = arguments.length; i < l; i++ ){
            a = a * arguments[i]; 
        }
        return cache[ args ] = a; 
    }
})();

提煉函式是程式碼重構中的一種常見技巧。如果在一個大函式中有一些程式碼塊能夠獨立出來, 我們常常把這些程式碼塊封裝在獨立的小函式裡面。獨立出來的小函式有助於程式碼複用,如果這些 小函式有一個良好的命名,它們本身也起到了註釋的作用。如果這些小函式不需要在程式的其他 9 地方使用,最好是把它們用閉包封閉起來。程式碼如下:

var cache = {};
var mult = (function(){
    var cache = {};
    var calculate = function(){ // 封閉 calculate 函式
        var a = 1;
        for ( var i = 0, l = arguments.length; i < l; i++ ){
            a = a * arguments[i];
        }
        return a;
    };
    return function(){
        var args = Array.prototype.join.call( arguments, `,` );
        if ( args in cache ){
            return cache[ args ];
        }
        return cache[ args ] = calculate.apply( null, arguments );
    }
})();

1.3.2 延續區域性變數的壽命

img 物件經常用於進行資料上報,如下所示:

var report = function( src ){
    var img = new Image();
    img.src = src;
};
report( `http://xxx.com/getUserInfo` );

但是通過查詢後臺的記錄我們得知,因為一些低版本瀏覽器的實現存在 bug,在這些瀏覽器下使用 report 函式進行資料上報會丟失 30%左右的資料,也就是說, report 函式並不是每一次都成功發起了 HTTP 請求。丟失資料的原因是 img 是 report 函式中的區域性變數,當 report 函式的呼叫結束後, img 區域性變數隨即被銷燬,而此時或許還沒來得及發出 HTTP 請求,所以此次請求就會丟失掉。

現在我們把 img 變數用閉包封閉起來,便能解決請求丟失的問題:

var report = (function(){
    var imgs = [];
    return function( src ){
        var img = new Image();
        imgs.push( img );
        img.src = src;
    }
})();

二、高階函式

高階函式是指至少滿足下列條件之一的函式。

  • 函式可以作為引數被傳遞;
  • 函式可以作為返回值輸出。

JavaScript 語言中的函式顯然滿足高階函式的條件,在實際開發中,無論是將函式當作引數
傳遞,還是讓函式的執行結果返回另外一個函式,這兩種情形都有很多應用場景,下面就列舉一
些高階函式的應用場景。

2.1 函式作為引數傳遞

把函式當作引數傳遞,這代表我們可以抽離出一部分容易變化的業務邏輯,把這部分業務邏
輯放在函式引數中,這樣一來可以分離業務程式碼中變化與不變的部分。其中一個重要應用場景就
是常見的回撥函式。

1. 回撥函式

在 ajax 非同步請求的應用中,回撥函式的使用非常頻繁。當我們想在 ajax 請求返回之後做一
些事情,但又並不知道請求返回的確切時間時,最常見的方案就是把 callback 函式當作引數傳入
發起 ajax 請求的方法中,待請求完成之後執行 callback 函式:

var getUserInfo = function( userId, callback ){
    $.ajax( `http://xxx.com/getUserInfo?` + userId, function( data ){
        if ( typeof callback === `function` ){
            callback( data );
        }
    });
}
getUserInfo( 13157, function( data ){
    alert ( data.userName );
});

回撥函式的應用不僅只在非同步請求中,當一個函式不適合執行一些請求時,我們也可以把這些請求封裝成一個函式,並把它作為引數傳遞給另外一個函式,“委託”給另外一個函式來執行。

2. Array.prototype.sort

Array.prototype.sort 接受一個函式當作引數,這個函式裡面封裝了陣列元素的排序規則。從Array.prototype.sort 的使用可以看到,我們的目的是對陣列進行排序,這是不變的部分;而使用 什 麼 規 則 去 排 序 , 則 是 可 變 的 部 分 。 把 可 變 的 部 分 封 裝 在 函 數 參 數 裡 , 動 態 傳 入Array.prototype.sort,使 Array.prototype.sort 方法成為了一個非常靈活的方法,程式碼如下:

//從小到大排列
[ 1, 4, 3 ].sort( function( a, b ){
    return a - b;
});
// 輸出: [ 1, 3, 4 ]

//從大到小排列
[ 1, 4, 3 ].sort( function( a, b ){
    return b - a;
});
// 輸出: [ 4, 3, 1 ]

2.2 函式作為返回值輸出

相比把函式當作引數傳遞,函式當作返回值輸出的應用場景也許更多,也更能體現函數語言程式設計的巧妙。讓函式繼續返回一個可執行的函式,意味著運算過程是可延續的。

1. 判斷資料的型別

我們來看看這個例子,判斷一個資料是否是陣列,在以往的實現中,可以基於鴨子型別的概念來判斷,比如判斷這個資料有沒有 length 屬性,有沒有 sort 方法或者 slice 方法等。但更好的方式是用 Object.prototype.toString 來計算。 Object.prototype.toString.call( obj )返回一個字 符 串 , 比 如 Object.prototype.toString.call( [1,2,3] ) 總 是 返 回 “[object Array]” , 而Object.prototype.toString.call( “str”)總是返回”[object String]”。所以我們可以編寫一系列的isType 函式。程式碼如下:

var isString = function( obj ){
    return Object.prototype.toString.call( obj ) === `[object String]`;
};
var isArray = function( obj ){
    return Object.prototype.toString.call( obj ) === `[object Array]`;
};
var isNumber = function( obj ){
    return Object.prototype.toString.call( obj ) === `[object Number]`;
};

我們發現,這些函式的大部分實現都是相同的,不同的只是 Object.prototype.toString.call( obj )返回的字串。為了避免多餘的程式碼,我們嘗試把這些字串作為引數提前值入 isType函式。程式碼如下:

var isType = function( type ){
    return function( obj ){
        return Object.prototype.toString.call( obj ) === `[object `+ type +`]`;
    }
};
var isString = isType( `String` );
var isArray = isType( `Array` );
var isNumber = isType( `Number` );
console.log( isArray( [ 1, 2, 3 ] ) ); // 輸出: true

我們還可以用迴圈語句,來批量註冊這些 isType 函式:

var Type = {};
for ( var i = 0, type; type = [ `String`, `Array`, `Number` ][ i++ ]; ){
    (function( type ){
        Type[ `is` + type ] = function( obj ){
            return Object.prototype.toString.call( obj ) === `[object `+ type +`]`;
        }
    })( type )
};
Type.isArray( [] ); // 輸出: true
Type.isString( "str" ); // 輸出: true

2. getSingle

下面是一個單例模式的例子,在第三部分設計模式的學習中,我們將進行更深入的講解,這
裡暫且只瞭解其程式碼實現:

var getSingle = function ( fn ) {
    var ret;
    return function () {
        return ret || ( ret = fn.apply( this, arguments ) );
    };
};

這個高階函式的例子,既把函式當作引數傳遞,又讓函式執行後返回了另外一個函式。我們可以看看 getSingle 函式的效果:

var getScript = getSingle(function(){
`return document.createElement( `script` );
});
var script1 = getScript();
var script2 = getScript();
alert ( script1 === script2 ); // 輸出: true

注:內容摘取《Javascript設計模式與開發實踐》

相關文章