弄懂JavaScript的做用域和閉包

《你不知道的JavaScript》真的是一本好書,閱讀這本書,我有屢次「哦,原來是這樣」的感受,之前自覺得理解了(其實並不是真的理解)的概念,這一次真的理解得更加透徹了。關於本書,我會寫好幾篇讀書筆記用以記錄那些讓我恍然大悟的瞬間,本文是第一篇《弄懂JavaScript的做用域和閉包》。javascript

看正文以前,先考你幾個問你,若是你能清晰的回答,那本文可能對你做用不大,若是有一些疑問,那咱們就一塊兒來解開這些疑問吧。java

考考你

  • 標識符是什麼?LHSRHS又是什麼,其意義何在?編程

  • 什麼是詞法做用域?javascript語言中那些東西會影響做用域?瀏覽器

  • 咱們一直都在據說的各類提高(函數提高,變量提高)究竟要怎麼理解?安全

  • 在咱們平時的編程中,那些地方用到了閉包?(悄悄告訴你,我以前也能把閉包的概念背的滾瓜亂熟,可是卻一直覺得本身平時不多用到閉包,後來才發現,原來一直都在用啊。。)性能優化

正文從這裏開始

從瀏覽器如何編譯JS代碼提及

好久以來我就在思考,當咱們把代碼交給瀏覽器,瀏覽器是如何把代碼轉換爲活靈活現的網頁的。JS引擎在執行咱們的代碼前,瀏覽器對咱們的代碼還作了什麼,這個過程對我來講就像黑匣子通常,神祕而又讓人好奇。閉包

理解var a = 2

咱們天天都會寫相似var a = 2這樣的簡單的JS代碼,但是瀏覽器是機器,它可只認識二進制的0和1,var a = 2對它來講確定比外語對咱們還難。不過有困難沒關係,至少咱們如今問題清晰了,要知道它是如何把有意義的人類字符轉化爲符合必定規則的機器的0 和 1 。異步

想一想咱們是如何閱讀一句話的(能夠想一想咱們不那麼熟悉的外語),咱們不熟悉英語的時候,咱們其實優先去理解的是一個個的詞,這些詞按照必定的規則就成了有意義的句子。瀏覽器其實也是如此var a = 2,瀏覽器其實看到的是var,a,=,2這是一個個的詞。這個過程叫作詞法解析階段,換句話說是這個過程會將由字符組成的字符串分解成(對編程語言來講)有意義的代碼塊。
就像咱們按照語法規則組合單詞爲句子同樣,瀏覽器也會把上述已經分解好的代碼塊組合爲表明了程序語法結構的樹(AST),這個階段稱爲語法分析階段,AST對瀏覽器來講已是有意義的外語了,不過距離它直接理解還差一步代碼生成,轉換代碼爲有意義的機器語言(二進制語言)。編程語言

咱們總結一下經歷的三階段模塊化

- 詞法分析:分解代碼爲有意義的詞語;
* 語法分析:把有意義的詞語按照語法規則組合成表明程序語法結構的樹(AST);
* 代碼生成:將 AST 轉換爲可執行代碼

經過上述三個階段,瀏覽器已經能夠運行咱們獲得的可執行代碼了,這三個階段還有一個合稱呼叫作編譯階段。咱們把以後對可執行代碼的執行稱爲運行階段

JS的做用域在什麼時候肯定

編程語言中,做用域通常來講有兩種,詞法做用域和動態做用域。詞法做用域就是依賴編程時所寫的代碼結構肯定的做用域,通常來講在編譯結束後,做用域就已經肯定,代碼運行過程當中再也不改變。而動態做用域聽名字就知道是在代碼運行過程當中做用域會動態改變。通常認爲咱們的javascript的做用域是詞法做用域(說通常,是由於javascript提供了一些動態改變做用域的方法,後文會有介紹)。

詞法做用域就是依賴編程時所寫的代碼結構肯定的做用域,對比一下瀏覽器在編譯階段作的事情,咱們發現,詞法做用域就是在編譯階段肯定的。看到這裏是否是忽然理解了爲何之前咱們經常聽到的「函數的做用域在函數定義階段就肯定了」這句話了。接下來咱們就來講明函數做用域是按照什麼規則肯定的。

JS中的做用域

做用域是什麼?

關於做用域是什麼?《You don’t know js》給出了這麼一個概念:

使用一套嚴格的規則來分辨哪些標識符對那些語法有訪問權限。

好吧,好抽象的一句話,標識符又是什麼呢?做用域到底要怎麼理解啊?咱們一個個來看。

標識符:

咱們知道,當咱們的程序運行的時候,咱們的數據(」字符串」,「對象」,「函數」等等都是要載入內存的)。那咱們該如何訪問到對應的內存區域呢,標識符就在這時候起做用了,經過它咱們就能找到對應的數據,從這個角度來看,變量名,函數名等等都是標識符。

對標識符的操做
知道了標識符,咱們來想一想,平時咱們會對標識符進行哪些操做。其實無外乎兩種,看下面的代碼:

// 第一種定義了標識符`a`並把數值2賦值給了`a`這種操做有一個專門的術語叫作`LHS`
var a = 2;

// 第二種,var b = a ,其實對應a ,b 兩個操做符是不一樣的操做,對b來講是一個賦值操做,這是LHS,可是對a來講倒是取到a對應的值,這種操做也有一個專門的術語叫作「RHS」
var b = a;

小結一下,對標識符來講有如下兩種操做

- 賦值操做(LHS);常見的是函數定義,函數傳參,變量賦值等等
* 取值操做(RHS);常見包括函數調用,
再回過頭來看做用域

明白了標識符及對標識符的兩種操做,咱們能夠很容易的理解做用域了,做用域其實就是定義了咱們的呈如今運行期,進行標識符操做的範圍,對應到實際問題來講,就是咱們熟悉的函數或者變量能夠在什麼地方調用。

做用域也能夠看作是一套依據名稱查找變量的規則。那咱們再細看一下這個規則,在當前做用域中沒法找到某個變量時,引擎就會在外層嵌套的做用域中繼續查找,直到找到該變量, 或抵達最外層的做用域(也就是全局做用域)爲止。

這裏提到了嵌套一詞,咱們接下來看js中那些因素能夠造成做用域。

JS中的做用域類型

函數做用域

函數做用域是js中最多見的做用域了,函數做用域給咱們最直觀的體會就是,內部函數能夠調用外部函數中的變量。一層層的函數,很直觀的就造成了嵌套的做用域。不過只說這一點真對不起本文的標題,還記得咱們經常聽到的「若是在函數內部咱們給一個未定義的變量賦值,這個變量會轉變爲一個全局變量」。對我來講以前這句話幾乎是背下來的,我一直都沒能理解。咱們從對標識符的操做的角度來理解這句話。

var a = 1;

function foo(){
// b第一次出如今函數foo中
    b = a ;
}

foo();

// 全局能夠訪問到b
console.log(b); //1

在咱們調用foo()時,對b實際上是進行了LHS操做(取得a的值並賦值給b),b前面並不存在var let 等,所以瀏覽器首先在foo()做用域裏面查找b這個標識符,結果在b裏面沒有找到,安裝做用域的規則,瀏覽器會繼續在foo()的外層做用域尋找標識符b,結果仍是沒有找到,說明在此次查詢標識符b的範圍內並不存在已經定義的b,在非嚴格模式下LHS操做會在可查找範圍的最外層(也就是全局)定義一個b,所以b也就成了一個全局的變量了(嚴格模式LHS找不到返回ReferenceError錯誤)。這樣那句話就能夠理解了。一樣值得咱們注意的是對操做符進行RHS操做會出現不一樣的狀況,不管嚴格或者非嚴格模式RHS找不到對返回ReferenceError錯誤(對RHS找到的值進行不合理的操做會返回錯誤TypeError(做用域判別成功,操做非法。))。

閉包:閉包是基於詞法做用域書寫代碼時所產生的天然結果,你甚至不須要爲了利用它們而有意 識地建立閉包。閉包的建立和使用在你的代碼中隨處可見。你缺乏的是根據你本身的意願 來識別、擁抱和影響閉包的思惟環境。

塊做用域

除了函數做用域,JS也提供塊做用域。咱們應該明確,做用域是針對標識符來講的,塊做用域把標識符限制在{}中。

ES6 提供的let,const方法聲明的標識符都會固定於塊中。常被你們忽略的try/catchcatch語句也會建立一個塊做用域。

改變函數做用域的方法

通常說來詞法做用域在代碼編譯階段就已經肯定,這種肯定性實際上是頗有好處的,代碼在執行過程當中,可以預測在執行過程當中如何對它們進行查找。可以提升代碼運行階段的執行效率。不過JS也提供動態改變做用域的方法。eval()函數和with關鍵字.

eval()方法:
這個方法接受一個字符串爲參數,並將其中的內容視爲好像在書寫時就存在於程序中這個位置的代碼。換句話說,能夠在你寫的代碼中用程序生成代碼並運行,就好像代碼是寫在那個位置的同樣。

