JavaScript函數(shù)閉包理解(面試篇)

前言:閉包可能是JavaScript中的一個特殊存在,在與后端和移動端同學溝通時他們都不知道閉包這個東西,而閉包也是我在面試過程中遇到很多的問題,在面試官問到我閉包是什么的時候我以前是這樣回答的:閉包就是js中的函數(shù)嵌套函數(shù),內部函數(shù)將外部函數(shù)作用域中的變量保存使其在外部能訪問到。
也就是一句話說閉包:閉包是指有權訪問另一個函數(shù)作用域中的變量的函數(shù)。
我覺得閉包就是js執(zhí)行環(huán)境以及作用域產(chǎn)生的一種特殊的環(huán)境。

最近通過文章《我從來不理解JavaScript閉包,直到有人這樣向我解釋它》再看閉包有了更深的理解,特此記錄一下。

作用域與執(zhí)行上下文

在這之前再看幾個小例子:

a = 2;
var a;
console.log(a)

此時輸出應該為什么?

console.log(a);
var a = 2;

那這個呢?

var a = 2;
function a (){}
console.log(a);

這個輸出呢?

再來一個

var a = 2;
a = function(){}
console.log(a);

這個輸出為什么?

這些問題的正確答案也是你看下去的基礎。最好知道正確答案,或者去查閱知道正確答案。

要理解閉包就得先理解兩個東西 一個是作用域,一個是執(zhí)行上下文;

作用域是什么?可以理解成我們可以有效訪問變量或函數(shù)的區(qū)域。它是一個規(guī)則,規(guī)定了執(zhí)行的程序中變量的存儲和訪問。也可以把它理解成一個“地盤”,我的地盤的東西只有我能使用你的地盤的東西我不能使用。

在javascript中又分全局作用域和局部作用域,這里就只說全局作用域函數(shù)作用域;
來看代碼:

1: var a = 1;
2: function func(){ 
3:   var b = a+1
4:   console.log(a+b);  // 3
5: }
6: console.log(a); //1
7: func(); 
8: console.log(b); //Uncaught ReferenceError: b is not defined

這段代碼有一個全局作用域,上面掛載了變量a,和函數(shù)fun;
還有一個函數(shù)fun的作用域,上面掛在了變量b;
關于這段代碼我們細細品一下:(js引擎執(zhí)行順序)
當代碼在解析執(zhí)行的時候從上到下

  1. 第1行生成變量a,并賦值為10;
  2. 第2行到第5行是連在一起的,聲明變量fun,并且是一個函數(shù);(函數(shù)未執(zhí)行時,內部東西先不用管)
  3. 第6行輸出a;
  4. 第7行函數(shù)func()執(zhí)行,此時回過頭來看2-5行,第3行函數(shù)作用域fun中聲明一個變量b,并賦值為a+1,此時在函數(shù)fun作用域中尋找a,未果,去函數(shù)外也就是父級作用域中尋找a,找到變量a=10,第4行輸出a+b;函數(shù)執(zhí)行完成。
  5. 第8行輸出b

其實在這個分析過程就是js的執(zhí)行上下文。

執(zhí)行上下文是當前正在執(zhí)行的“代碼環(huán)境”。執(zhí)行上下文有兩個階段:編譯和執(zhí)行。

編譯-在此階段,JS 引擎獲取所有函數(shù)聲明并將其提升到其作用域的頂部,以便我們稍后可以引用它們并獲取所有變量聲明(使用var關鍵字進行聲明),還會為它們提供默認值:undefined。

執(zhí)行——在這個階段中,它將值賦給之前提升的變量,并執(zhí)行或調用函數(shù)(對象中的方法)。

注意:只有使用var聲明的變量,或者函數(shù)聲明才會被提升,相反,函數(shù)表達式或箭頭函數(shù),let和const聲明的變量,這些都不會被提升。

執(zhí)行全局代碼時,會產(chǎn)生一個執(zhí)行上下文環(huán)境,每次調用函數(shù)都又會產(chǎn)生新的執(zhí)行上下文環(huán)境。當函數(shù)調用完成時,這個上下文環(huán)境以及其中的數(shù)據(jù)都會被消除,因為處于活動狀態(tài)的執(zhí)行上下文環(huán)境只有一個。

了解了概念之后再看我們的執(zhí)行過程
第1行 var a = undefined; a = 1;
第6行 輸出a a = 1
第7行 函數(shù)func執(zhí)行回到2-5行,var b = undefined; b = a+1;在函數(shù)func作用域中查找a沒找到向上級找,在父級作用域也就是創(chuàng)建函數(shù)func的作用域中找到a = 1;此時這種向上查找的過程被稱為作用域鏈 ;函數(shù)執(zhí)行完成后上下文環(huán)境以及其中的變量全都被銷毀掉。處于活動狀態(tài)的執(zhí)行上下文環(huán)境只有一個;
第8行 輸出變量b在當前作用域中查找不到,因此報錯b is not defined;

