【JS基礎】從零開始帶你理解JavaScript閉包--我是如何徹底搞明白閉包的

Colin_Mindset發表於2018-10-09

在這裡插入圖片描述


閱讀本文大概需要二十分鐘

一直有一些剛入門js的朋友問我“什麼是閉包?”,這裡我就專門總結一下,下次再有人問起來,就直接把這篇文章給他看好了。

為什麼閉包這麼重要?

因為要想理解閉包的概念,就必須要理解js語言的幾個基本特性:執行上下文作用域鏈(與類C語言的作用域不同!)。所以閉包考察的不僅僅是這一個概念,而是考察的是對js語言基本特性的理解程度。

所以本文將從執行上下文作用域鏈講起,在理解了這幾個概念之後,再介紹閉包的概念。

一、執行上下文

如果要問到JS的執行順序,想必有過JS開發經驗的開發者都會有一個直觀印象:順序執行。然而,

JavaScript引擎並非一行一行地分析和執行程式,而是一段一段地分析執行。當執行一段程式碼的時候,會進行一個“準備工作”。這裡的準備工作,更專業一點的說法,叫做“執行上下文”。

我們先來講全域性環境的“執行上下文”,再講函式的執行上下文。

1. 全域性環境的執行上下文

我們分三種情況來講全域性環境的執行上下文。

(1)第一種情況

我們先來看如下程式碼:
在這裡插入圖片描述
這裡第一塊直接報錯,因為根本沒有宣告變數a,第二三塊都輸出了undefined,這說明a被定義了,但並沒有被賦值,然而定義變數a的程式碼在執行時是在輸出語句之後被執行到,可是變數a被提前宣告瞭。
這說明,在js程式碼被一行一行執行之前,引擎已經提前做了一些準備操作,這其中就包括對變數的宣告,而不是賦值

(2)第二種情況

還是先來看程式碼:
在這裡插入圖片描述
有js開發經驗的朋友都知道,無論在哪個位置獲取this,都可以得到值,只不過根據不同情況,this的值都不同。與第一種情況不同的是:第一種情況只對變數進行宣告(並沒有賦值),而這種情況直接給this賦值,這也是準備工作做的重要事情之一。

(3)第三種情況

在第三種情況中,需要注意兩個概念——‘函式宣告’和‘函式表示式’。雖然兩者都很常用,但在準備工作中卻有著不同的待遇。
在這裡插入圖片描述
看以上程式碼,函式宣告在準備工作中不但被宣告,還被賦值了,而對待函式表示式就像對待變數宣告一樣,只是宣告。
好了,準備工作介紹完畢。

(4)注意函式宣告和變數宣告的優先順序

這裡先看一個例子

function test() {
  function a() {}
  var a;
  log(a);                //列印出a的函式體

  var b;
  function b() {}
  log(b);                 //列印出b的函式體 

  // !注意看,一旦變數被賦值後,將會輸出變數
  var c = 12
  function c() {}
  log(c);                 //12
  
  function d() {}
  var d = 12
  log(d);                //12
}
test();

可以看到,在準備工作中,當變數和函式同時宣告時,函式的優先順序是更高的。然而,應該明確的是,變數賦值是比函式宣告的優先順序更高的。


我們總結一下,在準備工作中做了哪些工作:

  • 變數、函式表示式:宣告(預設值是undefined)
  • this:宣告&賦值
  • 函式:宣告&賦值

這些準備工作我們稱之為執行上下文

2. 函式的執行上下文

在函式中,除了做如上準備工作,還會做其他準備工作。先來看程式碼:
在這裡插入圖片描述
在函式體執行前,arguments和引數x就已經被賦值。從這裡可以看出,函式每呼叫一次,就建立一個執行上下文。因為不同呼叫可能有不同的引數。

3、執行上下文棧

前面我們講解了執行上下文,下面我們來講一下執行上下文棧。執行全域性程式碼時,會先建立一個執行上下文環境;每次呼叫一個函式時,也會先建立一個執行上下文環境。當函式呼叫完成,這個執行上下文環境及其建立的資料都會被銷燬,並回到全域性上下文環境,處於活動的執行上下文只有一個
這其實是一個壓棧出棧的過程——執行上下文棧。如下圖:
在這裡插入圖片描述
可以根據如下程式碼來講解執行上下文棧:
在這裡插入圖片描述
這是一種很理想的情況,在實際情況中可能沒有辦法乾淨利落地說銷燬就銷燬。這種情況就是偉大的——閉包。

在介紹閉包之前,還需要講解下作用域自由變數的概念。



二. 作用域

這部分分為塊級作用域立即執行函式作用域和執行上下文的關係作用域鏈四個部分來講解。

1. 塊級作用域

提到作用域,有一句話大家可能比較熟悉:“JavaScript沒有塊級作用域,只有函式作用域”。塊級作用域就是大括號“{ }”中間的程式碼。任何一對大括號中的語句都屬於一個塊,在這之中定義的變數在括號外無法訪問,這叫做塊級作用域。例如:

