一篇文章看懂JS閉包,都要2020年了,你怎么能還不懂閉包?


 壹 ❀ 引

我覺得每一位JavaScript工作者都無法避免與閉包打交道,就算在實際開發中不使用但面試中被問及也是常態了。就我而言對於閉包的理解僅止步於一些概念,看到相關代碼我知道這是個閉包,但閉包能解決哪些問題場景我了解的並不多,這也是我想整理一篇閉包的原因。我們來看一段代碼,很明顯這是一個閉包,那么請問閉包指代的是下方代碼中的哪一部分呢?本文開始。

function outer() {
    let name = '聽風是風';

    function insider() {
        console.log(`歡迎來到${name}的博客`);
    };
    return insider;
};
outer()(); //歡迎來到聽風是風的博客

 貳 ❀ 什么是閉包?

如果在面試中被問及什么是閉包,大部分情況下得到的答復是(至少我以前是)A函數嵌套B函數,B函數使用了A函數的內部變量,且A函數返回B函數,這就是閉包

這段描述當然沒問題,那么為了讓下次面試回答的更為漂亮,就讓我們從更專業的角度重新認識閉包。

1.閉包起源

閉包翻譯自英文單詞 closure ([ˈkloʊʒər] 倒閉,關閉,停業),閉包的概念最早出現在1964 年的學術期刊《The Computer Journal》上,由 P. J. Landin The mechanical evaluation of expressions一文中提及。

在這個JavaScript,Java甚至C語言都還沒誕生的60年代,主流的編程語言是基於 lambda 演算的函數式編程語言。而在這個最早的閉包概念描述中使用了大量函數式術語想傳達的意思大概是帶有一系列信息的λ表達式,對於函數式語言來說λ表達式就是函數

早期的閉包由環境(執行環境、標識符列表)與表達式兩部分組成,而將此組成對應到JavaScript中,環境部分正好對應了JS執行上下文中的函數詞法環境與標識符列表表達式部分則對應了JS中的函數體

所以到這里,我們知道JavaScript中的閉包與最初閉包概念是高度吻合的,將帶有一系列信息的λ表達式對應到JavaScript中來,所謂閉包其實就是一個自帶了執行環境(由外層函數提供,即便外層函數銷毀依舊可以訪問)的特殊函數;那么回到文章開頭的提問,這段代碼中的閉包指代的就是內部函數 insider,而非外部函數outer所包含的范圍,這一點一定得弄清楚。

2.閉包的特征

了解了JavaScript閉包的起源,我們接着來看看其它文檔對於閉包的解釋,加深印象並匯總一下閉包有哪些特性。

百度百科:

閉包就是能夠讀取其他函數內部變量的函數。例如在javascript中,只有函數內部的子函數才能讀取局部變量,所以閉包可以理解成“定義在一個函數內部的函數“。

《JavaScript高級編程指南》:

閉包是指有權訪問另外一個函數作用域中的變量的函數。

MDN(幾年前的解釋,現已更新):

閉包是指那些能夠訪問自由變量的函數。

MDN早期解釋是比較有趣的,何為自由變量?自由變量是指在函數中使用的,但既不是函數arguments參數也不是函數局部變量的變量。看個例子:

let a = 1;//自由變量

function fn() {
    console.log(a);
};
fn(); //1

比如這個例子中,變量 a 不屬於函數 fn,但函數 fn 因為作用域鏈的關系,還是可以正常使用變量 a。

說到這里肯定有同學就疑惑了,MDN的描述不對吧,首先 fn 是一個函數,其次 fn 用到了自由變量 a,那豈不是 fn 也是個閉包?

事實就是如此,在《JavaScript權威指南》一書中明確提到,從理論角度來說,JavaScript中所有的函數都是閉包....

是不是有點顛覆了你對於閉包的認知?上面說的是理論角度,站在技術實踐角度來說,閉包無非滿足以下兩點:

一、閉包首先得是一個函數

二、閉包能訪問外部函數作用域中的自由變量即使外部函數上下文已銷毀

所以MDN現在對於閉包的描述已修改為“閉包是由函數以及創建該函數的詞法環境組合而成,這個環境包含了這個閉包創建時所能訪問的所有局部變量”了,這不就符合了我們在前面對於閉包特征的理解。我們通過一個例子加深對閉包特征的印象:

let fn = function () {
    let num = 1; //自由變量
    return {
        a: function () {
            console.log(num);
        },
        b: function () {
            num++;
        }
    };
};

let closure = fn();
//到這里outer函數已執行完畢,執行上下文被釋放
closure.a(); // 1

