淺談匿名函式和閉包

就那發表於2018-03-16

前言

相信很多前端小夥伴在工作和學習中,都會或多或少的接觸和了解到匿名函式閉包。被這倆知識點所困擾,也去網上搜尋了不少的資料,查到資料和解釋都各有說辭,甚至有些解釋本身就是不正確的,這更加讓人頭疼。今天就來聊一聊匿名函式閉包,淺談一下他們之間的關係(實際上他們之間並沒有什麼直接關係!important)。

什麼是匿名函式

匿名函式相對應的是具名函式,具名函式非常簡單:function myFn(){},這就是個具名函式這個函式的name是myFn。可以測試一下:

function myFn(){
}
cosnole.log(myFn.name);//myFn
複製程式碼

特別說明一下,es6版本中引用型函式表示式也可以看成是具名函式。比如var myFn1 = function(){},列印myFn1.name,也會得到myFn1。

再說匿名函式,一般用到匿名函式的時候都是立即執行的。通常叫做自執行匿名函式或者自呼叫匿名函式。常用來構建沙箱模式,作用是開闢封閉的變數作用域環境,在多人聯合工作中,合併js程式碼後,不會出現相同變數互相沖突的問題。立即執行的匿名函式有很多種寫法,常見的有以下兩種:

(function(){ 
  console.log("我是匿名方式1");
})();//我是匿名方式1

(function(){ 
  console.log("我是匿名方式2");
}());//我是匿名方式2

console.log((function(){}).name);//'' name為空
複製程式碼

兩者的區別就是:一個是發起執行的括號在匿名函式括號的外面,另外一個發起執行的括號在匿名函式的裡面。實際中的書寫方式個人的話比較推薦第一種,這種寫法更符合呼叫機制,呼叫時的引數也比較明顯,如下:

(function(i,j,k){ 
  console.log(i+j+k);
})(1,3,5);
//9
複製程式碼

還有其他一些自執行匿名函式的寫法,如下:

-function(){ 
  console.log("我是匿名方式x");
}();
console.log(-function(){}.name);//-0
+function(){ 
  console.log("我是匿名方式x");
}();
console.log(+function(){}.name);//0
~function(){ 
  console.log("我是匿名方式x");
}();
console.log(~function(){}.name);//-1
!function(){ 
  console.log("我是匿名方式x");
}();
console.log(!function(){}.name);//true
void function(){ 
  console.log("我是匿名方式x");
}();
console.log(void function(){}.name);//undefined
複製程式碼

這幾種操作符,有時會影響結果的型別,不推薦使用,大家可以查下資料看看各種方式之間的差別。具名函式其實也可以立即執行,在此不做太多的伸展(本文主要目的是為了說明匿名函式和閉包之間的關係)。

實際上,立即執行的匿名函式並不是函式,因為已經執行過了,所以它是一個結果,只不過這個結果可以是一個字串、數字或者null/false/true,也可以是物件、陣列或者一個函式(物件和陣列都可以包含函式),結果是什麼主要看函式執行完成時return什麼。

閉包是怎麼定義的,該如何理解

閉包本身定義比較抽象,MDN官方上解釋是:A closure is the combination of a function and the lexical environment within which that function was declared. 中文解釋是:閉包是一個函式和該函式被定義時的詞法環境的組合。 很多地方可以看到一個說法:js中每個函式都是一個閉包,這樣理解也是沒有問題的,不過會增加對閉包的理解難度,這裡先不這麼理解,可以按照閉包起的作用來理解它:就是能在一個函式外部執行這個函式內部定義的方法,並訪問這個函式內部定義的變數。

在此,先看個經典的使用閉包的案例,實現在函式外部訪問函式內部的區域性變數:

function box(){
  var a = 10;
  function inner(){
    return a;
  }
  return inner;
}
var outer = box();
console.log(outer());//10
複製程式碼

正常情況,box執行過後,會被回收機制回收所佔用的記憶體,包括其內部定義的區域性變數。但是此時box執行過後返回一個內部的函式inner,這個inner引用了內部的變數a,inner又被外部outer給接收,回收機制檢查到內部的變數被引用,就不會執行回收。

但是看到這裡,還是一臉蒙比,哪裡使用了閉包?貌似有三個函式呀,一個box,一個inner還有一個outer = box()。

  • 這個案例中用到的閉包其實是inner和inner被定義時的詞法環境,這個閉包被return出來後被外部的outer引用,因此可以在box外部執行這個inner,inner能夠讀取到box內部的變數a。

  • 使用這個閉包的目的是為了在box外部訪問a,就是通過執行outer()。

用匿名函式實現閉包

上面的例子是在具名函式box內部用一個具名函式inner實現了閉包,那怎麼使用匿名函式實現閉包呢,也很簡單:

//第一步直把內部inner這個具名函式改為匿名函式並直接return, 結果同樣是10
function box(){
  var a = 10;
  return function(){
    console.log(a) ; 
  }
}
var outer = box();
outer();//10
//第二步把外部var outer = box()改成立即執行的匿名函式
var outer = (function(){
  var a=10;
  return function(){
    console.log(a);
  }
})();
//outer 作為立即執行匿名函式執行結果的一個接收,這個執行結果是閉包,outer等於這個閉包。
//執行outer就相當於執行了匿名函式內部return的閉包函式
//這個閉包函式可以訪問到匿名函式內部的私有變數a,所以列印出10
outer();//10
複製程式碼