function foo(str,a){
     eval(str);//欺騙做用域,詞法階段階段foo()函數中並無定義標識符,可是在函數運行階段卻臨時定義了一個b;
     console.log(a,b);
 }
 
 var b = 2;
 
 foo("var b =3;",1);//1,3

 // 嚴格模式下,`eval()`會產生本身的做用域,沒法修改所在的做用域
 function foo(str){
     'use strict';
     eval(str);
     console.log(a);//ReferenceError: a is not de ned
 }
 
 foo('var a =2');

eval()有時候挺有用,可是性能消耗很大,可能也會帶來安全隱患,所以不推薦使用。

with關鍵字:

with 一般被看成重複引用同一個對象中的多個屬性的快捷方式。

var obj = { 
        a: 1,
      b: 2,
      c: 3 
      };
    // 單調乏味的重複 "obj" obj.a = 2;
    
    obj.b = 3;
    obj.c = 4;

    // 簡單的快捷方式 
      
   with (obj) {
        a = 3;
        b = 4;
        c = 5;
    }

    function foo(obj) { 
        with (obj) {
            a = 2; 
        }
    }

    var o1 = { 
        a: 3
    };

    var o2 = { 
        b: 3
    };

    foo( o1 );
    console.log( o1.a ); // 2
    
    foo( o2 );
    console.log( o2.a ); // undefined
    
    console.log( a ); // 2——很差,a被泄漏到全局做用域上了!
    
    // 執行了LHS查詢,不存在就在全局建立了一個。
    // with 聲明其實是根據你傳遞給它的對象憑空建立了一個全新的詞法做用域。

with也會帶來性能的損耗。

JavaScript 引擎會在編譯階段進行數項的性能優化。其中有些優化依賴於可以根據代碼的詞法進行靜態分析,並預先肯定全部變量和函數的定義位置,才能在執行過程當中快速找到標識符。

聲明提高

做用域關係到的是標識符的做用範圍,而標識符的做用範圍和它的聲明位置是密切相關的。在js中有一些關鍵字是專門用來聲明標識符的(好比var,let,const),非匿名函數的定義也會聲明標識符。

關於聲明也許你們都據說過聲明提高一詞。咱們來分析一下形成聲明提高的緣由。

咱們已經知道引擎會在解釋 JavaScript 代碼以前首先對其進行編譯。編譯階段中的一部分工做就是找到全部的聲明,並用合適的做用域將它們關聯起來(詞法做用域的核心)。
這樣的話,聲明好像被提到了前面。
值得注意的是每一個做用域都會進行提高操做。聲明會被提高到所在做用域的頂部。

不過並不是全部的聲明都會被提高,不一樣聲明提高的權重也不一樣,具體來講函數聲明會被提高,函數表達式不會被提高(就算是有名稱的函數表達式也不會提高)。

經過var 定義的變量會提高,而letconst進行的聲明不會提高。

函數聲明和變量聲明都會被提高。可是一個值得注意的細節也就是函數會首先被提高,而後纔是變量,也就是說若是一個變量聲明和一個函數聲明同名,那麼就算在語句順序上變量聲明在前,該標識符仍是會指向相關函數。

若是變量或函數有重複聲明以會第一次聲明爲主。

最後一點須要注意的是:
聲明自己會被提高,而包括函數表達式的賦值在內的賦值操做並不會提高。

做用域的一些應用

看到這裏,我想你們對JS的做用域應該有了一個比較細緻的瞭解。下面說一下對JS做用域的一些拓展應用。

最小特權原則

也叫最小受權或最小暴露原則。這個原則是指在軟件設計中,應該最小限度地暴露必要內容,而將其餘內容都「隱藏」起來,好比某個模塊或對象的 API 設計。也就是儘量多的把部分代碼私有化。

函數能夠產生本身的做用域,所以咱們能夠採用函數封裝(函數表達式和函數聲明均可以)的方法來實現這一原則。

// 函數表達式
    var a = 2;
    (function foo() { // <-- 添加這一行 var a = 3;
       console.log(a); // 3 
    })(); // <-- 以及這一行 
    console.log( a ); // 2

這裏順便說明一下如何區分函數表達式和函數聲明

若是 function 是聲明中 的第一個詞,那麼就是一個函數聲明,不然就是一個函數表達式。
函數聲明和函數表達式之間最重要的區別是它們的名稱標識符將會綁定在何處。函數表達式能夠是匿名的,而函數聲明則不能夠省略函數名——在 JavaScript 的語法中這是非法的。

可使用當即執行的函數表達式(IIFE)的方式來封裝。

當即執行的函數表達式(IIFE)

