天天看點

深入了解JavaScript的閉包

閉包

前言

現在去面試前端開發的崗位,如果你面對的面試官也是個前端,并且不是太水的話,你有很大的機率被問到JavaScript的閉包。

什麼是閉包

什麼是閉包,百度、Google之後,你可能會搜尋很多答案...

《JavaScript進階程式設計》這樣描述

閉包是指有權通路另一個函數作用域中的變量的函數

《JavaScript權威指南》這樣描述

從技術的角度講,所有的JavaScript函數都是閉包:它們都是對象,它們都關聯到作用域鍊

《你不知道的JavaScript》這樣描述

當函數可以記住并通路所在的詞法作用域時,就産生了閉包,即使函數是目前詞法作用域之外執行

最認可的當屬《你不知道的JavaScript》,前面兩種說話都沒有錯。

但閉包應該是基于詞法作用域書寫代碼時産生的自然結果,是一種現象!你也不用為了利用閉包而特意的建立,因為閉包的在你的代碼中随處可見,隻是你還不知道當時你寫的那一段代碼其實就産生了閉包

講解閉包

上面已經說到,當函數可以記住并通路所在的詞法作用域時,就産生了閉包,即使函數是在目前詞法作用域之外執行。

看段代碼

function fn1(){
    var name='小馬哥'
    function fn2(){
        console.log(name);
    }
    fn2();
}
fn1();
           

如果是根據《JavaScript進階程式設計》和《JavaScript權威指南》來說,上面的代碼已經産生閉包了。fn2通路到了fn1的變量,滿足了條件“有權通路另一個函數作用域中的變量的函數”,fn2本身是個函數,是以滿足了條件“所有的JavaScript函數都是閉包”。

這的确是閉包,但是這種方式定義的閉包不太好觀察。

再看一段代碼:

function fn1(){
    var name='小馬哥'
    function fn2(){
        console.log(name);
    }
    return fn2;
}
var fn3 = fn1();
fn3();
           

這樣就清晰地展示了閉包:

  • fn2的詞法作用域能通路fn1的作用域
  • 将fn2當做一個值傳回
  • fn1執行後,将fn2的引用指派給fn3
  • 執行fn3,輸出了變量name

我們知道通過引用關系,fn3就是fn2函數本身。執行fn3能正常輸出name,這不就是fn2能記住并通路它所在的詞法作用域,并且fn2函數的運作還是在目前詞法作用域之外

正常來說,當fn1函數執行完畢之後,其作用域是會被銷毀的,然後垃圾回收器會釋放那段記憶體空間。而閉包卻很神奇的将fn1的作用域存活了下來,fn2依然持有該作用域的引用,這個引用就是閉包。

總結:某個函數在定義時的詞法作用域之外的地方被調用,閉包可以使該函數極限通路定義時的詞法作用域。

注意:對函數值的傳遞可以通過其他的方式,并不一定隻有傳回該函數這一條路,比如可以用回調函數:

function fn1() {
	var name = '小馬哥';
	function fn2() {
		console.log(name);
	}
	fn3(fn2);
}
function fn3(fn) {
	fn();
}
fn1();
           

本例中,将内部函數fn2傳遞給fn3,當它在fn3中被運作時,它是可以通路到name變量的。

是以無論通過哪種方式将内部的函數傳遞到所在的詞法作用域以外,它都會持有對原始作用域的引用,無論在何處執行這個函數都會使用閉包。

再次解釋閉包

以上的例子會讓人覺得有點學院派了,但是閉包絕不僅僅是一個無用的概念,你寫過的代碼當中肯定有閉包的身影,比如類似如下的代碼:

function waitSomeTime(msg,time){
    setTimeout(function(){
        console.log(msg);
    },time);
}
waitSomeTime('hello',1000);
           

定時器中有一個匿名函數,該匿名函數就有涵蓋waitSomeTime函數作用域的閉包,是以當1秒之後,該匿名函數能輸出msg。