這樣我們就改寫成了由匿名函式實現的閉包,真正使用到的閉包是內部的被return的函式和這個函式所定義時的環境。由此可以說明:閉包跟函式是否匿名沒有直接關係,匿名函式和具名函式都可以建立閉包。

for迴圈的問題及解決方案

還有一個令人感到困惑,工作和學習中也經常遇見的問題是在for迴圈中:

for(var i = 0;i<5;i++){
  setTimeout(function(){
    console.log(i);
  },100*i);
}
複製程式碼

我們希望列印出來0,1,2,3,4,然而列印出來的是5個5,很尷尬。什麼原因引起的這問題呢?這是因為setTimeout的回撥函式並不是立即執行的而是要等到迴圈結束才開始計時和執行(在此對執行機制不伸展),要說明的一點是js中函式在執行前都只對變數保持引用,並不會真正獲取和儲存變數的值。所以等迴圈結束後i的值是已經是5了,因此執行定時器的回撥函式會列印出5個5。

1)怎麼解決這個問題? 最常見的解決方法就是給定時器外面加一個立即執行的匿名函式,並把當前迴圈的i作為實參傳入這個立即執行的匿名函式。如下:

for(var i = 0;i<5;i++){
  (function(i){
    setTimeout(function(){
      console.log(i);
    },100*i);
  })(i);
}
複製程式碼

可以得到預想的結果:0,1,2,3,4,此時很多人認為這個立即執行的匿名函式就是閉包,其實這麼理解是錯誤的,然後在錯誤的理解之上又擴充套件了好多案例,導致其他人看後不知所謂,一頭霧水。附上一張Stack Overflow上一位同學的回答截圖,我覺得他說的特別有道理:

淺談匿名函式和閉包
原文地址:stackoverflow.com/questions/8…

2)那到底這個for迴圈中的閉包是什麼呢,其中的自執行匿名函式又起到什麼作用呢? 我們可以試著把這個自執行的匿名函式改寫為具名的函式,來測試下結果:

for(var i = 0;i<5;i++){
  function hasNameFn(i){
    setTimeout(function(){
      console.log(i);
    },100*i);
  };
  hasNameFn(i);
}
複製程式碼

可以發現結果和使用匿名函式的結果是一樣的,所以這裡也可以說明閉包跟匿名函式沒什麼直接關係。

這個for迴圈中的閉包怎麼理解以及自執行匿名函式的作用:

  • 這個for迴圈其實是在執行定時器的回撥函式時才真正的產生了閉包,這些回撥函式的執行環境是window,類似剛才例子中的引用inner的全域性outer的執行環境,匿名函式則相當於剛才例子中的box函式。

  • 而自執行的匿名函式的作用也很簡單:就是每一次迴圈建立一個私有詞法環境,執行時把當前的迴圈的i傳入,儲存在這個詞法環境中,這個i就類似上面box函式中var宣告的區域性變數a。

  • 剛才有說到函式在被執行前都只是儲存對變數的引用,自執行的匿名函式正是因為執行了,所以能夠獲取當前的變數i的值。因此定時器的回撥函式在執行時引用的i就已經確定了具體的值。

  • 或許我們改寫一下,這麼看就能更清晰明瞭一些:

for(var i = 0;i<5;i++){
  (function(j){
    var _i = j;
    setTimeout(function(){
      console.log(_i);
    },100*_i);
  })(i);
}
複製程式碼

改寫後的匿名函式形參用j來表示,內部定義一個區域性變數_i=j。匿名函式執行時傳入的是迴圈時的i,此時定時器裡面列印的_i其實是j,匿名函式立即執行,j的值也會確定。所以最後每次定時器的回撥函式列印的結果也都是這個已經被匿名函式所確定的值。

3)其他的解決方案 解決剛才for迴圈的問題,其實根本要解決的問題是如何讓每次迴圈的定時器的回撥函式引用當前的i,而不是迴圈結束後的i。

最簡單的方法是使用es6 let,能夠為變數建立塊級作用域:

for(let i = 0;i<5;i++){
  setTimeout(function(){
    console.log(i);
  },100*i);
}
//改寫成下面這麼寫更好理解一些
for(var i = 0;i<5;i++){
  let j = i;
  setTimeout(function(){
    console.log(j);
  },100*j);
}
複製程式碼

還可以用bind繫結當前的i給定時器的回撥函式(實際上bind方法內部還是實現了一個對呼叫者的柯里化閉包,並儲存了執行時傳入的引數給呼叫者):

for(var i = 0;i<5;i++){
  setTimeout(function(i){
    console.log(i);
  }.bind(this,i),100*i);
}
複製程式碼

可以得到跟使用立即執行函式同樣的效果,所以說匿名函式閉包之間並沒有什麼關係,只不過很多時候在用到匿名函式解決問題的時候恰好形成了一個閉包,就導致很多人分不清楚匿名函式和閉包的關係。

至此,關於匿名函式和閉包的關係,也聊的差不多了,希望能給那些對匿名函式和閉包比較迷惑的小夥伴一些幫助,同時文章中有不足的地方,也請大夥給予指出,一起學習進步!

相關文章