徹底弄懂閉包

函數(shù)和對其周圍狀態(tài)(lexical environment罩抗,詞法環(huán)境)的引用捆綁在一起構(gòu)成閉包(closure)。也就是說灿椅,閉包可以讓你從內(nèi)部函數(shù)訪問外部函數(shù)作用域套蒂。在 JavaScript 中,每當(dāng)函數(shù)被創(chuàng)建茫蛹,就會在函數(shù)生成時生成閉包操刀。

詞法作用域

請看下面的代碼:

function init() {
    var name = "Mozilla"; // name 是一個被 init 創(chuàng)建的局部變量
    function displayName() { // displayName() 是內(nèi)部函數(shù),一個閉包
        alert(name); // 使用了父函數(shù)中聲明的變量
    }
    displayName();
}
init();

init() 創(chuàng)建了一個局部變量 name 和一個名為 displayName() 的函數(shù)婴洼。displayName() 是定義在 init() 里的內(nèi)部函數(shù)骨坑,并且僅在 init() 函數(shù)體內(nèi)可用。請注意柬采,displayName() 沒有自己的局部變量欢唾。然而,因為它可以訪問到外部函數(shù)的變量粉捻,所以 displayName() 可以使用父函數(shù) init() 中聲明的變量 name 礁遣。

使用這個 JSFiddle 鏈接運行該代碼后發(fā)現(xiàn), displayName() 函數(shù)內(nèi)的 alert() 語句成功顯示出了變量 name 的值(該變量在其父函數(shù)中聲明)肩刃。這個詞法作用域的例子描述了分析器如何在函數(shù)嵌套的情況下解析變量名祟霍。詞法(lexical)一詞指的是,詞法作用域根據(jù)源代碼中聲明變量的位置來確定該變量在何處可用盈包。嵌套函數(shù)可訪問聲明于它們外部作用域的變量沸呐。

閉包

現(xiàn)在來考慮以下例子 :

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();

運行這段代碼的效果和之前 init() 函數(shù)的示例完全一樣。其中不同的地方(也是有意思的地方)在于內(nèi)部函數(shù) displayName() 在執(zhí)行前呢燥,從外部函數(shù)返回崭添。

第一眼看上去,也許不能直觀地看出這段代碼能夠正常運行叛氨。在一些編程語言中呼渣,一個函數(shù)中的局部變量僅存在于此函數(shù)的執(zhí)行期間根暑。一旦 makeFunc() 執(zhí)行完畢,你可能會認(rèn)為 name 變量將不能再被訪問徙邻。然而,因為代碼仍按預(yù)期運行畸裳,所以在 JavaScript 中情況顯然與此不同缰犁。

原因在于,JavaScript中的函數(shù)會形成了閉包怖糊。 閉包是由函數(shù)以及聲明該函數(shù)的詞法環(huán)境組合而成的帅容。該環(huán)境包含了這個閉包創(chuàng)建時作用域內(nèi)的任何局部變量。在本例子中伍伤,myFunc 是執(zhí)行 makeFunc 時創(chuàng)建的 displayName 函數(shù)實例的引用并徘。displayName 的實例維持了一個對它的詞法環(huán)境(變量 name 存在于其中)的引用。因此扰魂,當(dāng) myFunc 被調(diào)用時麦乞,變量 name 仍然可用,其值 Mozilla 就被傳遞到alert中劝评。

下面是一個更有意思的示例 — 一個 makeAdder 函數(shù):

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2));  // 7
console.log(add10(2)); // 12

在這個示例中姐直,我們定義了 makeAdder(x) 函數(shù),它接受一個參數(shù) x 蒋畜,并返回一個新的函數(shù)咕幻。返回的函數(shù)接受一個參數(shù) y仅讽,并返回x+y的值。

從本質(zhì)上講,makeAdder 是一個函數(shù)工廠 — 他創(chuàng)建了將指定的值和它的參數(shù)相加求和的函數(shù)读恃。在上面的示例中,我們使用函數(shù)工廠創(chuàng)建了兩個新函數(shù) — 一個將其參數(shù)和 5 求和低缩,另一個和 10 求和堪旧。

