還擔心面試官問閉包?

Neal_yang發表於2017-11-06

網上總結閉包的文章已經爛大街了,不敢說筆者這篇文章多麼多麼xxx,只是個人理解總結。各位看官瞅瞅就好,大神還希望多多指正。此篇文章總結與《JavaScript忍者祕籍》 《你不知道的JavaScript上卷》

系列部落格地址:https://github.com/Nealyang/YOU-SHOULD-KNOW-JS

安利個人react技術棧+express+mongoose實戰個人部落格教程 React-Express-Blog-Demo

前言

為什麼我們需要理解並且掌握閉包,且不說大道理,就問你要不要成為JavaScript高手?不要?那你要不要面試找工作嘛。。。

再者,對於任何一個前端er或者JavaScript開發者來說,理解閉包可以看做是另一種意義上的重生。閉包是純函式程式語言的一個特性,因為他大大簡化複雜的操作,所以很容易在一些JavaScript庫以及其他高階程式碼中找到閉包的使用。

一言以蔽之,閉包,你就得掌握。

談談閉包之前,我們先說說作用域

這裡我們要說的作用域值得是詞法作用域。詞法作用域即為定義在詞法階段的作用域。換句話說,就是你寫程式碼時將變數和塊作用域寫在哪裡所決定的。因此在詞法解析的時會保持作用域不變。(JavaScript引擎在執行JavaScript程式碼的時候大致經過分詞/詞法分析、解析/語法分析、程式碼生成三個步驟)。

老規矩,看程式碼(就是程式碼多~~)

function foo(a) {
  var b = a*2;
  function bar(c) {
    console.log(a,b,c);
  }
  bar(b*3);
}
foo(2);//2 4 12
複製程式碼

這個例子中有三個逐級巢狀的作用域,如圖:

還擔心面試官問閉包?
截圖來自《你不知道的JavaScript》

部分一包含整個作用域也就是全域性作用域。其中包含識別符號:foo

部分二包含foo所建立的作用域,其中包含:a,bar和b

部分三包含bar所建立的作用域,其中包含:c

這些作用域氣泡的包含關係給引擎提供了足夠多的位置資訊。在上面的程式碼中,引擎執行console.log的時候,並查詢a,b,c。他首先在最裡面的作用域,也就是bar(...)函式的作用域。引擎無法在這一層作用域中找到變數a,因此引擎會去上一級巢狀作用域foo(...)中查詢,如果找到了,則即使用。

如果a,c 都存在作用域bar(...),foo(...)作用域中,console.log(...)即不需要到foo的外部作用域中去查詢變數。

無論函式在哪裡被呼叫,且無論他們如何被呼叫,他的詞法作用域都只由函式被宣告的位置決定的。詞法作用域查詢只會查詢一級識別符號,比如a,b和c。

簡單理解詞法作用域的概念,其實也就是我們常說的作用域,關於JavaScript中欺騙詞法以及更多關於詞法作用域的介紹,請翻閱《你不知道的JavaScript》。

閉包的概念

說到閉包的概念,這裡還真的比較模糊,我們且看下各種經典書籍給出的概念

《JavaScript權威指南》中的概念

函式物件可以通過作用域鏈互相關聯起來,函式體內部的變數都可以儲存在函式作用域內,這種特性在電腦科學中成為閉包

《JavaScript權威指南》中的概念

閉包是指有權訪問另一個函式作用域中的變數的函式。

《JavaScript忍者祕籍》中的概念

閉包是一個函式在建立時允許該自身函式訪問並操作該自身函式以外的變數時所建立的作用域。

《你不知道的JavaScript》中的概念

閉包是基於詞法作用域書寫程式碼時所產生的自然結果。當函式記住並訪問所在的詞法作用域,閉包就產生了。

個人理解

閉包就是一個函式,一個可以訪問並操作其他函式內部變數的函式。也可以說是一個定義在函式內部的函式。因為JavaScript沒有動態作用域,而閉包的本質是靜態作用域(靜態作用域規則查詢一個變數宣告時依賴的是源程式中塊之間的靜態關係),所以函式訪問的都是我們定義時候的作用域,也就是詞法作用域。所以閉包才會得以實現。

我們常見的閉包形式就是a 函式套 b 函式,然後 a 函式返回 b 函式,這樣 b 函式在 a 函式以外的地方執行時,依然能訪問 a 函式的作用域。其中“b 函式在 a 函式以外的地方執行時”這一點,才體現了閉包的真正的強大之處。

實質性的問題

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

基於詞法作用域和查詢規則,inner函式是可以訪問到outer內部定義的變數a的。從技術上講,這就是閉包。但是也可以說不是,因為用來解釋inner對a的引用方法是詞法作用域的查詢規則,而這些規則只是閉包中的一部分而已。

下面我們將上面的程式碼修改下,讓我們能夠清晰的看到閉包

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