另一個很經典的例子就是for循環中使用定時器延遲列印的問題:

for (var i = 1; i <= 10; i++) {
	setTimeout(function () {
		console.log(i);
	}, 1000);
}
           

我們預期的結果為1~10,但卻輸出10此11。這是因為setTimeout中的匿名函數執行的時候,for循環都已經結束了,for循環結束的條件是i大于10,是以輸出10此11。

原因:i是聲明在全局作用域中的,定時器中的匿名函數也是執行在全局作用域中,那當時是每次都輸出11

原因知道了,解決起來就簡單了,我們可以讓i在每次疊代的時候,都産生一個私有的作用域,在這個私有的作用域中儲存目前i的值

for (var i = 1; i <= 10; i++) {
	(function () {
		var j = i;
		setTimeout(function () {
			console.log(j);
		}, 1000);
	})();
}
           

這樣就達到我們的預期了呀,讓我們用一種比較優雅的寫法改造一些,将每次疊代的i作為實參傳遞給自執行函數,自執行函數中用變量去接收:

for (var i = 1; i <= 10; i++) {
	(function (j) {
		setTimeout(function () {
			console.log(j);
		}, 1000);
	})(i);
}
           

閉包的應用

  • setTimeout
//原生的setTimeout傳遞的第一個函數不能帶參數
setTimeout(function(param){
    alert(param)
},1000)


//通過閉包可以實作傳參效果
function func(param){
    return function(){
        alert(param)
    }
}
var f1 = func(1);
setTimeout(f1,1000);
           
  • 閉包的應用比較典型的是定義子產品和封裝變量,我們将操作函數暴露給外部,而細節隐藏在子產品内容
function module(){
    var arr = []; //私有變量
    function add(val){
        if(typeof val ==='number'){
            arr.push(val);
        }
    }
    function get(index){
        if(index < arr.length){
            return arr[index];
        }else {
            return null
        }
    }
    return {
        add:add,
        get:get
    }
}

var mod1 = module();
mod1.add(1);
mod1.add(2);
mod1.add('xxx');
console.log(mod1.get(2));
           
  • 緩存
function getNewValue(key) {
    var obj = {
      name:'張三'
    }
    return obj[key]
  }
  var CacheCount = (function () {
    var cache = {};
    return {
      getCache: function (key) {
        if (key in cache) { // 如果結果在緩存中
          console.log(cache);
          
          return cache[key]; // 直接傳回緩存中的對象
        }
        var newValue = getNewValue(key); // 外部方法,擷取緩存
        cache[key] = newValue; // 更新緩存
        return newValue;
      }

    };

  })();

  console.log(CacheCount.getCache("name"));
  console.log(CacheCount.getCache("name"));
           

JavaSript的進階函數

回調函數

function createDiv(cb){
    let oDiv = document.createElement('div');
    document.body.appendChild(oDiv);
    if(typeof cb === 'function'){
        cb(oDiv);
	}
}
createDiv(function(oDiv){
   oDiv.style.color = 'red'; 
});
           

這個例子中,有一個createDiv這個函數,這個函數負責建立一個div并添加到頁面中,但是之後要再怎麼操作這個div,createDiv這個函數就不知道,是以把權限交給調用createDiv函數的人,讓調用者決定接下來的操作,就通過回調的方式将div給調用者。

這是展現出了抽象,既然不知道div接下來的操作,那麼就直接給調用者,讓調用者去實作

抽象就是隐藏更具體的實作細節,從更高的層次看待我們要解決的問題。

數組中周遊

在程式設計的時候,并不是所有功能都是現成的,比如上面例子中,可以建立好幾個div,對每個div的處理都可能不一樣,需要對未知的操作做抽象,預留操作的入口,作為一名程式員,我們需要具備這種在恰當的時候将代碼抽象的思想

接下來看一下ES5中提供的幾個數組操作方法,可以更深入的了解抽象的思想,ES5之前周遊數組的方式是:

