徹底搞懂JavaScript作用域

prettyEcho發表於2019-02-07

BY 張建成(prettyEcho@github)

除非另行註明,頁面上所有內容採用知識共享-署名(CC BY 2.5 AU)協議共享

原文地址deep.js , 歡迎 評論star

我們常說,萬物都有其存在的價值,這話的確不錯,但是深思一下,是不是需要有個前提,萬物都在某些領域或多或少的存在某些價值

舉個例子,汽車,絕對是個非常有價值的stuff,它給我們的日常出行,貨物運輸等帶來了極大的便利;筷子,同樣也是個非常有價值的stuff,它給我們吃飯帶來了極大的方便。但是,汽車能幫我們把菜送到嘴裡嗎?筷子能載著我們出行嗎?

那麼,我上面所說的某些領域,我們是不是可以稱其為作用域,我想是可以的。

說到這,那麼我就想問了:在JS裡,作用域是不是也是類似的概念呢?

首先,我可以肯定的說這是一個在JavaScript中灰常灰常重要的概念,關係著JS裡很多核心的機制,理解它,很多問題都迎刃而解了。

那麼,問問自己,在JS裡,作用域是什麼?

心裡大概知道是什麼,但是細細一想又好像說不太清。

沒關係,下面我們就細細品味這個有意思的東東。

先throw概念吧:

作用域負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。

通俗來說,作用域相當於一個管理員(有自己的一套規則),他負責管理所有宣告的識別符號的有序查詢。

我們來講個故事,說說作用域到底幹了啥。

三兄弟齊上陣

long long ago,有3個關係很好的基友,老大叫引擎,老二叫編輯器,老三叫作用域。三兄弟眼看年歲已長,可手上還是沒有幾個銀子。個個都很著急,於是三兄弟謀劃一同做個事。

求職過程:此粗略去數萬個字。。。

最終他們做的工作是:負責JS的編譯和執行。

他們的工作內容是這樣的:

老闆甩給他們一項任務編譯並執行下面程式碼:

var a = 1;
console.log( a );
複製程式碼

開始工作:

  • 編譯器:作用域,幫我看看你那有沒有儲存變數a。
  • 作用域:二哥,還沒有。
  • 編譯器:那好,幫我儲存一個。
  • 引擎: 老三,你那有沒有一個叫做a的變數。
  • 編譯器:大哥,還真有,剛二哥讓我儲存了一個。
  • 引擎: 真是太好了,幫我拿出來,它的值是幾,我需要給它複製。
  • 編譯器:大哥,它的值是2。
  • 引擎: 謝謝你,三弟,這樣我就能列印它的值了。

上面講了一個不恰當的小故事,但是三者之間的關係大概就是這樣。

詞法作用域 VS 動態作用域

  • 詞法作用域

徹底搞懂JavaScript作用域裡介紹過,大部分標準語言編譯器的第一個工作階段叫作詞法化(也叫單詞化)。回憶一下,詞法化的過程會對原始碼中的字元進行檢查,如果是有狀態的解析過程,還會賦予單詞語義。

在JS裡,使用的作用域就是詞法作用域

簡單地說,詞法作用域就是定義在詞法階段的作用域。換句話說,詞法作用域是由你在寫程式碼時將變數和塊作用域寫在哪裡來決定的,因此當詞法分析器處理程式碼時會保持作用域不變(大部分情況下是這樣的)。

  • 動態作用域

在JS裡,動態作用域和this機制息息相關。它的作用域詩是在執行的過程中確定的

var a = 1;

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

foo(); // 1
複製程式碼

從上面的程式碼,我們可以看出:foo中列印a的值不是由寫程式碼的位置確定的,而是取決於foo執行的位置。

  • 區別

    • 詞法作用域是在寫程式碼或者說定義時確定的,而動態作用域是在執行時確定的。(this 也是!)
    • 詞法作用域關注函式在何處宣告,而動態作用域關注函式從何處呼叫。

函式作用域

JS裡,生成作用域的方式:

  • 函式
  • with、eval (不建議使用,影響效能)

由此,我們知道JS裡,絕大多數的作用域都是基於函式生成的

每個函式都會為自身生成一個作用域氣泡。這個氣泡內所有的識別符號都可以在這個氣泡中使用。

function bar() {
    var a = 1;

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

    foo();

    console.log(a);
}

bar();
複製程式碼

上面程式碼,bar氣泡有識別符號a、foo,因此在bar氣泡中可以訪問到a、foo; foo氣泡有識別符號b,因此在bar氣泡中可以訪問到b; 當然還有一個全域性氣泡,全域性氣泡中有bar識別符號,因此在全域性氣泡中可以訪問到bar。

最小授權原則

最小授權原則是指在軟體設計中,應該最小限度地暴露必要內容,而將其他內容都“隱藏”起來,比如某個模組或物件的 API 設計。

這個原則可以延伸到如何選擇作用域來包含變數和函式。如果所有變數和函式都在全域性作 用域中,當然可以在所有的內部巢狀作用域中訪問到它們。但這樣會破壞前面提到的最小 特權原則,因為可能會暴漏過多的變數或函式,而這些變數或函式本應該是私有的,正確 的程式碼應該是可以阻止對這些變數或函式進行訪問的。

例如:

function doSomething(a) {
        
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );

}

function doSomethingElse(a) { 
    return a - 1;
}

var b;
doSomething( 2 ); // 15
複製程式碼

在這個程式碼片段中,變數 b 和函式 doSomethingElse(..) 應該是 doSomething(..) 內部具體 實現的“私有”內容。給予外部作用域對 b 和 doSomethingElse(..) 的“訪問許可權”不僅 沒有必要,而且可能是“危險”的,因為它們可能被有意或無意地以非預期的方式使用, 從而導致超出了 doSomething(..) 的適用條件。更“合理”的設計會將這些私有的具體內容隱藏在 doSomething(..) 內部,

例如:

function doSomething(a) { 

    function doSomethingElse(a) {
        return a - 1; 
    }

    var b;
    
    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}
doSomething( 2 ); // 15
複製程式碼

現在,b 和 doSomethingElse(..) 都無法從外部被訪問,而只能被 doSomething(..) 所控制。 功能性和最終效果都沒有受影響,但是設計上將具體內容私有化了,設計良好的軟體都會 依此進行實現。

規避衝突

當我們的程式程式碼逐漸多起來,難免會出現變數衝突。那麼如何規避衝突就顯得額外重要。

函式可以把識別符號嚴謹的”隱藏”起來,外部無法訪問到,利用這個特性我們可以很好的規避衝突。

function foo() {
    var a = 1;
}

function bar() {
    var a = 2;
}
複製程式碼

foo和bar中定義了相同的變數a,但是卻不會相互造成影響。因為函式可以很好的把識別符號”隱藏”起來。

  • 全域性名稱空間

變數衝突的一個典型例子存在於全域性作用域中。當程式中載入了多個第三方庫時,如果它 們沒有妥善地將內部私有的函式或變數隱藏起來,就會很容易引發衝突。
這些庫通常會在全域性作用域中宣告一個名字足夠獨特的變數,通常是一個物件。這個物件 被用作庫的名稱空間,所有需要暴露給外界的功能都會成為這個物件(名稱空間)的屬 性,而不是將自己的識別符號暴漏在頂級的詞法作用域中。

例如:

var myLibrary = {
    name: `echo`,
    getName: function() {
        console.log( this.name );
    }
}
複製程式碼

函式宣告 VS 函式表示式

函式宣告和函式表示式判別的依據是:函式的生命是否以function關鍵詞開始
以關鍵詞function 開始的宣告是函式宣告,其餘的函式宣告全部是函式表示式。

//函式宣告
function foo() {

}

//函式表示式
var foo = function () {

};

(function() {

})();
複製程式碼

