前端戰五渣學JavaScript——閉包

戈德斯文發表於2019-03-15

就決定是你了——閉包

有不少開發人員總是搞不清匿名函式閉包兩個概念,因此經常混用。閉包是指有權訪問另一個函式作用域中的變數的函式。建立閉包的常見方式,就是在一個函式內部建立另一個函式。 ——————摘自《JavaScript高階程式設計》

上面說的很清楚,就是一個函式內部再建立另一個函式(尷尬。。。好像跟書上說的一樣。多餘解釋)。我之前面試也經常被問到這個問題。。。沒想到書上就這麼一句。以下需要有一點js的基礎,瞭解作用域才能看明白。。。

閉包的旅程才剛剛開始

我們先來看一個最簡單的⬇️

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

看,奇蹟吧!!!我們輸出了a函式中的區域性變數b的值。就是這麼神奇。。。
誒??你們可能會說那我這個區域性變數b,相當於一個常量,如果我像下面⬇️這樣豈不是一個效果?

function a() {
    var b = 10;
    return b
}
console.log(a());
複製程式碼

好的,沒有錯,那我們再換種方式⬇️

function a(paramA) {
    return function b(paramB) {
        return paramA + paramB
    }
}

var variable10 = a(10);
var variable20 = a(20);

console.log(variable10(5)); // 15
console.log(variable20(5)); // 25
複製程式碼

上面這個例子,在我們執行輸出variable10(5)variable20(5)的時候,為什麼我們能得到5與之前執行a(10)時傳進去的10呢?這就是我們閉包的用處,可以獲取到函式內的區域性變數,並相加。
等等!也許有人不明白了,怎麼是區域性變數了呢?那我按下面這種寫法可能清楚一些⬇️

function a(paramA) {
  + var variableA = paramA;
  return function b(paramB) {
    return variableA + paramB
  }
}

var variable10 = a(10);
/**
 *  a(10)是傳進去是什麼樣的呢?
 *  
 *  function a(10) {
 *    var variableA = 10;
 *    return function b(paramB) {
 *      return variableA + paramB
 *    }
 *  }
 *  上面語法不對哦,我只是寫的好理解一些
 *  也就是說variable10指向了內部函式b
 *  var variable10 = function b(paramB) {
 *    return 10 + paramB
 *  }
 */
var variable20 = a(20);

console.log(variable10(5)); // 15
console.log(variable20(5)); // 20
複製程式碼

通過上面的註釋,想必大家應該已經瞭解閉包是什麼了,就是在一個外部函式內部建立另一個函式,以至於全域性呼叫內部函式的時候可以訪問到外部函式的區域性變數,並使用。

真相永遠只有一個!

來,上《JavaScript高階程式設計》的例子

function createComparisonFunction(propertyName) {

  return function (object1, object2) {
    var value1 = object1[propertyName];
    var value2 = object2[propertyName];

    if (value1 < value2) {
      return -1
    } else if (value1 > value2) {
      return 1
    } else {
      return 0;
    }
  }
}
複製程式碼

在這個例子中,var value1var value2這兩行程式碼訪問了外部函式中的變數propertyName,即使這個內部函式被返回了,而且是在其他地方被調了,但它仍然可以訪問變數propertyName;這就類似於我上面的例子(程式碼接上)

var manA = { name: '江戶川柯南', age: 7 };
var manB = { name: '工藤新一', age: 17 };
var whoOlder = createComparisonFunction('age');
// 返回-1為前者小於後者,返回1位前者大於後者,返回0兩者一樣大
console.log(whoOlder(manA, manB));  // -1,柯南小於新一
複製程式碼

上面的例子就相當於開始介紹閉包時的例子,不明白?就是

var whoOlder = createComparisonFunction('age');
/**
 *  var whoOlder = createComparisonFunction('age') {
 *    var propertyName = 'age';
 *    return function (object1, object2) {
 *      var value1 = object1['age'];
 *      var value2 = object2['age'];
 *      if (value1 < value2) {
 *        return -1
 *      } else if (value1 > value2) {
 *        return 1
 *      } else {
 *        return 0;
 *      }
 *    }
 *  }
 *  上面語法不對啊,我只是寫的好理解一些
 *  最後得到的就是
 *  var whoOlder = function (object1, object2) {
 *    var value1 = object1['age'];
 *    var value2 = object2['age'];
 *      if (value1 < value2) {
 *        return -1
 *      } else if (value1 > value2) {
 *        return 1
 *      } else {
 *        return 0;
 *      }
 *    }
 *  }
 */
複製程式碼

重點!!! 在一個函式被呼叫的時候,會建立一個執行環境(execution context)及相應的作用域鏈。然後,還有初始化一個活動物件(activation object),這個活動物件裡有什麼呢?有arguments和其他命名引數的值,就是⬇️