add5add10 都是閉包。它們共享相同的函數(shù)定義辛润,但是保存了不同的詞法環(huán)境膨处。在 add5 的環(huán)境中,x 為 5砂竖。而在 add10 中真椿,x 則為 10。

實用的閉包

閉包很有用乎澄,因為它允許將函數(shù)與其所操作的某些數(shù)據(jù)(環(huán)境)關(guān)聯(lián)起來突硝。這顯然類似于面向?qū)ο缶幊獭T诿嫦驅(qū)ο缶幊讨兄眉茫瑢ο笤试S我們將某些數(shù)據(jù)(對象的屬性)與一個或者多個方法相關(guān)聯(lián)解恰。

因此锋八,通常你使用只有一個方法的對象的地方,都可以使用閉包护盈。

在 Web 中挟纱,你想要這樣做的情況特別常見。大部分我們所寫的 JavaScript 代碼都是基于事件的 — 定義某種行為腐宋,然后將其添加到用戶觸發(fā)的事件之上(比如點擊或者按鍵)紊服。我們的代碼通常作為回調(diào):為響應(yīng)事件而執(zhí)行的函數(shù)。

假如胸竞,我們想在頁面上添加一些可以調(diào)整字號的按鈕欺嗤。一種方法是以像素為單位指定 body 元素的 font-size,然后通過相對的 em 單位設(shè)置頁面中其它元素(例如header)的字號:

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

我們的文本尺寸調(diào)整按鈕可以修改 body 元素的 font-size 屬性卫枝,由于我們使用相對單位煎饼,頁面中的其它元素也會相應(yīng)地調(diào)整。

以下是 JavaScript:

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

size12校赤,size14size16 三個函數(shù)將分別把 body 文本調(diào)整為 12吆玖,14,16 像素马篮。我們可以將它們分別添加到按鈕的點擊事件上衰伯。如下所示:

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;

<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a> 

<iframe frameborder="0" height="200" src="https://jsfiddle.net/cubr4hs0/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>

用閉包模擬私有方法

編程語言中,比如 Java积蔚,是支持將方法聲明為私有的意鲸,即它們只能被同一個類中的其它方法所調(diào)用。

而 JavaScript 沒有這種原生支持尽爆,但我們可以使用閉包來模擬私有方法怎顾。私有方法不僅僅有利于限制對代碼的訪問:還提供了管理全局命名空間的強(qiáng)大能力,避免非核心的方法弄亂了代碼的公共接口部分漱贱。

下面的示例展現(xiàn)了如何使用閉包來定義公共函數(shù)槐雾,并令其可以訪問私有函數(shù)和變量。這個方式也稱為 模塊模式(module pattern):

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }   
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

在之前的示例中幅狮,每個閉包都有它自己的詞法環(huán)境募强;而這次我們只創(chuàng)建了一個詞法環(huán)境,為三個函數(shù)所共享:Counter.increment崇摄,``Counter.decrementCounter.value擎值。

該共享環(huán)境創(chuàng)建于一個立即執(zhí)行的匿名函數(shù)體內(nèi)。這個環(huán)境中包含兩個私有項:名為 privateCounter 的變量和名為 changeBy 的函數(shù)逐抑。這兩項都無法在這個匿名函數(shù)外部直接訪問鸠儿。必須通過匿名函數(shù)返回的三個公共函數(shù)訪問。

這三個公共函數(shù)是共享同一個環(huán)境的閉包。多虧 JavaScript 的詞法作用域进每,它們都可以訪問 privateCounter 變量和 changeBy 函數(shù)汹粤。

你應(yīng)該注意到我們定義了一個匿名函數(shù),用于創(chuàng)建一個計數(shù)器田晚。我們立即執(zhí)行了這個匿名函數(shù)嘱兼,并將他的值賦給了變量Counter。我們可以把這個函數(shù)儲存在另外一個變量makeCounter中贤徒,并用他來創(chuàng)建多個計數(shù)器遭京。

var makeCounter = function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }  
};

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