var a = 2;
    (function foo() {
        var a = 3;
        console.log(a); // 3
    })();
    console.log(a); // 2

函數表達式後面加上一個括號後會當即執行。

(function(){ .. }())是IIFE的另一種表達方式括號加在裏面和外面,功能是同樣的。

順便說一下,IIFE 的另外一個很是廣泛的進階用法是把它們看成函數調用並傳遞參數進去。

var a = 2;
    (function IIFE(global) {
        var a = 3;
        console.log(a); // 3 console.log( global.a ); // 2
    })(window);
    console.log(a); // 2
閉包

通常你們都會這麼形容閉包。

當一個函數的返回值是另一個函數,而返回的那個函數若是調用了其父函數內部的其它變量,若是返回的這個函數在外部被執行,就產生了閉包。

function foo() {
        var a = 2;
    
        function bar() {
            console.log(a);
        }
        return bar;
    }
    var baz = foo();
    baz(); // 2 —— 這就是閉包的效果。在函數外訪問了函數內的標識符
    
    // bar()函數持有對其父做用域的引用,而使得父做用域沒有被銷燬,這就是閉包

通常來講,因爲垃圾回收機制的存在,函數在執行完之後會被銷燬,再也不使用的內存空間。上例中因爲看上去 foo()的內容不會再被使用,因此很天然地會考慮對其進行回收。而閉包的「神奇」之處正是能夠阻止這件事情的發生(之前總有人說要減小使用閉包,懼怕內存泄漏什麼的,其實這個也不大比擔憂)。

其實上面這個定義,在很久以前我就知道,不過同時我也誤覺得我平時不多用到閉包,由於我真的並無主動去用過閉包,不過其實我錯了,無心中,我一直在使用閉包。

本質上不管什麼時候何地,若是將函數(訪問它們各自的詞法做用域)看成第一 級的值類型並處處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、 Ajax請求、跨窗口通訊、Web Workers或者任何其餘的異步(或者同步)任務中,只要使 用了回調函數,實際上就是在使用閉包!
因此你應該知道,你已經用過不少次閉包了。

這裏說一個你們可能都遇到過的坑,一個沒有正確理解做用域和閉包形成的坑。

for (var i = 1; i <= 5; i++) {
        setTimeout(function timer() {
            console.log(i);
        }, i * 1000);
    }
// 其實咱們想獲得的結果是1,2,3,4,5,結果倒是五個6

咱們分析一下形成這個結果的緣由:
咱們試圖假設循環中的每一個迭代在運行時都會給本身「捕獲」一個 i 的副本。可是根據做用域的工做原理,實際狀況是儘管循環中的五個函數是在各個迭代中分別定義的(前面說過以第一次定義爲主,後面的會被忽略), 可是它們都被封閉在一個共享的全局做用域中,由於在時間到了執行timer函數時,全局裏面的這個i就是6,所以沒法達到預期。

理解了是做用域的問題,這裏咱們有兩種解決辦法:

// 辦法1
    for (var i = 1; i <= 5; i++) {
        (function(j) {
            setTimeout(function timer() {
                console.log(j);
            }, j * 1000);
        })(i);
    //經過一個當即執行函數,爲每次循環建立一個單獨的做用域。
    }
    
    // 辦法2
    for (var i = 1; i <= 5; i++) {
        let j = i; // 是的,閉包的塊做用域! 
          setTimeout( function timer() {
        console.log(j);
        }, j * 1000);
    }
    // let 每次循環都會建立一個塊做用域

如今的開發都離不開模塊化,下面說說模塊是如何利用閉包的。

模塊是如何利用閉包的:
最多見的實現模塊模式的方法一般被稱爲模塊暴露

咱們來看看如何定義一個模塊

function CoolModule() {
        var something = "cool";
        var another = [1, 2, 3];
    
        function doSomething() {
            console.log(something);
        }
    
        function doAnother() {
            console.log(another.join(" ! "));
        }
    
    // 返回的是一個對象,對象中可能包含各類函數
        return {
            doSomething: doSomething,
            doAnother: doAnother
        };
    }

    var foo = CoolModule();
// 在外面調用返回對象中的方法就造成了閉包
    foo.doSomething(); // cool
    foo.doAnother(); // 1 ! 2 ! 3

模塊的兩個必要條件:

  • 必須有外部的封閉函數,該函數必須至少被調用一次

  • 封閉函數必須返回至少一個內部函數,這樣內部函數才能在私有做用域中造成閉包,而且能夠訪問或者修改私有的狀態。

文章寫到這裏也差很少該結束了,謝謝你的閱讀,但願你有所收穫。

相關文章
相關標籤/搜索