一文搞定閉包原理

前言

閉包是JS中重要的內(nèi)容,對大多數(shù)人來說都會覺的閉包本身很好理解,不就是一個函數(shù)嵌套一個函數(shù)嗎?但是再深入解釋時,好像不知道要說些啥。不用擔(dān)心,相信看完這篇你對閉包的理解就不僅僅只停留在概念層面上了。

基本概念

1、閉包是什么?

官方對閉包的解釋是:一個擁有許多變量和綁定了這些變量的環(huán)境的表達式(通常是一個函數(shù)),因而這些變量也是該表達式的一部分。

通俗的解釋是:閉包就是能夠讀取其他函數(shù)內(nèi)部變量的函數(shù)。

更清晰的講:閉包就是一個函數(shù),這個函數(shù)能夠訪問其他函數(shù)的作用域中的變量。

JS為什么使用閉包?

因為JS中變量的作用域分為全局變量和局部變量。在函數(shù)外部無法讀取函數(shù)內(nèi)的局部變量。需要閉包來解決。

閉包帶來的問題?

濫用閉包,會造成內(nèi)存泄漏;由于閉包會使得函數(shù)中的變量都被保存在內(nèi)存中,內(nèi)存消耗很大,所以不能濫用閉包,否則會造成網(wǎng)頁的性能問題,在IE中可能導(dǎo)致內(nèi)存泄露。解決方法是,在退出函數(shù)之前,將不使用的局部變量指向null

閉包相關(guān)概念

在了解閉包之前,先來了解作用域、執(zhí)行上下文、變量對象、活動對象、作用域鏈,這些將有助于對閉包的理解。

作用域(Scope)

作用域是一套規(guī)則,用于確定在何處以及如何查找變量(標(biāo)識符)。

1、作用域分為

  • 全局作用域
  • 函數(shù)作用域(ES6新增了塊級作用域)

PS:這些相信很多人都知道,就不詳細舉例了

2、作用域共有兩種主要的工作模型

  • 詞法(靜態(tài))作用域:作用域是在編寫代碼的時候確定的
  • 動態(tài)作用域:作用域是在代碼運行的時候確定的

我們知道Javascript使用的是詞法(靜態(tài))作用域。

3、詞法(靜態(tài))作用域

理解靜態(tài)作用域之前,首先要先了解Js在編譯階段做了些什么事情。

Js編譯階段分為三個階段,下面概括一下三個階段:

1. 分詞/詞法分析(Tokenizing/Lexing)

其實我們寫的代碼就是字符串,在編譯的第一個階段里,把這些字符串轉(zhuǎn)成詞法單元(toekn)。

2. 解析/語法分析(Parsing)

在有了詞法單元之后,JS還需要繼續(xù)分解代碼中的語法以便為JS引擎減輕負擔(dān),通過詞法單元生成了一個抽象語法樹(Abstract Syntax Tree),它的作用是為JS引擎構(gòu)造出一份程序語法樹,我們簡稱為AST

3. 代碼生成(raw code)

這個階段主要做的就是拿AST來生成一份JS語言內(nèi)部認(rèn)可的代碼。

靜態(tài)作用域是發(fā)生在編譯階段的第一個步驟當(dāng)中,也就是分詞/詞法分析階段。它有兩種可能,分詞和詞法分析,分詞是無狀態(tài)的,而詞法分析是有狀態(tài)的。

那我們?nèi)绾闻袛嘤袩o狀態(tài)呢?以var a = 1為例。

  • 如果詞法單元生成器在判斷a是否為一個獨立的詞法單元時,調(diào)用的是有狀態(tài)的解析規(guī)則(生成器不清楚它是否依賴于其他詞法單元,所以要進一步解析)。
  • 如果它不用生成器判斷,是一條不用被賦予語意的代碼(暫時可以理解為不涉及作用域的代碼,因為js內(nèi)部定義什么樣的規(guī)則我們并不清楚),那就被列入分詞中了。

總的來說,如果詞法單元生成器拿不準(zhǔn)當(dāng)前詞法單元是否為獨立的,就進入詞法分析,否則就進入分詞階段。

簡單的說,詞法作用域就是定義在詞法階段的作用域。詞法作用域就是你編寫代碼時,變量和塊級作用域?qū)懺谀睦餂Q定的。當(dāng)詞法解析器處理代碼時,會保持作用域不變(除動態(tài)作用域)。

執(zhí)行上下文(Execution Contexts)

1、Javascript中代碼的執(zhí)行上下文分為以下三種

  • 全局級別的代碼 – 這個是默認(rèn)的代碼運行環(huán)境,一旦代碼被載入,引擎最先進入的就是這個環(huán)境。
  • 函數(shù)級別的代碼 – 當(dāng)執(zhí)行一個函數(shù)時,運行函數(shù)體中的代碼。
  • Eval的代碼 – 在Eval函數(shù)內(nèi)運行的代碼。