再從變量變化的角度清晰的分析一下這個代碼執(zhí)行過程:
(1)代碼執(zhí)行之前首先創(chuàng)建全局上下文環(huán)境

a: undefined
func: undefined
this: window

(2)接著執(zhí)行代碼到第5行之前,上下文環(huán)境中的變量都在執(zhí)行過程中被賦值。

a: 1
func: function
this: window

(3)當執(zhí)行到第7行的時候調用函數(shù)func時。生成新的執(zhí)行上下文環(huán)境。

b: undefined
this: window

(4)賦值

b:2,
this.window

當func函數(shù)執(zhí)行完畢之后,調用func函數(shù)生成的func上下文環(huán)境銷毀,里面的變量也被銷毀,也就導致了在外面輸出b變量會報錯。

上面可能有些啰嗦,但我只是想從不同的角度去理解這些概念,不管怎么樣,到這里執(zhí)行上下文作用域大概就稍微清晰一點了。

閉包

有了作用域以及執(zhí)行上下文概念的輔助,理解閉包會更清晰一點。
javascript函數(shù)返回值類型可以是任何類型,如果沒有返回值默認為undefind;

一. 先看一個返回函數(shù)的函數(shù)例子
 1: var num = 1;
 2: function creater(){
 3:     let add = function(a,b){
 4:         let ret = a+b;
 5:         return ret;
 6: }
 7: return add;
 8: }
 9: let func1 = creater();
10: let result = func1(num,2);
11: console.log(result);
  1. 第1行,聲明變量num并賦值為1;
  2. 第2-8行,聲明變量creater并賦值一個函數(shù),暫時不管它內部構造;
  3. 第9行,全局執(zhí)行上下文中聲明變量func1,暫時,值為undefined;
  4. 第9行,看到括號(),此時需要執(zhí)行調用一個函數(shù),在全局作用域中查找找到變量creater,然后調用它。
  5. 調用函數(shù)creater,走到第2行,此時創(chuàng)建一個新的creater執(zhí)行上下文,此時的活動對象就是creater所在執(zhí)行上下文的活動對象。
  6. 3-6行,聲明變量add,并賦值為一個函數(shù),此時add只在creater的執(zhí)行上下文中。
  7. 第7行,返回變量add的內容,js引擎在當前作用域中查找名為add的變量,好的在第3行找到,我們返回add的定義,第4-5行括號之間的內容構成add函數(shù)定義。
  8. 返回時,creater執(zhí)行上下文被銷毀,add變量同時不復存在,但是,變量add函數(shù)定義仍然存在,以為他返回并賦值給了func1變量。
  9. 接著走第10行,在全局執(zhí)行上下文聲明一個變量result,這里拆解一下,先賦值為undefined
  10. 接著,需要執(zhí)行一個函數(shù),名為func1變量中定義的函數(shù),全局查找,它找到有兩個參數(shù)。
  11. 查找這兩個參數(shù),第一個參數(shù)為第1步的num,表示數(shù)字1,第二個數(shù)字是2。
  12. 現(xiàn)在需要執(zhí)行這個函數(shù),函數(shù)定義在3-5行,這時創(chuàng)建了一個add函數(shù)執(zhí)行上下文,在add執(zhí)行上下文中創(chuàng)建兩個變量ab。他們分別被賦值為1和2。
  13. 第4行,在add執(zhí)行上下文忠聲明一個名為ret的變量,并將變量a,和變量b相加的內容3賦值給了ret。
  14. 第5行,ret變量從add函數(shù)中返回,add函數(shù)執(zhí)行上下文被銷毀,變量a,b,ret不再存在。
  15. 返回值被分配到第10行的result變量中。
  16. 打印輸出result的值。

整個流程大致走下來需要明白幾點:

  1. 函數(shù)可以存儲在變量中。
  2. 函數(shù)定義在程序調用之前是不可見的。
  3. 函數(shù)的返回值可以使任何類型,包括函數(shù)。
  4. 每次調用函數(shù)時,都會(臨時)創(chuàng)建一個執(zhí)行上下文,當函數(shù)完成時,執(zhí)行上下文銷毀。
  5. 函數(shù)在遇到return或者 } 時執(zhí)行完成。
二. 看一個閉包
 1: function createCounter() {
 2:   let counter = 0
 3:    const myFunction = function() {
 4:      counter = counter + 1
 5:      return counter
 6:    }
 7:    return myFunction
 8:  }
 9:  const increment = createCounter()
10:  const c1 = increment()
11:  const c2 = increment()
12:  const c3 = increment()
13:  console.log('example increment', c1, c2, c3)