請注意兩個計數(shù)器 Counter1Counter2 是如何維護(hù)它們各自的獨立性的。每個閉包都是引用自己詞法作用域內(nèi)的變量 privateCounter 泞莉。

每次調(diào)用其中一個計數(shù)器時,通過改變這個變量的值船殉,會改變這個閉包的詞法環(huán)境鲫趁。然而在一個閉包內(nèi)對變量的修改,不會影響到另外一個閉包中的變量利虫。

以這種方式使用閉包挨厚,提供了許多與面向?qū)ο缶幊滔嚓P(guān)的好處 —— 特別是數(shù)據(jù)隱藏和封裝。

在循環(huán)中創(chuàng)建閉包:一個常見錯誤

在 ECMAScript 2015 引入 let 關(guān)鍵字 之前糠惫,在循環(huán)中有一個常見的閉包創(chuàng)建問題疫剃。參考下面的示例:

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp(); 

<iframe frameborder="0" height="200" src="https://jsfiddle.net/51brm6ps/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>

數(shù)組 helpText 中定義了三個有用的提示信息,每一個都關(guān)聯(lián)于對應(yīng)的文檔中的input 的 ID硼讽。通過循環(huán)這三項定義巢价,依次為相應(yīng)input添加了一個 onfocus 事件處理函數(shù),以便顯示幫助信息固阁。

運行這段代碼后壤躲,您會發(fā)現(xiàn)它沒有達(dá)到想要的效果。無論焦點在哪個input上备燃,顯示的都是關(guān)于年齡的信息碉克。

原因是賦值給 onfocus 的是閉包。這些閉包是由他們的函數(shù)定義和在 setupHelp 作用域中捕獲的環(huán)境所組成的并齐。這三個閉包在循環(huán)中被創(chuàng)建漏麦,但他們共享了同一個詞法作用域,在這個作用域中存在一個變量item况褪。這是因為變量item使用var進(jìn)行聲明撕贞,由于變量提升,所以具有函數(shù)作用域测垛。當(dāng)onfocus的回調(diào)執(zhí)行時麻掸,item.help的值被決定。由于循環(huán)在事件觸發(fā)之前早已執(zhí)行完畢赐纱,變量對象item(被三個閉包所共享)已經(jīng)指向了helpText的最后一項脊奋。

解決這個問題的一種方案是使用更多的閉包:特別是使用前面所述的函數(shù)工廠:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
  return function() {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp(); 

<iframe frameborder="0" height="300" src="https://jsfiddle.net/v7gjv/12185/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>

這段代碼可以如我們所期望的那樣工作熬北。所有的回調(diào)不再共享同一個環(huán)境, makeHelpCallback 函數(shù)為每一個回調(diào)創(chuàng)建一個新的詞法環(huán)境诚隙。在這些環(huán)境中讶隐,help 指向 helpText 數(shù)組中對應(yīng)的字符串。

另一種方法使用了匿名閉包:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    (function() {
       var item = helpText[i];
       document.getElementById(item.id).onfocus = function() {
         showHelp(item.help);
       }
    })(); // 馬上把當(dāng)前循環(huán)項的item與事件回調(diào)相關(guān)聯(lián)起來
  }
}

setupHelp();

如果不想使用過多的閉包久又,你可以用ES2015引入的let關(guān)鍵詞:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    let item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp();

這個例子使用let而不是var巫延,因此每個閉包都綁定了塊作用域的變量,這意味著不再需要額外的閉包地消。

另一個可選方案是使用 forEach()來遍歷helpText數(shù)組并給每一個[<p>](https://wiki.developer.mozilla.org/en-US/docs/Web/HTML/Element/p "The HTML <p> element represents a paragraph.")添加一個監(jiān)聽器炉峰,如下所示:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  helpText.forEach(function(text) {
    document.getElementById(text.id).onfocus = function() {
      showHelp(text.help);
    }
  });
}

setupHelp();

性能考量

如果不是某些特定任務(wù)需要使用閉包,在其它函數(shù)中創(chuàng)建函數(shù)是不明智的脉执,因為閉包在處理速度和內(nèi)存消耗方面對腳本性能具有負(fù)面影響疼阔。