function scopeTest() {
    var scope = {};
    if (scope instanceof Object) {
        var j = 1;
        for (var i = 0; i < 10; i++) {
            //console.log(i);
        }
        console.log(i); //輸出10
    }
    console.log(j);//輸出1

}

在JavaScript中變數的作用範圍是函式級的,所以會在for迴圈後輸出10,在if語句後輸出1。
那麼在JavaScript中怎麼模擬一個塊級作用域呢?就可以用我們接下來要講到的立即執行函式

2. 立即執行函式

立即執行函式可以模擬塊級作用域,防止變數全域性汙染,同時也是為了立即去執行一個函式。

立即執行函式是指宣告完便立即執行的函式,這裡函式通常是一次性使用的,因此沒必要給函式命名,直接讓它執行就好了。

所以,立即執行函式的形式應該如下:

<script type="text/javascript">
    function (){}();   // SyntaxError: Unexpected token (
    //引擎在遇到關鍵字function時,會預設將其當做是一個函式宣告,函式宣告必須有一個函式名,所以在執行到第一個左括號時就報語法錯誤了;
    (function(){})();
    //在function前面加!、+、 -、=甚至是逗號等或者把函式用()包起來都可以將函式宣告轉換成函式表示式;我們一般用()把函式宣告包起來或者用 = 
</script>

雖然立即執行函式是想在定義完函式後直接就呼叫,但是引擎在遇到關鍵字function時,會預設將其當做是一個函式宣告,函式宣告必須要有一個函式名,所以執行到第一個括號就報錯了。
正確地定義一個立即執行函式,是應該用括號把函式宣告包起來。
此外,實際應用中,立即執行函式還可用來寫外掛。

<script type="text/javascript">
            var Person = (function(){
                var _sayName = function(str){
                    str = str || 'shane';
                    return str;
                }
                var _sayAge = function(age){
                    age = age || 18;
                    return age;
                }
                
                return {
                    SayName : _sayName,
                    SayAge : _sayAge
                }
            })();
            
            //通過外掛提供的API使用外掛
            console.log(Person.SayName('lucy')); //lucy
            console.log(Person.SayName());//shane
            console.log(Person.SayAge());//18
        </script>

那麼在Javascript中如何模擬塊級作用域呢?舉個例子:

function test(){ 
	(function (){ 
		for(var i=0;i<4;i++){ 
		} 
	})(); 
	alert(i); 
} 
test();

函式執行完,彈出的是i未定義的錯誤。

3. 作用域與執行上下文

作用域和執行上下文是一對一的關係,如下圖:
在這裡插入圖片描述
在執行全域性作用域中的程式碼前,會先建立一個全域性作用域的執行上下文;當呼叫函式時,也會建立一個函式作用域的上下文。
作用域是一個抽象的概念,其中沒有變數,要通過作用域對應的執行上下文來獲取變數的值。同一個函式,在不同的呼叫下,會建立不同的執行上下文,所以會產生不同的變數的值。
如果要查詢一個作用域下某個變數的值,就需要找個這個作用域的執行上下文,在裡面找到對應變數的值。
這個時候,要是在當前作用域的執行上下文中找不到變數該怎麼辦?這就涉及到了作用域鏈的概念。

4. 作用域鏈

我們先來看如下程式碼:

var x = 10
function fn() {
	var b = 20
	console.log(b + x)
}

在呼叫fn時,取變數b的值就可以在當前作用域中取,而取變數x的值時,就需要到另一個作用域中取。到哪個作用域中取呢?
很多人說到父作用域中取,其實這種解釋會產生歧義。例如:

var x = 10

function fn() {
	console.log(x)
}

function show(f) {
	var x = 20
	f()
}

show(fn)

程式執行完,輸出是10,而不是20。這是因為,在當前函式中找不到變數時,要到建立這個函式的那個作用域中去取值。

想必接下來這個程式的執行結果你應該也知道了。

var a = 'global';
var f = function(){
    console.log(a); // 答案是undefined, 想想為什麼
    var a = 'local';
}
f();

好了,有了以上基礎知識,我們接下來來講解閉包



三. 閉包

JavaScript語言的特別之處在於:函式內部可以讀取全域性變數,但函式外部無法訪問到函式內部的變數。出於種種原因,我們有時候需要獲取函式內部變數的值。正常情況下,這是辦不到的!只有通過變通的方法才能實現:在函式內部,再定義一個函式。

	function f1(){

    var n=999;

    function f2(){
      alert(n); // 999
    }

  }

那麼什麼是閉包呢?閉包就是有權訪問另一個函式內部作用域的變數的函式

有了前文的基礎,理解閉包的概念就不是那麼難了。不過在現實情況中,閉包有兩種典型場景一定要知道。

1. 閉包的兩種典型應用

第一,函式作為返回值

function fn() {
	var max = 10
	return function bar(x) {
		if(x > max) {
			console.log(x)
		}
	}
}

var f1 = fn()
f1(15)

第二,函式作為引數被傳遞

var max = 10,
fn = function(x) {
	if(x > max) {
		console.log(x)
	}
}

(function(f) {
	var maxx = 100
	f(15)
})(fn)

