JavaScript 閉包機制的詳解

JavaScript 的閉包是老生常談的問題了,包括面試時也喜歡問,所以是時候總結一波了。

在我看來,解決一個碰到的問題有兩個思路:一是找到解決這個問題的方法就OK。二是嘗試從根源上去解析這個問題,以避免被其他類似的問題困擾。

一.什麼是閉包?

閉包是什麼?我們先看看 MDN 上對它的解釋:

閉包是函數和聲明該函數的詞法環境的組合。

MDN上的官方說明都比較言簡意賅,解釋的比較經典,也許有些夥計可能會有些迷糊,所以我簡單給大家翻譯下上面那句話什麼意思:

1.“閉包一定與函數有關,沒有函數就沒有閉包”。
2.“閉包不僅僅只有函數,還包括函數的執行上下文”。

單純的理論太乾巴巴了,咱們看點實際的:

function aa(){
	var bb = 'This is a Test';
	function cc(){
        console.log(bb)
    }
    return cc;
}
var dd = aa();
dd();  // logs 'This is a Test'

上面這個函數,就是一個基礎的閉包,我們可以看到,在函數 aa( ) 中有一個 函數內變量 bb 和一個函數內函數 cc( ),cc( )函數中引用了aa( ) 作用域內的 bb 變量,然後被 aa 的作用域返回,暴露給了 window 環境。

這麼一個簡單的閉包,我們可以簡單歸納出閉包三個特性中的前兩個:

1.閉包一定是函數內嵌套函數。
2.閉包可以讓我們在函數外部訪問函數內部的私有變量和私有函數。

OK,我們再來看一個例子:

function aa(){
	var count = 0;
	function cc(){
        count++;
        console.log(count)
    }
    return cc;
}
var dd = aa();
dd(); // logs 1
dd(); // logs 2
dd(); // logs 3

那麼它使用起來與普通函數有什麼區別呢?我們進行一個對比就清楚了:

function aa_normal(){
	var count = 0;
	function cc(){
        count++;
        console.log(count)
    }
    return cc();
}
aa_normal();  // logs 1
aa_normal();  // logs 1
aa_normal();  // logs 1

我們還可以更大膽,更奔放一些:

function aa_normal(){
	var count = 0;
	count++;
	console.log(count)
}
aa_normal();  // logs 1
aa_normal();  // logs 1
aa_normal();  // logs 1

這下是不是對比就很鮮明瞭?根據 JavaScript 的 GC 機制,普通的函數在每一次調用結束時,都會被內存回收,所以普通函數無論你執行多少次,那個 count 都只會是1,你問我爲什麼?因爲你永遠叫不醒一個裝睡的人嘛。而對於閉包來說就不一樣了,它會一直留在內存中,除非你手動清除它。

so~我們得出了閉包三個特性中的最後一個特性:
3.帶有閉包的詞法環境中的變量和函數不會被GC機制回收。

okok~我們現在總結性的看一下閉包的總的三條特性:

1.閉包一定是函數內嵌套函數。
2.閉包可以讓我們在函數外部訪問函數內部的私有變量和私有函數。
3.帶有閉包的詞法環境中的變量和函數不會被GC機制回收。

二.循環中的閉包

閉包其實還有很多其他高級的用法,比如說以下列表:

  1. 結果緩存
  2. 封裝
  3. 實現類和繼承

但是這些並不是我們這篇文章的重點。本文的重點是啥?是解決剛需!什麼叫剛需,就是你跟你女朋友擦槍走火,她懷孕你們得結婚了,房子就是剛需了。那麼閉包對於大家來說什麼情況下是剛需?那就是閉包出問題的時候是剛需!所以本文重點就是探討,什麼情況下它會出問題,以及怎麼解決它。

下面我說說第一種思路。
咱們先看一個業務中常見的需求:後臺給一個類數組,裏面都是對象,你拿到這個數據後要在一個 ul 中生成相應的 li 列表,並給對應的 li 添加點擊事件。我們來看代碼:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<ul class="data-list"></ul>
</body>
</html>
<script>
var data = [{name:'aaa'}, {name:'bbb'}, {name:'ccc'}];
function initDom(){
    for (var i= 0;i< data.length ;i++){
    		var p = i;
            var ulDom = document.querySelector('.data-list');
            var liDom = document.createElement('li');
            var text = document.createTextNode(data[i].name);
            liDom.className = 'li' + i;
            liDom.appendChild(text);
            ulDom.appendChild(liDom)
        	document.querySelector('.data-list .li'+i).onclick = function() {
				alert(data[p].name);
			}
    }
};
initDom();
</script>

