JavaScript之作用域和閉包

keywords發表於2019-02-16

一、作用域

  1. 作用域共有兩種主要的工作模型:第一種是最為普遍的,被大多數程式語言所採用的詞法作用域,另外一種叫作動態作用域;
  2. JavaScript所採用的作用域模式是詞法作用域。

1.詞法作用域

  1. 詞法作用域意味著作用域是由書寫程式碼時函式宣告的位置來決定的。編譯的詞法分析階段基本能夠知道全部識別符號在哪裡以及是如何宣告的,從而能夠預測在執行過程中如何對它們進行查詢。
  2. JavaScript 中有兩個機制可以“欺騙”詞法作用域:

    • eval(..):可以對一段包含一個或多個宣告的“程式碼”字串進行演算,並藉此來修改已經存在的詞法作用域(在執行時) ;
    • with:通過將一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,從而建立了一個新的詞法作用域(同樣是在執行時) 。
    • 這兩個機制的副作用是引擎無法在編譯時對作用域查詢進行優化,因為引擎只能謹慎地認為這樣的優化是無效的。使用這其中任何一個機制都將導致程式碼執行變慢。

2.函式作用域和塊級作用域

  1. 函式作用域: 函式是 JavaScript 中最常見的作用域單元。本質上,宣告在一個函式內部的變數或函式會在所處的作用域中“隱藏”起來,即函式內定於的函式和變數為該函式私有;
  2. 塊級作用域:

    • 塊作用域指的是變數和函式不僅可以屬於所處的作用域,也可以屬於某個程式碼塊(通常指 { .. } 內部)
    • ES6前在JavaScript中並不存在塊級作用域( 例外:try/catch 結構在 catch 分句中具有塊作用域);
    • 在 ES6 中引入了 let 關鍵字( var 關鍵字的表親) ,用來在任意程式碼塊中宣告變數。 if(..) { let a = 2; } 會宣告一個劫持了 if 的 { .. } 塊的變數,並且將變數新增到這個塊中(另外常量定義const也具有塊級作用域)。

3.函式和變數的提升

(1)、提升

  1. 函式作用域和塊作用域的行為是一樣的,即,某個作用域內的變數,都將附屬於這個作用域。
  2. 引擎會在解釋 JavaScript 程式碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的宣告,並用合適的作用域將它們關聯起來;
  3. 因此包括變數和函式在內的所有宣告都會在任何程式碼被執行前首先被處理;
  4. 當看到 var a = 2; 時,可能會認為這是一個宣告。但 JavaScript 實際上會將其看成兩個宣告: var a; 和 a = 2; 。第一個定義宣告是在編譯階段進行的。第二個賦值宣告會被留在原地等待執行階段。

    • 這個過程就好像變數和函式宣告從它們在程式碼中出現的位置被“移動”到了最上面。這個過程就叫作提升
  5. 每個作用域都會進行提升操作;

(2)、函式優先

  1. 函式宣告和變數宣告都會被提升。但是函式會首先被提升,然後才是變數。
foo(); // 1
var foo;
function foo() {
    console.log( 1 );
}
foo = function() {
    console.log( 2 );
};
  • 會輸出 1 而不是 2 !這個程式碼片段會被引擎理解為如下形式:
function foo() {
    console.log( 1 );
}
foo(); // 1
foo = function() {
    console.log( 2 );
};
  • var foo 儘管出現在 function foo()… 的宣告之前,但它是重複的宣告(因此被忽略了) ,因為函式宣告會被提升到普通變數之前。
  • 儘管重複的 var 宣告會被忽略掉,但出現在後面的函式宣告還是可以覆蓋前面的。

二、作用域閉包

(1)、理解閉包

  • 當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。
  1. 在Javascript語言中,只有函式內部的子函式才能讀取區域性變數,因此可以把閉包簡單理解成”定義在一個函式內部的函式”。
  2. 在本質上,閉包就是將函式內部和函式外部連線起來的一座橋樑

(2)、閉包的用途

  1. 可以讀取函式內部的變數;
  2. 讓變數的值始終保持在記憶體中。

(3)、閉包的產生例項

  1. 可以讀取函式內部的變數
function foo() {
var a = 2;
function bar() {
    console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 這就是閉包的效果。
  • 在 foo() 執行後,通常會期待 foo() 的整個內部作用域都被銷燬,因為我們知道引擎有垃圾回收器用來釋放不再使用的記憶體空間;
  • 閉包的“神奇”之處正是可以阻止這件事情的發生。事實上內部作用域依然存在,因此沒有被回收,因為 bar() 本身在使用;
  • 拜 bar() 所宣告的位置所賜,它擁有涵蓋 foo() 內部作用域的閉包,使得該作用域能夠一直存活,以供 bar() 在之後任何時間進行引用。
  • bar() 依然持有對該作用域的引用,而這個引用就叫作閉包。
  1. 迴圈和閉包:
for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}
  • 正常情況下,我們對這段程式碼行為的預期是分別輸出數字 1~5,每秒一次,每次一個。但實際上,這段程式碼在執行時會以每秒一次的頻率輸出五次 6:

    • 延遲函式的回撥會在迴圈結束時才執行。事實上,當定時器執行時即使每個迭代中執行的是 setTimeout(.., 0) ,所有的回撥函式依然是在迴圈結束後才會被執行,因此會每次輸出一個 6 出來。
    • 實際情況是儘管迴圈中的五個函式是在各個迭代中分別定義的,但是它們都被封閉在一個共享的全域性作用域中,因此實際上只有一個 i,即所有函式共享一個 i 的引用 。
  • 解決方案:使用 IIFE在每次迭代中將本次迭代的i傳入建立的作用域並封閉起來;
for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })( i );
}
  • 在迭代內使用 IIFE 會為每個迭代都生成一個新的作用域,使得延遲函式的回撥可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變數供我們訪問。

(4)、使用閉包的注意點

  1. 由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。

    • 解決方案:在退出函式之前,將不使用的區域性變數全部刪除。
  2. 閉包會在父函式外部,改變父函式內部變數的值。所以,如果把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

相關文章