在上方的例子中,外層函數fn執行返回了兩個閉包 a,b。我們知道函數每次被調用執行都會創建一個新的執行上下文,當函數執行完畢函數執行上下文被彈出執行棧並銷毀,所以在 let closure = fn() 執行完畢時函數fn的執行上下文已不復存在,但我們執行closure.a()可以看到依舊能訪問到外層函數的局部變量num。

為了讓這種感覺更為強烈,我們直接銷毀掉函數fn再次調用閉包函數,可以看到閉包不僅是訪問甚至還能操作外層函數中的變量。

fn = null;
closure.b();
closure.a(); // 2

是不是很神奇?那為什么外層函數上下文都銷毀了,閉包還能訪問到自由變量呢,這就得說說閉包作用域鏈的特別之處了。

 叄 ❀ 用奇妙的執行上下文看閉包

JavaScript中的作用域是指變量與函數的作用范圍。當在某作用域使用某變量時,首先會在本作用域的標識符中查找有沒有,如果沒有就會去父級找,還沒有就一直找到源頭window為止(window也沒有就報錯),這個查找的過程便形成了我們所說的作用域鏈。

那么在JavaScript中這個過程具體是怎么樣的呢,我在 一篇文章看懂JS執行上下文 一文中有詳細描述執行上下文的執行過程,所以這里我就簡單描述下,我們先來看個例子:

let scope = "global scope";

function checkscope() {
    //這是一個自由變量
    let scope = "local scope";
    //這是一個閉包
    function f() {
        console.log(scope);
    };
    return f;
};

let foo = checkscope();
foo();

我們使用偽代碼分別表示執行棧中上下文的變化,以及上下文創建的過程,首先執行棧中永遠都會存在一個全局執行上下文

//創建全局上下文
ECStack = [GlobalExecutionContext];

此時全局上下文中存在兩個變量scope、foo與一個函數checkscope,上下文用偽代碼表示具體是這樣:

//全局上下文創建
GlobalExecutionContext = {
    // this指向全局對象
    ThisBinding: < Global Object > ,
    // 詞法環境
    LexicalEnvironment: {
        //環境記錄
        EnvironmentRecord: {
            Type: "Object", // 對象環境記錄
            // 標識符綁定在這里 函數,let const創建的變量在這
            scope: < uninitialized > ,
            foo: < uninitialized > ,
            checkscope: < func >
        }
        // 全局環境外部環境引入為null
        outer: < null >
    }
}

全局上下文創建階段結束,進入執行階段,全局執行上下文的標識符中像scope、foo之類的變量被賦值,然后開始執行checkscope函數,於是一個新的函數執行上下文被創建,按照執行棧前進后出的特點,執行棧現在是這樣:

ECStack = [checkscopeExecutionContext,GlobalExecutionContext];

那么checkscope函數執行上下文也進入創建階段,它的上下文我們同樣用偽代碼表示:

// 函數執行上下文
checkscopeExecutionContext = {
    //由於函數是默認調用 this綁定同樣是全局對象
    ThisBinding: < Global Object > ,
    // 詞法環境
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative", // 聲明性環境記錄
            // 標識符綁定在這里  arguments與局部變量在這
            Arguments: {},
            scope: < uninitialized > ,
            f: < func >
        },
        // 外部環境引入記錄為</Global>
        outer: < GlobalLexicalEnvironment >
    }
}

由於 checkscope() 等同於 window.checkscope() ,所以在 checkExectionContext 中this指向全局,而且外部環境引用outer也指向了全局(作用域鏈),其次在標識符中我們可以看到記錄了形參arguments對象以及一個變量scope與一個函數 f 。

函數 checkscope 執行到返回返回函數 f 時,函數執行完畢,checkscope 的執行上下文被彈出執行棧,所以此時執行棧中又只剩下全局執行上下文:

ECStack = [GlobalExecutionContext];

代碼執行又走到了foo(),foo函數被執行,於是foo的執行上下文被創建,執行棧中現在是這樣:

ECStack = [fooExecutionContext, GlobalExecutionContext];

foo的執行上下文是這樣:

fooExecutionContext = {
    //由於函數是默認調用 this綁定同樣是全局對象
    ThisBinding: < Global Object > ,
    // 詞法環境
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative", // 聲明性環境記錄
            // 標識符綁定在這里  arguments與局部變量在這
            Arguments: {},
        },
        // 外部環境引入記錄為</checkscope>
        outer: < checkscopeEnvironment >
    }
}

foo執行也等同於是window調用,所以this同樣指向全局window,但outer外部環境引入有點不同,這里指向了外層函數 checkscope,為啥是checkscope?

我們知道JavaScript采用的是詞法作用域,也就是靜態作用域,函數的作用域在定義時就確定了,而不是執行時確定。看個小例子來鞏固下靜態作用域:

var a = 1;

function fn1() {
    console.log(a);
};

function fn2() {
    var a = 2;
    fn1(a);
};

fn2(); //1

這里輸出1,這是因為 fn1 定義在全局作用域中,它能訪問的作用域就是全局,即便我們在 fn2中 調用,它依舊只能訪問定義它地方的作用域。

明白了這個概念,這下總能理解foo執行上下文outer外部環境引入為啥是 checkscopeExecutionContext 了吧。

那也不對啊,現在執行棧中一共就 fooExecutionContext 與 GlobalExecutionContext 這兩個,checkscopeExecutionContext 早被釋放了啊,怎么還能訪問到 checkscope 中的變量。

正常來說確實是不可以,但是JavaScript騷就騷在這里,即使 checkscope 執行上下文被釋放,因為閉包 foo 外部環境 outer 的引用,從而讓 checkscope作用域中的變量依舊存活在內存中,無法被釋放。

這也是為什么談到閉包我們總是強調手動釋放自由變量

這也是為什么文章開頭我們說閉包是自帶了執行環境的函數

那么閉包的理解就點到這里,讓我們總結一句,閉包是指能使用其它作用域自由變量的函數,即使作用域已銷毀。

如果你在閱讀上下文這段有疑惑,如果你好奇為什么var存在變量聲明提升而let沒有,還是強烈閱讀博主這篇文章 一篇文章看懂JS執行上下文 

 肆 ❀ 閉包有什么用?

說閉包聊閉包,結果閉包有啥用都不知道,甚至遇到了一個閉包第一時間都沒反應過來這是閉包,這就是我以前的常態。那么我們專門說說閉包有啥用,不管用不用得上,作為了解也沒壞處。

1.模擬私有屬性、方法

在Java這類編程語言中是支持創建私有屬性與方法的,所謂私有屬性方法其實就是這些屬性方法只能被同一個類中的其它方法所調用,但是JavaScript中並未提供專門用於創建私有屬性的方法,但我們可以通過閉包模擬它,比如:

let fn = (function () {
    var privateCounter = 0;

    function changeBy(val) {
        privateCounter += val;
    };
    return {
        increment: function () {
            changeBy(1);
        },
        decrement: function () {
            changeBy(-1);
        },
        value: function () {
            console.log(privateCounter);
        }
    };
})();
fn.value(); //0
fn.increment();
fn.increment();
fn.value(); //2
fn.decrement();
fn.value(); //1

這個例子中我們通過自執行函數返回了一個對象,這個對象中包含了三個閉包方法,除了這三個方法能訪問變量privateCounter與 changeBy函數外,你無法再通過其它手段操作它們。

構造函數大家不陌生吧,構造函數中也有閉包,直接上例子:

function Echo(name) {
    //這是一個私有屬性
    var age = 26;
    //這些是構造器屬性
    this.name = name;
    this.hello = function () {
        console.log(`我的名字是${this.name},我今年${age}了`);
    };
};
var person = new Echo('聽風是風');
person.hello();//我的名字是聽風是風,我今年26了

如果大家對於我說構造函數中使用了閉包有疑問,可以閱讀博主這篇文章 js new一個對象的過程,實現一個簡單的new方法 這篇文章,其實new過程都會隱性返回一個對象,這個對象中也包含了構造函數中構造器屬性中的方法。

如果某個屬性方法在所有實例中都需要使用,我們一般推薦加在構造函數的prototype原型鏈上,還有種做法就是利用私有屬性。比如這個例子中所有實例都可以正常使用變量 age。同時我們將age稱為私有屬性的同時,我們也會將this.hello稱為特權方法,因為你只有通過這個方法才能訪問被保護的私有屬性age啊。

我在JavaScript模式 精讀JavaScript模式(七),命名空間模式,私有成員與靜態成員 這篇文章中有介紹私有屬性方法,靜態屬性法,特權方法,有興趣也可以讀讀看(內鏈推的飛起...)。

2.工廠函數

什么是工廠函數?工廠函數給我的感覺與構造函數或者class類似,調用工廠函數就會生產該類(構造函數)的實例,我們舉一個MDN的簡單例子:

function makeAdder(x) {
    return function (y) {
        console.log(x + y);
    };
};

var a = makeAdder(5);
var b = makeAdder(10);
a(2); // 7
b(2); // 12

在這個例子中,我們利用了閉包自帶執行環境的特性(即使外層作用域已銷毀),僅僅使用一個形參完成了兩個形參求和的騷操作,是不是很奈斯。當然例子函數還有個更專業的名詞,叫函數柯里化。

3.其它應用

閉包其實在很多框架中都是隨處可見的,比如angularjs中可以自定義過濾器,而自定義過濾器的方式同樣也是一個閉包,比如這樣:

angular.module('myApp',[])
    .filter('filterName',function () {
        return function () {
            //do something
        };
    })

如果我沒記錯,vue創建過濾器的方式貌似也是閉包....

 伍 ❀ 閉包使用注意

說了這么多,閉包總給我們一種高逼格的感覺,其實說到底也就是自帶執行環境的函數而已,如果你要使用閉包有些地方還真的注意一下。

1.閉包的性能與內存占用

我們已經知道了閉包是自帶執行環境的函數,相比普通函數,閉包對於內存的占用還真就比普通函數大,畢竟外層函數的自由變量無法釋放。

function bindEvent(){
    let ele = document.querySelector('.ele');
    ele.onclick = function () {
        console.log(ele.style.color);
    };
};
bindEvent();

比如這個例子中,由於點擊事件中使用到了外層函數中的DOM ele,導致 ele 始終無法釋放,大家都知道操作DOM本來是件不太友好的事情,你現在操作別人不說,還抓着不放了,你良心不會痛?

比如這個例子你要獲取color屬性,那就單獨復制一份color屬性,在外層函數執行完畢后手動釋放ele,像這樣:

function bindEvent() {
    let ele = document.querySelector('.ele');
    let color = ele.style.color;
    ele.onclick = function () {
        console.log(color);
    };
    ele = null;
};
bindEvent();

2.閉包中的this

閉包中的this也會讓人產生誤解,我們在前面說了靜態作用域的概念,即函數作用域在定義時就已經確定了,而不是調用時確定。this這個東西我們也知道,this在最終調用時才確定,而不是定義時確定,跟靜態作用域有點相反。

var name = "聽風是風";
var obj = {
    name: "行星飛行",
    sayName: function () {
        return function () {
            console.log(this.name);
        };
    }
};

obj.sayName()(); //

猜猜這里輸出什么,很遺憾這里輸出外層的聽風是風,具體為什么其實在上文中通過執行上下文看閉包就解釋了,下面的解釋看不懂就回去重新讀一遍。

函數每次執行都會創建執行上下文,而上下文又由this、詞法環境、變量環境以及外部環境引用等組成,我們只說作用域是可以繼承的,沒人說this指向也可以繼承吧。我們上面的代碼改改:

var a = obj.sayName()
a(); //等同於window.a()

this指向是不能像作用域一樣存在鏈式的,執行第二個方法時其實是window在調用,這下明白沒?

那么有同學就要問了,那我要用在閉包中使用外層函數的this咋辦,這還不簡單,保存this唄:

var name = "聽風是風";
var obj = {
    name: "行星飛行",
    sayName: function () {
        var that = this;
        return function () {
            console.log(that.name);
        };
    }
};
obj.sayName()();//行星飛行

 陸 ❀ 總

那么到這里,我們從閉包的起源解釋了JavaScript閉包的來源,了解到閉包其實就是自帶了執行環境的函數,如果在以后的面試中有面試官問你閉包,我希望你能通過在這里學到的知識秀的對方頭皮發麻。

除了知道閉包的概念,我們還從執行上下文的角度解釋了為何閉包還能使用已銷毀父級函數的自由變量,並復習了作用域,作用域鏈以及靜態作用域的概念。

說閉包用閉包,我們介紹了幾種常規的閉包用法,以及在實際使用中我們應該注意的點。

那么到這里閉包文章就算寫完了,下一篇寫this。

this相關文章已更新:

五種綁定徹底弄懂this,默認綁定、隱式綁定、顯式綁定、new綁定、箭頭函數綁定詳解

從兩道面試題加深理解閉包與箭頭函數中的this

如果你對於本文描述存在疑惑或者本文存在描述錯誤,歡迎留言討論,我會在第一時間回復你,畢竟對於一個孤獨的人來說,收到陌生人的評論也是件開心的事。

 參考

 JavaScript深入之從作用域鏈理解閉包

JavaScript深入之閉包

深入javascript——作用域和閉包

MDN


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM