1. 程式人生 > 其它 >JavaScript中 函式閉包詳解

JavaScript中 函式閉包詳解

技術標籤:JavaScript前端javascript

1. 變數作用域

理解閉包,首先必須理解變數作用域,在ECMAScript5的標準中有兩種作用域:全域性作用域和函式作用域
兩者的呼叫關係是:

  • 函式內部可以直接讀取全域性變數;
  • 正常情況下,函式外部無法讀取函式內部宣告的變數;
let num = 1;

function test() {
  let n = 2;
  console.log(num); // 1
}

test();  
console.log(n); // ReferenceError: n is not defined

實際開發中會出於各種原因,我們必須得拿到函式內部的區域性變數。

JavaScript 語言規定:父物件的所有變數,對子物件都是可見的,反之則不成立。即"鏈式作用域"結構(chain scope)
基於這一點,我們就可以在目標函式內再定義一個函式,這個子函式就可以正常訪問其父函式的內部變數。

function parent() {
  let n = 1;
  function child() {
  console.log(n); // 1
  }
}

既然子函式可以拿到父函式的區域性變數,那麼父函式直接返回這個子函式,不就達到了在全域性作用域下訪問函式內部變數的目的了。

function parent() {
  let n = 1
; function child() {   console.log(n); // 1 }; return child; } let f1 = parent(); f1();

2. 閉包的概念及特性

上述的例子就是一個最簡單的閉包的寫法:函式 child 就是閉包,所以閉包就是一個“定義在函式內部的函式”。 在本質上,閉包就是一座連線函式內外的橋樑。

閉包本身還具有以下幾點重要的特性:

  • 函式內巢狀函式;
  • 閉包內可以訪問其外層函式的內部引數,變數或方法;
  • 閉包內用到的引數和變數會始終儲存在記憶體中,不會在函式呼叫結束後,被垃圾回收機制回收;
  • 同一個閉包機制可以創建出多個閉包函式例項,它們彼此獨立,互不影響;

3. 閉包的經典寫法

3.1 函式作為返回值

上述的例子還可以進一步精簡為匿名函式的寫法:
通過匿名函式訪問其外層函式的內部變數 num,然後外層函式返回該匿名函式,該匿名函式繼續返回 num 變數。

function closure1(){
  let num = 1;
  return function(){
    return num
  }
}

let fn1 = closure1();
console.log(fn1()); // 1

這樣就可以在全域性作用域下宣告一個變數 fn1 來承接 num 變數,這樣就達到了在全域性作用域訪問函式內區域性變數的目的。

3.1.1 儲存變數
閉包在可以讀取函式內區域性變數的同時,它還可以讓這些變數始終儲存在記憶體中,不會在函式呼叫結束後,被垃圾回收機制回收。
比如這個例子:

function closure2(){
  let num = 2;
  return function(){
    let n = 0;
    console.log(n++,num++);
  }
}

let fn2 = closure2();
fn2();  // 0 2
fn2();  // 0 3

執行兩次函式例項 fn2(),可以看到結果是略有差異的:

  • n++ 兩次輸出一致:
    變數 n 是匿名函式的內部變數,在匿名函式呼叫結束後,它這塊記憶體空間就會被正常釋放,即被垃圾回收機制回收。

  • num++ 兩次輸出不一致:
    匿名函式內引用了其外層函式的區域性變數 num,即使匿名函式的呼叫結束了,但是這種依賴關係依然存在,所以變數 num 就無法被銷燬。一直儲存在記憶體中 匿名函式下次呼叫時,就會繼續沿用上次的呼叫結果。

利用閉包的這一特性,確實可以做簡單的資料快取。
但是也不能濫用閉包,這樣很容易使記憶體消耗增大,進而導致記憶體洩漏或者網頁的效能問題。

3.1.2 多個閉包函式彼此獨立

同一個閉包機制可以創建出多個閉包函式例項,它們彼此獨立,互不影響。
比如下面這個簡單的例子:

function fn(num){
  return function(){
    return num++
  }
}

我們分別宣告三個閉包函式例項,分別傳入不同的引數。然後分別執行1,2,3次:

function fn(num){
  return function(){
    return num++
  }
}

let f1 = fn(10);
let f2 = fn(20);
let f3 = fn(30);

console.log(f1())  // 10
console.log(f2())  // 20
console.log(f2())  // 21
console.log(f3())  // 30
console.log(f3())  // 31
console.log(f3())  // 32

可以看到:f1(),f2(),f3()的第一次執行都輸出了10,多執行的也是在自身上次執行的結果上累加的,互相之間沒有影響。

3.2 立即執行函式(IIFE)

上一種寫法中函式只是作為返回值返回,而具體的函式呼叫是寫在其他地方。那麼我們能不能讓外層函式直接返回閉包的呼叫結果呢?
答案當然是可以的:採用立即執行函式(IIFE)的寫法
接下來就先了解一下具體什麼是立即執行函式(IIFE):
我們都知道,在 JavaScript中呼叫函式最常用的方法就是函式名之後跟圓括號()。有時,我們需要在定義函式之後,立即呼叫該函式。但是你不能直接在函式定義之後加上圓括號,這樣會產生語法錯誤。

// 提示語法錯誤
function funcName(){}();

產生錯誤的原因是,function 關鍵字既可以當作語句,也可以當作表示式。

// 語句
function f() {}

// 表示式
var f = function f() {}

當作表示式時,函式可以定義後直接加圓括號呼叫。

var f = function f(){ return 1}();
console.log(f) // 1

為了避免解析的歧義,JavaScript 規定,如果 function 關鍵字出現在行首,一律解釋成語句。那麼如果我們還想用 function 關鍵字宣告函式後能立即呼叫,就需要讓 function 不直接出現在行首,讓引擎將其理解成一個表示式。
最簡單的處理,就是將其放在一個圓括號裡面。

(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

這就叫做“立即呼叫的函式表示式”(Immediately-Invoked Function Expression),即立即執行函式,簡稱 IIFE 。

3.2.1 定時器 setTimeout 的經典迴圈輸出問題
瞭解過立即執行函式後,趕緊來看一個例項:使用for迴圈依次輸出1~5。那麼如果是下面的程式碼,它的執行結果是什麼?

for (var i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i); // 6 6 6 6 6
  }, 1000 );
} 

結果肯定是輸出5個6。原因是 for 迴圈屬於同步任務,setTimeout 定時器屬於非同步任務的巨集任務範疇。JavaScript 引擎會優先執行同步的主執行緒程式碼,再去執行巨集任務。

所以在執行 setTimeout 定時器之前,for 迴圈就已經結束了,此時迴圈變數 i = 6。然後 setTimeout 定時器被迴圈建立了 5 次,全部執行完畢也就輸出了5個6。

但是我們的目的是希望輸出1~5,這樣顯然沒達到要求。在正式介紹立即執行函式(IIFE)的寫法之前,我先說另外一種方法:迴圈變數 i 使用let關鍵字宣告。

for (let i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i); // 1 2 3 4 5
  }, 1000 );
}

為什麼換成let宣告之後就可以呢?是因為要想實現1~5迴圈輸出的本質要求是記住每次迴圈時迴圈變數的值
而 let 的宣告方式恰好就可以滿足。傳送門:JavaScript中 var、let、const 特性及區別詳解

這樣再來看立即執行函式(IIFE)的寫法:

for (var i = 1; i <= 5; i++) {
  (function(i){
    setTimeout( function timer() {
      console.log(i); // 1 2 3 4 5
    }, 1000 );
  })(i);
}

把 setTimeout 定時器函式用一個外層匿名函式包裹構成閉包的形式,然後再採用立即執行函式(IIFE)的寫法:繼續用圓括號包裹外層匿名函式,然後跟上圓括號呼叫,並把每次的迴圈變數作為引數傳入。
這樣每次迴圈的結果就是閉包的呼叫結果:輸出 i 的值;再根據閉包本身的特性之一:可以儲存變數或引數,就滿足了所有條件從而正確輸出了1~5。

再多說一點,目前的輸出形式是一秒後同時輸出1~5;那我想這五個數字每隔一秒再輸出一個呢?

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

可以控制每個setTimeout定時器的第二個引數:間隔時長,依次乘上迴圈變數 i 即可。
效果如下:
在這裡插入圖片描述
3.2.2 函式作為API的形參傳入
閉包結合立即執行函式(IIFE) 的這種機制還有一類很重要的用處是:需要函式作為形參的各種API。
以陣列的 sort() 方法為例:Array.prototype.sort() 方法中支援傳入一個比較器函式,來讓我們自定義排序的規則。該比較器函式必須要有返回值,推薦返回 Number 型別。

比如以下的陣列場景:我們希望你能編寫一個 mySort() 方法:可以按照指定的任意屬性值降序排列陣列元素。
mySort() 方法肯定需要兩個形參:需要排序的陣列 arr 和指定的屬性值 property。
另外用到的 API 肯定還是 sort() 方法,這裡我們就不能直接傳入一個比較器函式,而是採用閉包的IIFE寫法:
屬性值 property 作為引數傳入外層匿名函式,然後匿名函式內部返回最終 sort() 方法需要的比較器函式。

var arr = [
  {name:"code",age:19,grade:92},
  {name:"zevi",age:12,grade:94},
  {name:"jego",age:15,grade:95},
];

function mySort(arr,property){
  arr.sort((function(prop){
    return function(a,b){
       return a[prop] > b[prop] ? -1 : a[prop] < b[prop] ? 1 : 0;
    }
  })(property));
};


mySort(arr,"grade");
console.log(arr); 
/*
[
  {name:"jego",age:15,grade:95},
  {name:"zevi",age:12,grade:94},
  {name:"code",age:19,grade:92},
]
*/

3.3 封裝物件的私有屬性和私有方法

閉包同時也可以用於物件的封裝,尤其是封裝物件的私有屬性和私有方法:
我們封裝了一個物件 Person,它擁有一個公共屬性 name,一個私有屬性 _age 和兩個私有方法。
我們不能直接訪問和修改私有屬性 _age,必須通過呼叫其內部的閉包 getAge 和 setAge。

function Person(name) {
  var _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}


var p1 = Person('zevin');
p1.setAge(22);
console.log(p1.getAge()); // 22

4. 使用閉包的優缺

4.1 優點

4.1.1 實現封裝,保護函式內的變數安全
採用閉包的寫法可以把變數儲存在記憶體中,不會被系統的垃圾回收機制銷燬,從而起到了保護變數的作用。

function closure2(){
  let num = 1;
  return function(){
    console.log(num++)
  }
}

let fn2 = closure2();
fn2();  // 1
fn2();  // 2

4.1.2 避免全域性變數的汙染
開發中應該儘量避免使用全域性變數,防止不必要的命名衝突和呼叫錯亂

// 報錯
var num = 1;
function test(num){
  console.log(num)
}

test();
let num = test(4);
console.log(num);

這時就可以選擇把變數宣告在函式內部,並採用閉包的機制。
這樣既能保證變數的正常呼叫,又可以避免全域性變數的汙染。

function test(){
  let num = 1;
  return function(){
    return num
  }
}

let fn = test();
console.log(fn());

4.2 缺點

4.2.1 記憶體消耗和記憶體洩漏
外層函式每次執行,都會生成一個新的閉包,而這個閉包又會保留外層函式的內部變數,所以記憶體消耗很大。
解決方法:不濫用閉包。
同時閉包中引用的內部變數會被儲存,得不到釋放,從而也造成了記憶體洩漏的問題。
解決方法:

window.onload = function(){
  var userInfor = document.getElementById('user');
  var id = userInfor.id;
  oDiv.onclick = function(){
    alert(id);
  }
  userInfor = null;
}

在內部閉包使用變數 userInfor 之前,先用一個其他的變數id 來承接一下,並且使用完變數 userInfor 後手動為它賦值為 null。