具名函式 VS 匿名函式

  • 具名函式
    擁有名字的函式

    function foo() {
    
    }
    
    var foo = function bar() {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    +function foo() {
    
    }();
    複製程式碼

需要注意:函式宣告一定要是具名函式

  • 匿名函式
    沒有名字的函式

    var foo = function () {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    -function foo() {
    
    }();
    複製程式碼

立即執行函式(IIFE)

vara=2;

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

console.log( a ); // 2
複製程式碼

該函式是以()開始,不是以關鍵詞function開始,因此IIFE是函式表示式

函式名對 IIFE 當然不是必須的,IIFE 最常見的用法是使用一個匿名函式表示式。雖然使 用具名函式的 IIFE 並不常見,但它具有以下優勢:

  1. 匿名函式在棧追蹤中不會顯示出有意義的函式名,使得除錯很困難。
  2. 如果沒有函式名,當函式需要引用自身時只能使用已經過期的arguments.callee引用, 比如在遞迴中。另一個函式需要引用自身的例子,是在事件觸發後事件監聽器需要解綁 自身。
  3. 匿名函式省略了對於程式碼可讀性/可理解性很重要的函式名。一個描述性的名稱可以讓 程式碼不言自明。

因此具名函式的 IIFE 也是一個值得推廣的實踐。

  • 另一種表達形式
(function() {

}())
複製程式碼

這也是IIFE的一種表達方式,功能上和上面那種方式是一致的。選擇哪種全憑個人愛好

  • 引數傳遞

IIFE 也可以和其他形式的函式一樣實現引數的傳遞(多說一句:引數傳遞是按值傳遞)。

(function foo(a) {
    console.log(a);
})(3);
複製程式碼

這個模式的另外一個應用場景是解決 undefined 識別符號的預設值被錯誤覆蓋導致的異常(雖 然不常見)。將一個引數命名為 undefined,但是在對應的位置不傳入任何值,這樣就可以 保證在程式碼塊中 undefined 識別符號的值真的是 undefined:

undefined = true; // 給其他程式碼挖了一個大坑!絕對不要這樣做! 
(function IIFE( undefined ) {
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();
複製程式碼
  • UMD (Universal Module Definition)

IIFE 還有一種變化的用途是倒置程式碼的執行順序,將需要執行的函式放在第二位,在 IIFE 執行之後當作引數傳遞進去。儘管這種模式略顯冗長,但有些人認為它更易理解。

var a=2;

(function IIFE( def ) { 
    //引數的處理
    def( window );
})(function def( global ) {
    //邏輯運算
    var a=3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
});
複製程式碼

塊作用域

儘管函式作用域是最常見的作用域單元,當然也是現行大多數 JavaScript 中最普遍的設計 方法,但其他型別的作用域單元也是存在的,並且通過使用其他型別的作用域單元甚至可 以實現維護起來更加優秀、簡潔的程式碼。

  • try…catch
    非常少有人會注意到 JavaScript 的 ES3 規範中規定 try/catch 的 catch 分句會建立一個塊作用域, catch 的引數變數僅在 catch 內部有效。
try{
    throw undefined;
}catch(a){
    a = 2;
    console.log(a); // 2
}
console.log(a);  // ReferenceError
複製程式碼
  • let

ES6的標準使我們能夠簡單的建立塊作用域,其中一個變數定義方式是let關鍵詞定義。

let定義的變數具有以下的特點:

  1. let隱形的建立塊作用域({…})
  2. let宣告的變數不能進行變數提升,因此只能先定義,後使用
{
    let a = 1;
    console.log(a); // 1
}
console.log(a);  // ReferenceError
複製程式碼

let一個典型的應用就是在for迴圈裡

我們看下面兩個例子:

// 每秒輸出一個5
for( var i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}

// 依次輸出0,1,2,3,4,時間間隔位1秒
for( let i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}
複製程式碼

其原因就是let形成了5個塊作用域,使每次輸出的變數都從本次迴圈的塊作用域中獲取。

當然我們還可以有其他方式做到第二種效果,我們將在 閉包,是真的美中說道。

  • const

除了 let 以外,ES6 還引入了 const,同樣可以用來建立塊作用域變數,但其值是固定的 (常量)。之後任何試圖修改值的操作都會引起錯誤。

var foo = true;
if (foo) { 
    var a=2;
    const b = 3; // 包含在 if 中的塊作用域常量

    a=3;//正常!
    b=4;//錯誤! 
}

console.log( a ); // 3
console.log( b ); // ReferenceError!
複製程式碼

作用域鏈

作用域鏈是由當前作用域與上層一系列父級作用域組成,作用域的頭部永遠是當前作用域,尾部永遠是全域性作用域。作用域鏈保證了當前上下文對其有權訪問的變數的有序訪問。

var a = 2;

function bar() {

    function foo() {
        console.log(a);
    } 
    
    foo();
}
bar(); // 2
複製程式碼

上面程式碼是由3層作用域氣泡組成,foo氣泡中試圖列印變數a,引擎在foo氣泡中未找到a變數,於是去其父作用域氣泡bar中尋找…以此類推直到找到全域性作用域氣泡,發現有變數a,將其值列印出來。如若沒找到,報ReferenceError錯誤。

相關文章