2. 如何從記憶體角度理解閉包?

  1. JavaScript具有自動垃圾回收機制,函式執行完之後,其內部變數就會被銷燬;
  2. 閉包就是在外部可以訪問此函式作用域變數的函式,JavaScript中,只要存在引用函式內部變數的可能,JavaScript就需要在記憶體中保留這些變數,而且JavaScript執行時需要跟蹤這個內部變數的所有外部引用,直到最後一個引用被解除(置為null或者頁面關閉),JavaScript垃圾收集器才釋放相應的記憶體空間

舉個例子,

<script type="text/javascript">
    function outer(){
        var a = 1;
        function inner(){
            return a++;
        }
        return inner;
    }
    var abc = outer();
    //outer()只要執行過,就有了引用函式內部變數的可能,然後就會被儲存在記憶體中;
    //outer()如果沒有執行過,由於作用域的關係,看不到內部作用域,更不會被儲存在記憶體中了; 
    
    console.log(abc());//1
    console.log(abc());//2
    //因為a已經在記憶體中了,所以再次執行abc()的時候,是在第一次的基礎上累加的
    
    var def = outer(); 
    console.log(def());//1
    console.log(def());//2
    //再次把outer()函式賦給一個新的變數def,相當於繫結了一個新的outer例項;
    
    //console.log(a);//ReferenceError: a is not defined
    //console.log(inner);//ReferenceError: a is not defined
    //由於作用域的關係我們在外部還是無法直接訪問內部作用域的變數名和函式名
    
    abc = null;
    //由於閉包占用記憶體空間,所以要謹慎使用閉包。儘量在使用完閉包後,及時解除引用,釋放記憶體;
</script>


四. 閉包的經典陷阱

接下來來看一個閉包的經典陷阱——在迴圈中使用閉包。舉個例子,要給10個span元素加上click事件監聽,讓每個span點選時依次輸出0-9。我們可能會這樣寫:

for (var i = 0; i < spans.length; i++) {
   spans[i].onclick = function() {
        alert(i);
    }
}

可實際上呢?這裡每個span標籤點選都輸出的是10。
為什麼會這樣呢?
因為內部函式持有了外部函式中變數i的引用,所以i不會被銷燬,每個function都返回i,而在執行上述程式時,onclick方法是沒有執行的,等到for迴圈執行完畢,i變為10,每個onclick函式都返回i,i這時已經變為10了。
為了解決這個問題,我們應該用到立即執行函式:

for (var i = 0; i < spans.length; i++) {
   (function(e){
	   spans[e].onclick = function() {
	        alert(e);
	    }
   })(i)
}

立即執行函式會立即執行,並把 i 作為它的引數,此時函式內 e 變數就擁有了 i 的一個拷貝。



五. 為什麼要用閉包?

  1. 符合函數語言程式設計規範
    什麼是函數語言程式設計?它的思想是:把運算過程儘量寫成一系列巢狀的函式呼叫。舉例來說,要想程式碼中實現數學表示式:
(1 + 2) * 3 - 4

傳統的寫法是:

var a = 1 + 2;

var b = a * 3;

var c = b - 4;

函數語言程式設計要求儘量使用函式,把運算過程定義為不用的函式

var result = subtract(multiply(add(1,2), 3), 4);

此外,函數語言程式設計把函式作為“一等公民”。函式與其他資料型別一樣,處於平等地位,可以賦值給其他變數,也可以作為引數傳入另一個函式,或者作為別的函式的返回值

  1. 延長變數生命週期
    區域性變數本來在函式執行完就被銷燬,然而閉包中不是這樣,區域性變數生命週期被延長。不過這也容易使這些資料無法及時銷燬,會佔用記憶體,容易造成記憶體洩漏。如:
function addHandle() {
        var element = document.getElementById('myNode');
        element.onclick = function() {
            alert(element.id);
        }
    }

onclick儲存了一個element的引用,element將不會被回收。

function addHandle() {
        var element = document.getElementById('myNode');
        var id = element.id;
        element.onclick = function() {
            alert(id);
        }
        element = null;
}

此處將element設為null,即解除對其引用,垃圾回收器將回收其佔用記憶體。

六. tips:

  1. 如果閉包只有一個引數,這個引數可以省略,可以直接用it訪問該引數。
  2. 實際中閉包常常和立即執行函式結合使用。

七. 參考

http://imweb.io/topic/5665683bd91952db73b41f5e
https://www.cnblogs.com/sspeng/p/6623556.html
http://www.cnblogs.com/dolphinX/archive/2012/09/29/2708763.html
https://segmentfault.com/a/1190000003985390
https://www.jianshu.com/p/0fe03fd2d862
https://segmentfault.com/a/1190000000618597
http://www.cnblogs.com/wangfupeng1988/p/3977924.html
https://www.cnblogs.com/cxying93/p/6103375.html
https://www.cnblogs.com/ZinCode/p/5551907.html
https://blog.csdn.net/weixin_40197429/article/details/79557101
https://www.cnblogs.com/luqin/p/5164132.html

相關文章