正常情況下,我們想要的結果自然是點擊aaa,彈出aaa,點擊bbb,彈出bbb;但是事實上卻是無論單擊誰,彈出的都是ccc。原因就在於閉包了。在 initDom( )這個函數中,我們給 li 綁定了點擊事件,所以說,其實是有三個閉包,每次循環都有一個,如果這三個閉包都有各自的作用域鏈,那大家相安無事,自然也不會出現這種情況;但是現在是循環結束了,點擊事件還未觸發,這就會造成三個閉包公用一個父級作用域鏈,此時循環已經結束,p都是2;

那麼我們怎麼解決這個問題呢?有三種方式:
第一種:立即執行函數

var data = [{name:'aaa'}, {name:'bbb'}, {name:'ccc'}];
function initDom(){
    for (var i= 0;i< data.length ;i++){
      (function () {
                var ulDom = document.querySelector('.data-list');
                var liDom = document.createElement('li');
                var text = document.createTextNode(data[i].name);
                liDom.className = 'li' + i;
                liDom.appendChild(text);
                ulDom.appendChild(liDom)
                var p = i;
                document.querySelector('.data-list .li' + i).onclick = function () {
                    alert(data[p].name);
                }
            })()
   }
};
initDom();

這時我們再去點擊對應的 li,就能彈出對應的name了。

第二種:工廠函數

function showMsg(msg){
     alert(msg)
 }

function alertMsg(msg) { 
   return function () { 
      showMsg(msg)
   }
}

function initDom() {
    var data = [{
       name: 'aaa'
       }, {
       name: 'bbb'
       }, {
       name: 'ccc'
    }];
    for (var i = 0; i < data.length; i++) {
          var item = data[i];
          var ulDom = document.querySelector('.data-list');
          var liDom = document.createElement('li');
          var text = document.createTextNode(data[i].name);
          liDom.className = 'li' + i;
          liDom.appendChild(text);
          ulDom.appendChild(liDom)    
          document.querySelector('.data-list .li' + i).onclick = alertMsg(item.name)
      }
  };
initDom();

這種工廠模式,其實與我之前在總結閉包的第三個特性時舉的例子很相似,它的重點在於document.querySelector('.data-list .li' + i).onclick = alertMsg(item.name)這一行代碼,實際上它的作用就是創建了循環次數個的工廠函數實例,同過實例化來使每一個閉包的作用域的互干擾,最終達到目的;

第三種:ES6 中的 let
過多的閉包會佔用大量的內存,特別是你存儲的數據還比較大的時候,更會影響性能。所以如果你處於ES 2015 的環境,你可以嘗試使用 let,它使得每一個塊級作用域都有自己的作用域,簡單粗暴解決問題。

function initDom() {
     var data = [{
          name: 'aaa'
          }, {
          name: 'bbb'
          }, {
         name: 'ccc'
     }];
     for (let i = 0; i < data.length; i++) {  
            var ulDom = document.querySelector('.data-list');
            var liDom = document.createElement('li');
            var text = document.createTextNode(data[i].name);
            liDom.className = 'li' + i;
            liDom.appendChild(text);
            ulDom.appendChild(liDom)
            document.querySelector('.data-list .li' + i).onclick = function () {
                alert(data[i].name);
            }
     }
};

三.閉包以外的世界

從上面的知識點我們可以發現,想更深入的使用閉包,我們還需要了解 JavaScript 以下內容:

  1. 變量作用域鏈
  2. JavaScript 的內存回收機制

什麼是作用域鏈?

簡單來說,就是當函數在一個環境中執行時,會創建變量對象的一個作用域鏈。作用域鏈的用途是保證對執行環境有權訪問的所有變量和函數的有序訪問。何爲有序?比如說有三個for循環,他們在各自的循環裏都定義了一個局部變量,假設從外到內是a,b,c。對於最裏面的那層循環來說,它的作用域鏈就是 c到b到a到全局變量。

什麼是內存回收機制?

和 java 一樣 JavaScrip t也是自動回收機制,把不再使用的內存回收。什麼情況下不再使用呢?比如說在一個函數中定義的局部變量,在這個函數執行完畢後它就被銷燬了。但是當你在一個函數中定義了另一個匿名函數,並在裏面定義了一個局部變量,在這個匿名函數外部又調用了這個內部的局部變量,這時回收機制就不會生效,直到這個閉包徹底失效。

有趣的是,在我遇到這個問題不就後我又碰到一個類似的情況,那就是settimeout( )函數。使用場景是,處於某種需要,我想將一個for循環遍歷的時間縮短,然後直接在for中加入了settimeout後發現打印的是亂碼,這與之前的閉包問題有異曲同工之妙。

爲什麼會這樣呢?因爲js是單線程的執行順序,而settimeout()卻是一個異步的方法。如下:

var a=[1,2,3];  
var len=a.length;  
for(i=0;i<len;i++){  
    setTimeout{function(){  
       console.log(i);  
    },100}  
}  

會打印3個undefined。但是修改一下代碼

var a = [1, 2, 3];
var len = a.length;
for (i = 0; i < len; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        }, i * 100)
    })(i)
}

就可以正確的log出來了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章