YDKJS:做用域與閉包

什麼是做用域

做用域是一組定義在何處儲存變量以及如何訪問變量的規則javascript

編譯器

javascript 是編譯型語言。可是與傳統編譯型語言不一樣,它是邊編譯邊執行的。編譯型語言通常從源碼到執行會經歷三個步驟:java

  • 分詞/詞法分析jquery

    將一連串字符串打斷成有意義的片斷,成爲 token(記號)。數組

  • 解析數據結構

    將一個 token 流(數組)轉化爲一個嵌套元素的樹,即抽象語法樹(AST)。閉包

  • 代碼生成ide

    將抽象語法樹轉化爲可執行的代碼。實際上是轉化成機器指令。函數

好比var a = 1的編譯過程:工具

  1. 分詞/詞法分析: var a = 1這段程序可能會被打斷成以下 token:vara=1,空格保留與否得看其是否具備意義。
  2. 解析:將第一步的 token 造成抽象樹:大體以下:性能

    變量聲明: {
        標識符: a
        賦值表達式: {
            數字字面量: 1
        }
    }
  3. 代碼生成: 轉化成機器命令:建立一個稱爲 a 的變量,並分配內存,存入一個值爲數字 1。

理解做用域

做用域就是經過標識符名稱查詢變量的一組規則。

代碼解析運行中的角色:

  • 引擎

    負責代碼的編譯和程序的執行。

  • 編譯器

    協助引擎,主要負責解析和代碼生成。

  • 做用域

    協助引擎,收集並維護一張全部被聲明的標識符(變量)的列表,並對當前執行的代碼如何訪問這些變量強制實施一組嚴格的規則。

好比var a = 1的運行:

  1. 編譯器遇到var a,會首先讓做用域去查詢 a 是否已經存在,存在則忽略,不存在,則讓做用域建立它;
  2. 編譯器遇到a = 1,會編譯成引擎稍後須要運行的代碼;
  3. 引擎執行編譯後的代碼,會讓當前查看是否存在變量a能夠訪問,存在則引用這個變量,不存在則查看其餘其餘。

上面過程當中,引擎會對變量進行查詢,而查詢分爲 RHS(right-hand Side)查詢 和 LHS(left-hand Side)查詢,它們根據變量出如今賦值操做的左手邊仍是右手邊來判斷查詢方式。

  • RHS

    變量在賦值的右手邊時採用這種方式查詢,查不到會拋出錯誤 referenceError

  • LHS

    變量在賦值的左手邊時採用這種方式查詢,在非嚴格模式下,查不到會再頂層做用域建立這個變量

嵌套的做用域

實際工做中,一般會有多於一個的做用域須要考慮,會存在做用域嵌套在其餘做用域中的狀況。

嵌套做用域的規則:

從當前做用域開始查找,若是沒有,則向上走一級繼續查找,以此類推,直至到了最外層全局做用域,不管找到與否,都會中止。

詞法做用域

做用域的工做方式通常有倆種模型:詞法做用域和動態做用域。javascript 所採用的是詞法做用域。

詞法分析時

詞法做用域是在詞法分析時被定義的做用域。

上述定義的潛在含義即:詞法做用域是基於寫程序時變量和做用域的塊兒在何處被編寫所決定的。公認的最佳實踐是將詞法做用域看做是僅僅依靠詞法的。

查詢變量:

引擎查找標識符時會在當前做用域開始一直向最外層做用域查找,一旦匹配到第一個,做用域查詢便中止。

相同名稱的標識符能夠在嵌套做用域的多個層中被指定,這成爲「遮蔽」。

無論函數是從哪裏被調用、如何調用,它的詞法做用域是由這個函數被聲明的位置惟一定義的。

欺騙詞法做用域

javascript 提供了在運行時修改詞法做用域的機制——with 和 eval,它們會欺騙詞法做用域。實際工做中,這種作法並不被推薦,應當儘可能避免使用。

欺騙詞法做用域會致使更低下的性能。

引擎在編譯階段會對代碼作許多優化工做,好比靜態地分析代碼。但若是代碼存在 eval 和 with,致使詞法做用域的不固定行爲,這一切的優化都有可能毫無心義,因此引擎就會簡單地不作任何優化。

  1. eval

eval函數接收一個字符串做爲參數,並在運行時將該字符串的內容在當前位置運行。

function foo(str, a) {
    eval(str); // 做弊!
    console.log(a, b);
}

var b = 2;
foo("var b = 3", 1); //1,3

上面的代碼,var b = 3會再 eval 位置運行,從而在 foo 做用域內建立了變量b。當console.log(a,b)調用發生時,引擎會直接訪問 foo 做用域內的b,而不會再訪問外部的b變量。

注意:使用嚴格模式,在 eval 中做出的聲明不會實際上修改包圍他的做用域

  1. with

咱們一般使用 with 來引用一個對象的多個屬性。

var obj = {
    a: 1,
    b: 2,
    c: 3
};

with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

console.log(obj); //{a: 3, b: 4, c: 5}

可是,with 會作的事,比這要多得多。

var o1 = { a: 3 };
var o2 = { b: 3 };

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

foo(o1);
console.log(o1.a); //2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2  全局做用域泄漏

with 語句接受一個對象,並將這個對象視爲一個徹底隔離的詞法做用域

可是 with 塊內部的一個普通的var聲明並不會歸於這個with塊兒的做用域,而是歸於包含它的函數做用域。

因此,上面代碼執行foo(o2)時,在執行到 a = 2 時,引擎會進行 LHS查找,可是一直到最外層都沒有找到 a 變量,因此會在最外層建立這個變量,這裏就形成了做用域泄漏。

函數與塊做用域

javascript 中是否是隻能經過函數建立新的做用域,有沒有其餘方式/結構建立做用域?

函數中的做用域

javascript 擁有基於函數的做用域

函數做用域支持着這樣的想法:全部變量都屬於函數,而去貫穿整個函數均可以使用或重用(包括嵌套的做用域中)。

這樣以來,一個聲明出如今做用域何處是可有可無的。

隱藏標識符於普通做用域

咱們能夠經過將變量和函數圍在一個函數的做用域中來「隱藏」它們。

爲何須要「隱藏」變量和函數?

若是容許外圍的做用域訪問一個工做的私有細節,不只不必,並且多是危險的。因此軟件設計中有一個最低權限原則原則:

最低權限原則:也稱「最低受權」/「最少曝光」,在軟件設計中,好比一個模塊/對象的 API,你應當只暴露所須要的最低限度的東西,而隱藏其餘一切。

將變量和函數隱藏能夠避免多個同名但用處不一樣的標識符之間發生無心的衝突,從而致使值被意外的覆蓋。

實際可操做的方式:

  1. 全局命名空間

    在引用多個庫時,若是他們沒有隱藏內部/私有函數和變量,那麼它們十分容易出現相互衝突。因此,這些庫一般會在全局做用域中使用一個特殊的名稱來建立一個單讀的變量聲明。它常常是一個對象,而後這個對象被用做這個庫一個命名空間,全部要暴露出來的功能都會做爲屬性掛載在這個對象上。

    好比,Jquery 的對象就是 jquery/$;

  2. 模塊管理

    實現命名衝突的另外一種方式是模塊管理。

函數做爲做用域

聲明一個函數,能夠拿來隱藏函數和變量,但這種方式同時也存在着問題:

  • 不得不聲明一個命名函數,這個函數的標識符名稱自己就污染了外圍做用域
  • 不得不經過名稱明確地調用這個函數

不須要名稱,又能自動執行的,js 剛好提供了這樣一種方式。

(function(){
    ...
})()

上面的代碼使用了匿名函數和當即調用函數表達式:

  1. 匿名函數

函數表達式能夠匿名,函數聲明不能匿名。

匿名函數的缺點:

  • 在棧中沒有有用的名稱能夠表示,調試困難;
  • 想要遞歸本身(arguments.callee)或者解綁事件處理器變得麻煩
  • 更不易代碼閱讀

最佳的方式老是命名你的函數表達式。

  1. 當即調用函數表達式

經過一個(),咱們能夠將函數做爲表達式。末尾再加一個括號能夠執行這個函數表達式。這種模式被成爲 IIFE(當即調用函數表達式;Immediately Invoked Function Expression)