2、一個執(zhí)行的上下文可以抽象的理解為一個對象。每一個執(zhí)行的上下文都有一系列的屬性:

  • 變量對象(variable object)
  • this指針(this value)
  • 作用域鏈(scope chain)

代碼表示如下:

Execution Contexts = {
    variable object:變量對象/活動對象;
    this value: this指針;
    scope chain:作用域鏈;
}

3、如何管理執(zhí)行上下文

Js的運行采用的方式對執(zhí)行上下文進行管理,棧底始終是全局上下文,棧頂始終是正在被調(diào)用執(zhí)行的函數(shù)的執(zhí)行上下文。

實例

var a = 10;
var b = 'hello';

function fun1 () {
    console.log('i am fun1...');
    fun2();
}

function fun2 () {
    console.log('i am fun2...');
}

fun1();

上面代碼具體流程是:

  1. 當(dāng)Js文件開始執(zhí)行時,創(chuàng)建全局上下文,并pushcall stack
  2. fun1()被調(diào)用時,創(chuàng)建fun1上下文,pushcall stack。
  3. fun2()被調(diào)用時,創(chuàng)建fun2上下文,pushcall stack。
  4. fun2()執(zhí)行完畢,fun2上下文pop出棧,等待被回收。
  5. fun1()執(zhí)行完畢,fun1上下文pop出棧,等待被回收。
  6. 全局執(zhí)行環(huán)境不會出棧。

4、執(zhí)行環(huán)境的生命周期

執(zhí)行上下文生命周期分為創(chuàng)建階段、執(zhí)行階段、執(zhí)行完畢。如下圖:

image

執(zhí)行上下文是代碼執(zhí)行的一種抽象,而代碼執(zhí)行除了整個Js開始執(zhí)行之外,代碼的執(zhí)行都是通過函數(shù)調(diào)用執(zhí)行的,所以執(zhí)行上下文生命周期的各個階段其實是可以分別對應(yīng)函數(shù)被調(diào)用時的初始化、執(zhí)行、執(zhí)行完畢階段的。下面會詳細的解釋每個階段的過程。

變量對象(variable object)

1、變量對象的定義:

如果變量與執(zhí)行上下文相關(guān),那變量自己應(yīng)該知道它的數(shù)據(jù)存儲在哪里,并且知道如何訪問。這種機制稱為變量對象(variable object)。

2、變量對象的作用:

可以說變量對象是與執(zhí)行上下文相關(guān)的數(shù)據(jù)作用域(scope of data) 。它是與執(zhí)行上下文關(guān)聯(lián)的特殊對象,用于存儲被定義在執(zhí)行上下文中的變量(variables)、函數(shù)聲明(function declarations) 、arguments。

3、變量對象的創(chuàng)建過程:

實例

function add(num){
    var sum = 5;
    return sum + num;
}
var sum = add(4);

根據(jù)上面代碼,創(chuàng)建變量對象的流程是:

  1. 檢查當(dāng)前執(zhí)行環(huán)境上的參數(shù)列表,建立Arguments對象,并作為add VOarguments屬性值。
  2. 檢查當(dāng)前執(zhí)行環(huán)境上的function函數(shù)聲明,每檢查到一個函數(shù)聲明,就在變量對象中以函數(shù)名建立一個屬性,屬性指向函數(shù)所在的內(nèi)存地址。
  3. 檢查當(dāng)前執(zhí)行環(huán)境上的所有var變量聲明。每檢查到一個var聲明,如果VO中已存在function屬性名則跳過,如果沒有就在變量對象中以變量名新建一個屬性,屬性值為undefined。

當(dāng)進入全局上下文時,全局上下文的變量對象可表示為:

VO = {
    add: <reference to function>,
    sum: undefined,
    Math: <...>,
    String: <...>
    ...
    window: global //引用自身
}

活動對象(Activation Object)

當(dāng)函數(shù)被調(diào)用者激活時,這個特殊的活動對象(activation object) 就被創(chuàng)建了。它包含普通參數(shù)(formal parameters) 與特殊參數(shù)(arguments)對象(具有索引屬性的參數(shù)映射表)。活動對象在函數(shù)上下文中作為變量對象使用。

image

根據(jù)上圖,簡單解釋:在沒有執(zhí)行當(dāng)前環(huán)境之前,變量對象中的屬性都不能訪問!但是進入執(zhí)行階段之后,變量對象轉(zhuǎn)變?yōu)榱嘶顒訉ο?,里面的屬性都能被訪問了,然后開始進行執(zhí)行階段的操作。所以活動對象實際就是變量對象在真正執(zhí)行時的另一種形式。

根據(jù)上面變量對象的實例。當(dāng)add函數(shù)被調(diào)用時,add函數(shù)執(zhí)行上下文被壓入執(zhí)行上下文堆棧的頂端,add函數(shù)執(zhí)行上下文中活動對象可表示為

AO = {
    num: 4,
    sum: 5,
    arguments:{0:4}
}

作用域鏈 (Scope Chain)

函數(shù)上下文的作用域鏈在函數(shù)調(diào)用時創(chuàng)建的,包含活動對象AO和這個函數(shù)內(nèi)部的[[scope]]屬性。

實例

var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
  bar();
}
foo(); 

在這段代碼中我們看到變量y在函數(shù)foo中定義(意味著它在foo上下文的AO中)z在函數(shù)bar中定義,但是變量x并未在bar上下文中定義,相應(yīng)地,它也不會添加到barAO中。乍一看,變量x相對于函數(shù)bar根本就不存在。

函數(shù)bar如何訪問到變量x?理論上函數(shù)應(yīng)該能訪問一個更高一層上下文的變量對象。實際上它正是這樣,這種機制是通過函數(shù)內(nèi)部的[[scope]]屬性來實現(xiàn)的。
[[scope]]是所有父級變量對象的層級鏈,處于當(dāng)前函數(shù)上下文之上,在函數(shù)創(chuàng)建時存于其中。

根據(jù)上面代碼我們逐步分析:

  1. 代碼初始化時,創(chuàng)建全局上下文的變量對象。
globalContext.VO === Global = {
  x: 10
  foo: <reference to function>
};
  1. foo創(chuàng)建時,foo[[scope]]屬性是:
foo.[[Scope]] = [
  globalContext.VO
];
  1. foo激活時(進入上下文),foo上下文的活動對象。
fooContext.AO = {
  y: 20,
  bar: <reference to function>
};
  1. foo上下文的作用域鏈為:
fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];
  1. 內(nèi)部函數(shù)bar創(chuàng)建時,其[[scope]]為:
bar.[[Scope]] = [
  fooContext.AO,
  globalContext.VO
];
  1. bar激活時,bar上下文的活動對象為:
barContext.AO = {
  z: 30
};
  1. bar上下文的作用域鏈為:
bar.Scope= [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];

閉包的原理

了解了上面的相關(guān)概念之后,我們通過一個閉包的例子來分析一下閉包的形成原理。

function add(){
    var sum =5;
    var func = function () {
        console.log(sum);
    }
    return func;
}
var addFunc = add();
addFunc(); //5

根據(jù)上面代碼我們逐步分析:

  1. Js執(zhí)行流進入全局執(zhí)行上下文環(huán)境時,全局執(zhí)行上下文可表示為:
globalContext = {
    VO: {
        add: <reference to function>,
        addFunc: undefined
    },
    this: window,
    scope chain: window 
}
  1. 當(dāng)add函數(shù)被調(diào)用時,add函數(shù)執(zhí)行上下文可表示為:
addContext = {
    AO: {
        sum: undefined //代碼進入執(zhí)行階段時此處被賦值為5
        func: undefined //代碼進入執(zhí)行階段時此處被賦值為function (){console.log(sum);}
    },
    this: window,
    scope chain: addContext.AO + globalContext.VO 
}
  1. add函數(shù)執(zhí)行完畢后,Js執(zhí)行流回到全局上下文環(huán)境中,將add函數(shù)的返回值賦值給addFunc。

  2. 由于addFunc仍保存著func函數(shù)的引用,所以add函數(shù)執(zhí)行上下文從執(zhí)行上下文堆棧頂端彈出后并未被銷毀而是保存在內(nèi)存中。

  3. 當(dāng)addFunc()執(zhí)行時,func函數(shù)被調(diào)用,此時func函數(shù)執(zhí)行上下文可表示為:

funcContext = {
    this: window,
    scope chain: addContext.AO + globalContext.VO 
}

當(dāng)要訪問變量sum時,func的活動對象中未能找到,則會沿著作用域鏈查找,由于Js遵循詞法作用域,作用域在函數(shù)創(chuàng)建階段就被確定,在add函數(shù)的活動對象中找到sum = 5;

閉包的用法實戰(zhàn)

閉包可以用在許多地方。它的最大用處有兩個,一個是可以讀取函數(shù)內(nèi)部的變量,另一個就是讓這些變量的值始終保持在內(nèi)存中。本文閉包的用法不是重點內(nèi)容,如果想了解更多方法,可以自行查閱資料。下面列舉幾個應(yīng)用方法。

1、延遲回調(diào)

var a = 10;
setTimeout(function () {
  alert(a); // 10, after one second
}, 1000);

2、回調(diào)函數(shù)

//...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // 當(dāng)數(shù)據(jù)就緒的時候,才會調(diào)用;
  // 這里,不論是在哪個上下文中創(chuàng)建
  // 此時變量“x”的值已經(jīng)存在了
  alert(x); // 10
};
//...

3、創(chuàng)建封裝的作用域來隱藏輔助對象

var foo = {};

// 初始化
(function (object) {

  var x = 10;

  object.getX = function _getX() {
    return x;
  };

})(foo);

alert(foo.getX()); // 獲得閉包 "x" – 10

總結(jié)

本文介紹了關(guān)于閉包以及閉包相關(guān)的知識,如果對你有用,歡迎點贊收藏?。?!??

相關(guān)文章

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容