var arr = [1, 2, 3, 4, 5];
for (var i = 0; i < arr.length; i++) {
  var item = arr[i];
  console.log(item);
}
           

仔細看一下,這段代碼中用for,然後按順序取值,有沒有覺得如此操作有些不夠優雅,為出現錯誤留下了隐患,比如把length寫錯了,一不小心複用了i。既然這樣,能不能抽取一個函數出來呢?最重要的一點,我們要的隻是數組中的每一個值,然後操作這個值,那麼就可以把周遊的過程隐藏起來:

function forEach(arr, callback) {
  for (var i = 0; i < arr.length; i++) {
    var item = arr[i];
    callback(item);
  }
}
forEach(arr, function (item) {
  console.log(item);
});
           

以上的forEach方法就将周遊的細節隐藏起來的了,把使用者想要操作的item傳回出來,在callback還可以将i、arr本身傳回:

callback(item, i, arr)

JS原生提供的forEach方法就是這樣的:

arr.forEach(function (item) {
  console.log(item);
});
           

跟forEach同族的方法還有map、some、every等。思想都是一樣的,通過這種抽象的方式可以讓使用者更友善,同僚又讓代碼變得更加清晰。

抽象是一種很重要的思想,讓可以讓代碼變得更加優雅,并且操作起來更友善。在高階函數中也是使用了抽象的思想,是以學習高階函數得先了解抽象的思想。

高階函數

什麼是高階函數

至少滿足以下條件中的一個,就是高階函數

  • 将其他函數作為參數傳遞
  • 将函數作為傳回值

簡單來說,就是一個函數可以操作其他函數,将其他函數作為參數或将函數作為傳回值。我相信,寫過JS代碼的同學對這個概念都是很容易了解的,因為在JS中函數就是一個普通的值,可以被傳遞,可以被傳回。

參數可以被傳遞,可以被傳回

函數作為參數傳遞

函數作為參數傳遞就是我們上面提到的回調函數,回調函數在異步請求中用的非常多,使用者想要在請求成功後利用請求回來的資料做一些操作,但是又不知道請求什麼時候結束。

用jQuery來發一個Ajax請求

function getDetailData(sub_category_id, callback) {
$.ajax(`https://www.luffycity.com/api/v1/courses/?sub_category=${sub_category_id}&ordering=`, function (res) {
    if (typeof callback === 'function') {
      callback(res);
    }
  });
}
getDetailData('1', function (res) {
  // do some thing
});
           

類似Ajax這種操作非常适合用回調去做,當一個函數裡不适合執行一些具體的操作,或者說不知道要怎麼操作時,可以将相應的資料傳遞給另一個函數,讓另一個函數來執行,而這個函數就是傳遞進來的回調函數。

另一個典型的例子就是數組排序

函數作為值傳回

在判斷資料類型的時候最常用的是typeof,但是typeof有一定的局限性,比如:

console.log(typeof []);//object
console.log(typeof {});//object
           

判斷數組和對象都是輸出object,如果想要更細緻的判斷應該要使用Object.prototype.toString

console.log(Object.prototype.toString.call([])); // 輸出[object Array]
console.log(Object.prototype.toString.call({})); // 輸出[object Object]
           

于是,我們可以寫出判斷對象、數組、數字的方法

function isObject(obj){
    return Object.prototype.toString.call(obj) === '[object Object]';
}
function isArray(arr) {
  return Object.prototype.toString.call(arr) === '[object Array]';
}
function isNumber(number) {
  return Object.prototype.toString.call(number) === '[object Number]';
}
           

我們發現這三個方法太像了,可以做一些抽取:

function isType(type) {
  return function (obj) {
    return Object.prototype.toString.call(obj) === '[object ' + type + ']';
  }
}
var isArray = isType('Array');
console.log(isArray([1,2]));
           

這個isType方法就是高階函數,該函數傳回了一個函數,并且利用閉包,将代碼變得優雅。