塊做爲做用域

大部門語言都支持塊級做用域,從而將信息隱藏到咱們的代碼塊中,塊級做用域是一種擴展了最低權限原則的工具。

可是,表面上看來 javascript 沒有塊級做用域。

for (var i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i); // 10 變量i被劃入了外圍做用域中
if (true) {
    var bar = 9;
    console.log(bar); //9
}
console.log(bar); //9 // 變量bar被劃入了外圍做用域中

但也有特殊狀況:

  • with

    它從對象中建立的做用域僅存在於這個 with 語句的生命週期中。

  • try/catch

    ES3 明確指出 try/catch 中的 cathc 子語句中聲明的變量,是屬於 catch 塊的塊級做用域。

    try {
        var a = 1;
    } catch (e) {
        var c = 2;
    }
    console.log(a); //1
    console.log(c); //undefined
  • let/const

    let 將變量聲明依附在它所在的塊兒(一般是{...})做用域中。

    • 隱含使用現存得塊兒
    if (true) {
        let bar = 1;
        console.log(bar); //1
    }
    console.log(bar); // ReferenceError
    • 建立明確塊兒
    if (true) {
        {
            // 明確的塊兒
            let bar = 1;
            console.log(bar); //1
        }
    }
    console.log(bar); // ReferenceError

    const 也建立一個塊級做用域,可是它的值是固定的(常量)。

    注意: let/const 聲明不進行變量提高。

塊級做用域的用處:

  1. 垃圾回收

    能夠處理閉包和釋放內存的垃圾回收。

    function process() {
        // do something
    }
    var bigData = {...}; // 大致量數據
    process(bigData);
    var btn = document.getElementById('btn');
    btn.addEventListener("click",function(e){
        console.log('btn click');
    })

    點擊事件的回調函數根本不須要 bigData 這個大致量數據。理論上講,在執行完 process 函數後,這個消耗巨大內存的數據結構應該被做爲垃圾而回收。然而由於 click 函數在整個函數做用域上擁有一個閉包,bigData 將會仍然保持一段事件。

    塊級做用域能夠解決這個問題:

    function process() {
       // do something
    }
    {
       let bigData = {...}; // 大致量數據
       process(bigData);
    }
    var btn = document.getElementById('btn');
    btn.addEventListener("click",function(e){
       console.log('btn click');
    })
  2. 循環

    對每一次循環的迭代從新綁定。

    for (let i = 0; i < 10; i++) {
        console.log(i);
    }
    console.log(i); // ReferenceError

    也能夠這樣:

    {
        let j;
        for (j = 0; i < 10; i++) {
            let i = j; // 每次迭代從新綁定
            console.log(i);
        }
    }

提高

函數做用域仍是塊級做用域的行爲都依賴於一個相同的規則: 在一個做用域中聲明的任何變量都附着在這個做用域上。

可是出現一個做用域內各類位置的聲明如何依附做用域?

先有雞仍是先有蛋?

咱們傾向於認爲代碼是自上而下地被解釋執行的。這大體上是對的,但也有一部分並不是如此。

a = 2;
var a;
console.log(a); // 2

若是代碼自上而下的解釋運行,預期應該輸出 undefined ,由於 var aa = 2 以後,應該從新定義了變量 a。顯然,結果並非如此。

console.log(a); // undefined
var a = 2;

從上面的例子上,你也許會猜想這裏會輸出 2,或者認爲這裏會致使一個 ReferenceError 被拋出。不幸的是,結果倒是 undefined。

代碼究竟如何執行,是先有聲明仍是賦值?

編譯器再次襲來

咱們知道,引擎在 javascript 執行代碼以前會先對代碼進行編譯,編譯的其中一個工做就是找到全部的聲明,並將它關聯在合適的做用域上。

因此,在咱們的代碼被執行前,全部的聲明,包括變量和函數,都會被首先處理。

對於var a = 2,咱們認爲是一個語句,但 javascript 實際上認爲這是倆個語句:var aa = 2。第一句(聲明)會在編譯階段處理,第二句(賦值)會在執行階段處理。

知道了這些,我想對於上一節的疑惑也就迎刃而解了:先有聲明,後有賦值

注意:提高是以做用域爲單位的

函數聲明會被提高,可是表達式不會。

foo(); // 1
goo(); // TypeError

function foo() {
    console.log(1);
}

var goo = function() {
    console.log(2);
};

變量 goo 被提高了,但表達式沒有,因此調用 goo 時,goo 的值爲 undefined。因此會報 TypeError。

函數優先

函數聲明和變量都會提高。可是函數享有更高的優先級。

console.log(typeof foo); // function

var foo = 2;

function foo() {
    console.log(1);
}

從上面代碼能夠看出,結果輸出 function 而不是 undefined 。說明函數聲明優先於變量。

重複聲明,後面的會覆蓋前面的。

做用域閉包

必需要對做用域有健全和堅實的理解才能理解閉包。

啓蒙

在 javascript 中閉包無處不在,你只是必須認出它並接納它。它是依賴於詞法做用域編寫代碼而產生的結果。

事實真相

閉包就是函數可以記住並訪問它的詞法做用域,即便當這個函數在他的詞法做用域以外執行時

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

這種形式算閉包嗎?技術上算,它實現了閉包,函數 bar 在函數 foo 的做用域上有一個閉包,即 bar 閉住了 foo 的做用域。可是在上面代碼中並非能夠嚴格地觀察到。

function foo() {
    var a = 2;
    function bar() {
        console.log(2);
    }
    return bar;
}
var baz = foo();
baz(); //2  這樣使用纔算真正意義上的閉包

bar 對於 foo 內的做用域擁有此法做用域訪問權,當咱們調用 foo 以後返回 bar 的引用。按理來講,foo 執行事後,咱們通常會指望 foo 的整個內部做用域消失,由於垃圾回收機制會自動回收再也不使用的內存。但 bar 擁有一個詞法做用域的閉包,覆蓋着 foo 的內部做用域,閉包爲了能使 bar 在之後的任意時刻能夠引用這個做用域而保持的它的存在。

因此,bar 在詞法做用域以外依然擁有對那個做用域的引用,這個引用稱爲閉包。

閉包使一個函數能夠繼續訪問它在編寫時被定義的詞法做用域。

var a = 2;

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

function foo(fn) {
    fn(); // 發現閉包!
}

foo(bar);

上面的代碼,函數做爲參數被傳遞,實際上這也是一種觀察/使用閉包的例子。

不管咱們使用什麼方法將一個函數傳送到它的詞法做用域以外,它都將維護一個指向它被聲明時的做用域的引用。

循環 + 閉包

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i); // 5
    }, i * 1000);
}

這段代碼的預期是每隔一秒分別打印數字:1,2,3,4,5。可是咱們執行後發現結果一共輸出了 5 次 6。

爲何達不到預期的效果?

定時器的回調函數會在循環完成以後執行(詳見事件循環機制)。而 for 不是塊級做用域,因此每次執行 timer 函數的時候,它們的閉包都在全局做用域上。而此時全局做用域環境中的變量 i 的值爲 6。

咱們的代碼缺乏了什麼?

由於每個 timer 函數執行的時候都是使用全局做用域,因此訪問的變量必然是一致的,因此想要達到預期的結果,咱們必須爲每個 timer 函數建立一個私有做用域,並在這個私有做用域內存在一個可供回調函數訪問的變量。如今咱們來改寫一下:

for (var i = 1; i <= 5; i++) {
    (function() {
        let j = i;
        setTimeout(function() {
            console.log(j); // 1,2,3,4,5
        }, i * 1000);
    })();
}

咱們使用 IIFE 爲每次迭代建立新的做用域,而且保存每次迭代須要的值。

其實這裏主要用到的原理是使用塊級做用域,因此,理論上還有其餘方式能夠實現,好比:with,try/catch,let/const,你們均可以嘗試下哦。

模塊

模塊也利用了閉包的力量。

function coolModule() {
    var something = "cool";
    function doSomething() {
        console.log(something);
    }

    return {
        doSomething: doSomething
    };
}

var foo = coolModule()
foo.doSomething() // cool
相關文章
相關標籤/搜索