例如,在創(chuàng)建新的對象或者類時半夷,方法通常應(yīng)該關(guān)聯(lián)于對象的原型婆廊,而不是定義到對象的構(gòu)造器中。原因是這將導(dǎo)致每次構(gòu)造器被調(diào)用時巫橄,方法都會被重新賦值一次(也就是說淘邻,對于每個對象的創(chuàng)建,方法都會被重新賦值)湘换。

考慮以下示例:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function() {
    return this.name;
  };

  this.getMessage = function() {
    return this.message;
  };
}

在上面的代碼中宾舅,我們并沒有利用到閉包的好處,因此可以避免使用閉包彩倚。修改成如下:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName: function() {
    return this.name;
  },
  getMessage: function() {
    return this.message;
  }
};

但我們不建議重新定義原型贴浙。可改成如下例子:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function() {
  return this.name;
};
MyObject.prototype.getMessage = function() {
  return this.message;
};

在前面的兩個示例中署恍,繼承的原型可以為所有對象共享崎溃,不必在每一次創(chuàng)建對象時定義方法。參見 對象模型的細(xì)節(jié) 一章可以了解更為詳細(xì)的信息盯质。

該文章轉(zhuǎn)自MDN

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末袁串,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子呼巷,更是在濱河造成了極大的恐慌囱修,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,539評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件王悍,死亡現(xiàn)場離奇詭異破镰,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評論 3 396
  • 文/潘曉璐 我一進(jìn)店門鲜漩,熙熙樓的掌柜王于貴愁眉苦臉地迎上來源譬,“玉大人,你說我怎么就攤上這事孕似〔饶铮” “怎么了?”我有些...
    開封第一講書人閱讀 165,871評論 0 356
  • 文/不壞的土叔 我叫張陵喉祭,是天一觀的道長养渴。 經(jīng)常有香客問我,道長泛烙,這世上最難降的妖魔是什么理卑? 我笑而不...
    開封第一講書人閱讀 58,963評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮蔽氨,結(jié)果婚禮上藐唠,老公的妹妹穿的比我還像新娘。我一直安慰自己孵滞,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,984評論 6 393
  • 文/花漫 我一把揭開白布鸯匹。 她就那樣靜靜地躺著坊饶,像睡著了一般。 火紅的嫁衣襯著肌膚如雪殴蓬。 梳的紋絲不亂的頭發(fā)上匿级,一...
    開封第一講書人閱讀 51,763評論 1 307
  • 那天,我揣著相機(jī)與錄音染厅,去河邊找鬼痘绎。 笑死,一個胖子當(dāng)著我的面吹牛肖粮,可吹牛的內(nèi)容都是我干的孤页。 我是一名探鬼主播,決...
    沈念sama閱讀 40,468評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼涩馆,長吁一口氣:“原來是場噩夢啊……” “哼行施!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起魂那,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤蛾号,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后涯雅,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鲜结,經(jīng)...
    沈念sama閱讀 45,850評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,002評論 3 338
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了精刷。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片拗胜。...
    茶點故事閱讀 40,144評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖贬养,靈堂內(nèi)的尸體忽然破棺而出挤土,到底是詐尸還是另有隱情,我是刑警寧澤误算,帶...
    沈念sama閱讀 35,823評論 5 346
  • 正文 年R本政府宣布仰美,位于F島的核電站,受9級特大地震影響儿礼,放射性物質(zhì)發(fā)生泄漏咖杂。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,483評論 3 331
  • 文/蒙蒙 一蚊夫、第九天 我趴在偏房一處隱蔽的房頂上張望诉字。 院中可真熱鬧,春花似錦知纷、人聲如沸壤圃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽伍绳。三九已至,卻和暖如春乍桂,著一層夾襖步出監(jiān)牢的瞬間冲杀,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評論 1 272
  • 我被黑心中介騙來泰國打工睹酌, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留权谁,地道東北人。 一個月前我還...
    沈念sama閱讀 48,415評論 3 373
  • 正文 我出身青樓憋沿,卻偏偏與公主長得像旺芽,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子辐啄,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,092評論 2 355