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 defined
 }
 
 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

模塊的兩個必要條件:

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

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

原文鏈接


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
  © 2014-2022 ITdaan.com 联系我们: