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

 壹 ❀ 引javascript

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

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

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

 貳 ❀ 什麼是閉包?vue

若是在面試中被問及什麼是閉包,大部分狀況下獲得的答覆是(至少我之前是)A函數嵌套B函數,B函數使用了A函數的內部變量,且A函數返回B函數,這就是閉包java

這段描述固然沒問題,那麼爲了讓下次面試回答的更爲漂亮,就讓咱們從更專業的角度從新認識閉包。git

1.閉包起源angularjs

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

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

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

因此到這裏,咱們知道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中全部的函數都是閉包....

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

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

2、閉包能訪問外部函數做用域中的自由變量即便外部函數上下文已銷燬

因此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 = [GlobalExectionContext];

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

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

全局上下文建立階段結束,進入執行階段,全局執行上下文的標識符中像scope、foo之類的變量被賦值,而後開始執行checkscope函數,因而一個新的函數執行上下文被建立,按照執行棧前進後出的特色,執行棧如今是這樣:

ECStack = [checkscopeExectionContext,GlobalExectionContext];

那麼checkscope函數執行上下文也進入建立階段,它的上下文咱們一樣用僞代碼表示:

// 函數執行上下文
checkscopeExectionContext = {
    //因爲函數是默認調用 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 = [GlobalExectionContext];

代碼執行又走到了foo(),foo函數被執行,因而foo的執行上下文被建立,執行棧中如今是這樣:

ECStack = [fooExectionContext, GlobalExectionContext];

foo的執行上下文是這樣:

fooExectionContext = {
    //因爲函數是默認調用 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外部環境引入爲啥是 checkscopeExectionContext 了吧。

那也不對啊,如今執行棧中一共就 fooExectionContext 與 GlobalExectionContext 這兩個,checkscopeExectionContext 早被釋放了啊,怎麼還能訪問到 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);
        }
    };
})();
Counter.value(); //0
Counter.increment();
Counter.increment();
Counter.value(); //2
Counter.decrement();
Counter.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。

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

 參考

 JavaScript深刻之從做用域鏈理解閉包

JavaScript深刻之閉包

深刻javascript——做用域和閉包

MDN

相關文章
相關標籤/搜索