JavaScript中 函數閉包詳解

這是我參與8月更文挑戰的第4天,活動詳情查看:8月更文挑戰javascript

1. 變量做用域

理解閉包,首先必須理解變量做用域,在ECMAScript5的標準中有兩種做用域:全局做用域和函數做用域。 二者的調用關係是:java

  • 函數內部能夠直接讀取全局變量;
  • 正常狀況下,函數外部沒法讀取函數內部聲明的變量;
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 就是閉包,因此閉包就是一個「定義在函數內部的函數」。 在本質上,閉包就是一座鏈接函數內外的橋樑。markdown

閉包自己還具備如下幾點重要的特性:閉包

  • 函數內嵌套函數;
  • 閉包內能夠訪問其外層函數的內部參數,變量或方法;
  • 閉包內用到的參數和變量會始終保存在內存中,不會在函數調用結束後,被垃圾回收機制回收;
  • 同一個閉包機制能夠建立出多個閉包函數實例,它們彼此獨立,互不影響;

3. 閉包的經典寫法

3.1 函數做爲返回值異步

上述的例子還能夠進一步精簡爲匿名函數的寫法: 經過匿名函數訪問其外層函數的內部變量 num,而後外層函數返回該匿名函數,該匿名函數繼續返回 num 變量。函數

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

let fn1 = closure1();
console.log(fn1()); // 1
複製代碼

這樣就能夠在全局做用域下聲明一個變量 fn1 來承接 num 變量,這樣就達到了在全局做用域訪問函數內局部變量的目的。oop

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 20 30,多執行的也是在自身上次執行的結果上累加的,互相之間沒有影響。

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),即當即執行函數 簡稱IFE 。

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。

相關文章
相關標籤/搜索