接著走一遍流程:

  1. 第1-8行,創(chuàng)建一個全局變量createCounter,并指定函數(shù)定義。
  2. 第9行,創(chuàng)建一個全局變量,increment;
  3. 第9行,需要調用名為createCounter的函數(shù)。
  4. 回到1-8行,調用執(zhí)行函數(shù)createCounter,創(chuàng)建一個名為createCounter的新的執(zhí)行上下文。
  5. 第2行,在createCounter執(zhí)行上下文中聲明一個變量counter,并賦值為0;
  6. 第3行,在createCounter執(zhí)行上下文中聲明一個變量myFunction,并定義為一個函數(shù),函數(shù)定義在4-5行。
  7. 第7行,返回myFunction函數(shù)定義,銷毀createCounter執(zhí)行上下文,createCounter上下文中聲明的變量也全部銷毀。
  8. 第10行,在全局上下文聲明一個變量c1。
  9. 第10行,在全局查找increment變量,找到并調用它。它的函數(shù)定義在3-6行。
  10. 回到3-6行,創(chuàng)建一個名為myFunction的執(zhí)行上下文。
  11. 第4行,counter = counter + 1,在myFunction的執(zhí)行上下文中查找counter變量,沒有找到,在全局執(zhí)行上下文中查找counter變量,也未找到。未找到會怎么樣呢?會報錯counter is not defined
  12. 這里會有個疑惑,為什么不去第2行去找這個counter變量?按照我們的理解createCounter在此時是已經(jīng)銷毀的,所以createCounter執(zhí)行上下文里面的變量也是跟著銷毀掉的,所以訪問不到的。
  13. 到這里按照之前的分析邏輯似乎卡住了,貌似這段程序是沒法執(zhí)行的,但當你去運行這段程序的時候卻發(fā)現(xiàn)它跑的很歡快。所以,這里是有貓膩的,什么貓膩呢?就是閉包。
  14. 我們可以這樣理解閉包。它是這樣工作的,無論何時聲明新函數(shù)并將其賦值給變量,都要存儲函數(shù)定義和閉包。閉包包含在函數(shù)創(chuàng)建時作用域中的所有變量,它類似于背包。函數(shù)定義附帶一個小背包,它的包中存儲了函數(shù)定義創(chuàng)建時作用域中的所有變量。當函數(shù)中返回一個函數(shù)的時候此時不僅僅返回的函數(shù)的定義,還連帶他的背包也一并返回了,而這個背包中就存儲了當前執(zhí)行上下文的所有定義的變量。
  15. 接下來再回到第7步,第7行返回myFunction函數(shù)定義,銷毀createCounter執(zhí)行上下文,createCounter上下文中聲明的變量也全部銷毀。但是此時不僅返回了myFunction函數(shù)定義,還一并返回了它的背包。背包里存儲了當前執(zhí)行上下文的所有變量。
  16. 接下來直接到第11步,第4行,counter = counter + 1,在myFunction執(zhí)行上下文和全局執(zhí)行上下文之前,先檢查一下背包,背包里含有一個名為counter的變量其值為0,在第4行表達之后它的值被設置為1,并且再次被存儲在背包里;背包現(xiàn)在包含值為1的counter。
  17. 第5行,counter值被返回出來,myFunction執(zhí)行上下文被銷毀。
  18. 回到第10行。返回值1被賦給變量c1。
  19. 第11行,重復第9-18步,這一次在背包中變量counter的值是1,它是在第16步被設置,被遞增為2并存儲在當前執(zhí)行上下文的函數(shù)背包里,c2被賦值為2。
  20. 第12行,重復第9-18步,c3被賦值為3。
  21. 第13行,打印輸出變量c1,c2,c3的值,分別為1,2,3。
我們上面所說的背包也就是閉包。理解的話就把它理解成函數(shù)默認存在的一種機制,是否所有的函數(shù)都會有閉包,是的。全局范圍創(chuàng)建的函數(shù)也具有閉包,但是由于全局環(huán)境變量都會訪問到,所以全局范圍的函數(shù)的閉包就顯得不那么重要。但是當函數(shù)作為返回值被另外函數(shù)返回的時候,閉包就顯得尤為重要,因為,被返回的函數(shù)可以訪問到不屬于全局作用域的變量,就是閉包中的變量。
最后總結一下,函數(shù)在被當做返回值返回的時候,該函數(shù)會攜帶自己的閉包,閉包是該函數(shù)聲明時的作用域內部所有變量。

我們已經(jīng)知道上面代碼輸出為1,2,3;
那么看一下下面這段代碼c1,c2,c3分別輸出多少呢?

function createCounter() {
   let counter = 0
   const myFunction = function(counter) {
      counter = counter + 1
      return counter
    }
    return myFunction(counter)
}
const increment = createCounter()
const c1 = increment
const c2 = increment
const c3 = increment
console.log('example increment', c1, c2, c3)

這是一種避免閉包的方式,閉包容易導致內存泄漏,造成性能問題。因此在寫程序的時候盡量避免使用閉包。
這樣將變量以參數(shù)的形式傳遞給被返回函數(shù),這樣就可以避免閉包。

覺得對你有用的,點個贊吧?。?!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容