可能是所有講解閉包的部落格中都用爛了的例子了。這裡inner函式被正常呼叫執行,並且可以訪問到outer函式裡定義的變數a。講道理,在outer函式執行後,通常函式整個內部作用域都會被銷燬。

而閉包的神奇之處正是如此可以阻止垃圾回收這種事情的發生,事實上,內部作用域已然存在且拿著a變數,所以沒有被回收。inner函式擁有outer函式內部作用域的閉包,使得該作用域能夠一直存活,以供inner函式在之後的任何時間可以訪問。

inner()已然持有對該作用域的引用,而這個引用就被叫做閉包。

函式在定義時的詞法作用域以外的地方被呼叫,閉包使得函式可以繼續訪問定義時的詞法作用域。

無論通過何種手段將內部函式傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函式都會使用閉包

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

function bar() {
  fn();
}
foo();
bar();
複製程式碼

上面的程式碼不做過多解釋,挺簡單,通過下面的程式碼,我們再說下閉包的三個有趣的概念

var outerValue = 'ninja';
var later;
function outFunction() {
  var innerValue = 'Neal';
  function innerFunction(param){
      console.log(outerValue,innerValue,param,tooLate);
  }
  later = innerFunction;
}
console.log('tooLate is',tooLate);
outFunction();
later('Nealyang');
var tooLate = 'Haha';
later('Neal_yang');

//tooLate is undefined
//ninja Neal Nealyang undefined
//ninja Neal Neal_yang Haha
複製程式碼

上面程式碼執行結果大家可以自行嘗試。總之,從上面的程式碼中,我們可以看到閉包的有趣的三個概念

  • 內部函式的引數包含在閉包中
  • 作用域之外的所有變數、即便是函式宣告之後的那些宣告,也都包含在閉包中.
  • 相同作用域內,尚未宣告的變數,不能進行提前引用

程式碼處處有閉包

function wait(message) {
     setTimeout( function timer() {
         console.log( message ); }, 1000 ); }
wait( "Hello, closure!" );
複製程式碼

如上的程式碼,一個很常見的定時器,但是timer函式具有涵蓋wait作用域的閉包,因為此還保留對變數Message的引用。

wait執行1s後,他的內部作用域並不會消失,timer函式依然保持有wait作用域的閉包。

深入到引擎內部原理中,內建的g工具函式setTimeout持有對一個引數的引用,引擎呼叫這個函式,在例子中就是內部的timer函式,而詞法作用域在這個過程中保持完整。這就是閉包。

無論何時何地,如果將函式作為第一級值型別併到處傳遞,你就會看到閉包在這些函式中的使用。在定時器、事件監聽、Ajax請求、跨視窗通訊或者其他非同步任務中,只要使用回撥函式,就在使用閉包。

在經典的for迴圈中使用閉包

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

如上for迴圈,大家都知道輸出6,畢竟這個作用域中,我們只有一個i,所有的回撥函式都是在這個for迴圈結束以後才執行的。

如果我們試圖假設迴圈中的每一個迭代在執行時都會給自己捕獲一個i的副本,但是根據作用域的工作原理,儘管迴圈中五個函式是在各個迭代中分別定義,但是他們都被封閉在共享的作用域中,因此還是隻有一個i。

所以回到正題,我們需要使用閉包,在每一個迴圈中每一個迭代都讓他產生一個閉包作用域。

所以我們程式碼修改如下:

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

but!!!你也發現了,這樣並不姓,不是IIFE會產生一個閉包的麼?是的沒錯,但是如果這個IIFE產生的閉包作用域是可空的,那麼將它封裝起來又有什麼意義呢?所以它需要點實質性的東西,讓我們去使用。

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

當然,如上問題我們可以使用es6中的let來解決。但是這裡就不做過多說明了。大家可以自行Google。

模組

這個部分比較簡單好理解,因為閉包可以很好形成塊級作用域,對內部變數有很好的隱藏。所以自然我們可以將其作為模組開發的手段。撇開如今的export、import不談

直接看例子就好,操作比較常規

function foo() {
    var something = "cool"; 
    var another = [1, 2, 3];
    function doSomething() {
         console.log( something ); 
    } 
    function doAnother() {
         console.log( another.join( " ! " ) ); 
    }
    return {
        doSomething:doSomething,
        doAnother:doAnother
    }
}
複製程式碼

簡單說明下,doSomething和doAnother函式具有涵蓋模組例項內部作用域的閉包。當通過返回一個含有屬性引用的物件的方式來將函式傳遞到詞法作用域外部,我們已經創造了可以觀察和實踐的 閉包條件。

  • 必須有外部的封閉函式,該函式必須至少被呼叫一次
  • 封閉函式必須返回至少一個內部函式,這樣內部函式才能在私有作用域中形成閉包,並且可以訪問或修改私有的狀態。

當然,上面的程式碼我們還可以寫成IIFE的形式。但是畢竟市場上講解閉包的好文是在太多,這裡我們就點到為止。

相關文章