function a(paramA, paramB) {
    return paramA + paramB
}
a(1, 2)
/**
 *  這個函式的活動物件有什麼呢??有三個
 *  arguments [1, 2]
 *  paramA    1
 *  paramB    2
 */
複製程式碼

然後就是剛說的作用域鏈,外部函式的活動物件會始終處於第二位,就是比如我在內部函式用到變數a,會先在內部函式找有沒有宣告a這個變數,如果沒有,就會去外部函式找,外部函式沒有就會往全域性執行環境找。
在函式執行過程中,為讀取和寫入變數的值,就需要在作用域鏈中查詢變數,來看下面的例子(《JavaScript高階程式設計》例子)

function compare(value1, value2) {
    if (value1 < value2) {
        return -1
    } else if (value1 > value2) {
        return 1
    } else {
        return 0
    }
}

var result = compare(5, 10)
複製程式碼

這個的作用域鏈畫出來就是

作用域鏈,執行環境,活動物件
現在應該多少能看懂這張圖了吧,當時看書的時候真是費死勁了,中間那個作用域鏈1,0什麼的,就是處於0位置的活動物件是函式自己的活動物件,處於1位置的是全域性物件,所以上面說外部函式的活動物件始終處於第二位。

天下第一武道大會

想必大家面試的時候都會遇到一個非常非常經典的題,題面就是:

頁面上有十個<li></li>標籤,就是一個列表有十項,需要點選每個li輸出對應的索引。

經典錯誤答案:

var list = document.getElementsByTagName('li');
for (var i = 0; i < list.length; i ++) {
  list[i].addEventListener('click', function () {
    console.log(i)
  })
}
複製程式碼

這我們現在都知道是輸出同一個數了,這是為什麼呢?我來換一種寫法:

var list = document.getElementsByTagName('li');
var i = 0;
for (i; i < list.length; i ++) {
  list[i].addEventListener('click', function () {
    console.log(i)
  })
}
複製程式碼

這下我們可以看清楚了吧,當這段程式碼都執行完,還沒有點選li的時候,全域性變數i已經變成最後一個數了,假設有十個li,i就已經根據迴圈變成了10。所以,當我們點選li的輸入全域性變數i的時候,當然每次輸出的都是10了。

經典正確答案:

var list = document.getElementsByTagName('li');
for (var i = 0;; i < list.length; i ++) {
  (function (i) {
    list[i].addEventListener('click', function () {
      console.log(i)
    })
  })(i)
}
複製程式碼

在for迴圈裡,每執行一次,執行一個自調函式,並把i當做引數傳進去,js函式的引數有個按值傳遞的特性,也可以理解為我們前面講過的活動物件,這個自調函式的活動物件裡面有個i的值,所以這時候每個li上要執行的監聽函式輸出的就不在是全域性變數i,而是活動物件中的i,因為迴圈的時候沒迴圈一次執行一次自調函式,所以每個自調函式之間是各自獨立的,所以輸出的i值也自然不一樣了。
當然也有人會說用ES6的語法更簡單一些:

var list = document.getElementsByTagName('li');
 - for (var i = 0; i < list.length; i ++) {
 + for (let i = 0; i < list.length; i ++) {
  list[i].addEventListener('click', function () {
    console.log(i)
  })
}
複製程式碼

就是簡單的把宣告i的var改成let,這是為什麼呢,這是因為ES6中新的規定let宣告的是自帶作用域的變數,並且ES6有塊級作用域的概念,所以會把每次迴圈的i值鎖死,自然輸出的就是不一樣的值啦;

有木葉的地方就會燃燒火之意志

閉包就先寫到這了,如果有哪裡寫的不對的地方希望大家指正,我立馬更改,我也不想誤人子弟。寫這篇部落格也是為了讓自己對閉包這個概念有個重新的認識,對活動物件,作用域鏈更清楚了,但是還有好多東西沒有寫,比如this指向問題,記憶體洩漏怎麼處理,ES5是怎麼模仿塊級作用域的,私有變數,私有函式,模組模式,這都是一個閉包就可以延伸出來的問題,這在《JavaScript高階程式設計》第7章第4節都可以找到,靜下心來看一看,我相信還是可以看明白的,一開始看不懂,就一段時間看一遍,每次肯定會有不同的理解。

對於前端來說,追求時髦的技術固然沒錯,畢竟時髦的技術有著先進的思想,他能告訴你應該怎樣程式設計。但是在程式設計之前,還是需要學好基礎,基礎弄明白了,肯定會對js,前端,有一個全新的認識。


我是前端戰五渣,一個前端界的小學生。

相關文章