學習JavaScript作用域

YaeSakura發表於2019-04-08

作用域是什麼?

所有的程式語言都可以儲存,訪問,修改變數。但是這些變數如何儲存,程式如何找到並且能夠使用它們?這些問題需要設計一套規則,這套規則就被稱為我們所熟知的作用域

瞭解JavaScript

在介紹作用域之前,先來了解JavaScript這門語言,通常百科的說法是JavaScript是一種高階的,解釋執行的程式語言。但事實上它也是一門編譯語言。也需要經歷傳統編譯語言的步驟。詞法分析語法分析程式碼生成這三個步驟統稱為“編譯”。對於JavaScript來說,大部分情況下編譯發生在程式碼執行前的幾微秒的時間內。

作用域如何工作

這裡就要說到JavaScript的工作原理,JavaScript工作時由引擎,編譯器以及作用域共同完成。例如var a = 1;,我們來簡單分析一下。

  1. 遇到var a編譯器首先詢問作用域是否已經有一個該名稱的變數存在同一個作用域的集合中。如果有,則編譯器忽略該宣告,繼續編譯;反之它會要求作用域在當前作用域的集合中宣告一個新的變數a
  2. 接下來編譯器會為引擎生成執行時所需的程式碼,這些程式碼被用來處理a=1這個賦值操作。引擎執行時會首先詢問作用域,在當前的作用域集合中是否存在一個a的變數。如果是,引擎就會使用這個變數;反之引擎繼續查詢該變數。
  3. 如果引擎找到了a變數,就會將1賦值給它。反之引擎就會丟擲一個異常。

作用域巢狀

當一個塊或函式巢狀在另一個塊或函式中時,就發生了作用域的巢狀。因此,在當前作用域中無法找到某個變數時,引擎就會在外層巢狀的作用域中繼續查詢,直到找到該變數或抵達最外層的作用域(全域性作用域)為止。

function foo(b) {
    return a + b;
}
var a = 1;
foo(2); // 3
複製程式碼

引擎會在foo的作用域中尋找a,沒有找到該變數,繼續向上層尋找也就是全域性作用域,然後在全域性作用域中尋找到變數a。引擎在遍歷過程中,會產生一個作用域鏈。作用域鏈的用途,確保變數和函式有規則的訪問。

JavaScript作用域

詞法作用域

詞法作用域就是定義在詞法階段階段的作用域。直觀的說法就是詞法作用域是由你寫程式碼時將變數和塊作用域寫在哪裡來決定的。定義比較抽象,這裡舉例說明。

function foo(a) {
    var b = a + 1;
    function bar(b) {
        var c = b + 1;
        console.log(a, b, c);
    }
    bar(b);
}
var a = 1;
foo(a); //1, 2, 3
複製程式碼

為了幫助理解,可以想象成逐級包含的氣泡。如圖所示。

學習JavaScript作用域

  • 1中包含著整個作用域,其中有兩個識別符號a,foo。
  • 2中包含foo所建立的作用域,其中有兩個識別符號b,bar。
  • 3中包含bar所建立的作用域,其中有一個識別符號c。

當引擎console.log(a, b, c)執行時。它首先從最內部的作用域,也就是bar()函式的作用域氣泡開始查詢。引擎無法在找到a,因此會繼續遍歷到上層foo()函式的作用域查詢。還是沒有找到a,引擎繼續向上遍歷查詢,在全域性作用域中找到了a,引擎就會使用這個引用。同理bc一樣引擎重複a的方式進行查詢。

作用域查詢會在找到第一個匹配的識別符號時停止。

此外還有2種修改詞法作用域的方法eval()with。使用這兩個方法會對效能產生影響。因為JavaScript引擎會在編譯階段進行效能優化,其中一些優化依賴程式碼的詞法分析,如果使用eval()with其中的程式碼無法得到優化。這裡就不展開說明官方文件都有很詳細的說明eval()with

函式作用域

JavaScript中最常見的就是基於函式的作用域,每宣告一個函式都會為其自身建立一個作用域氣泡。

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

這個程式碼片段中,foo()的作用域中包含了識別符號abcbar,全域性作用域中包含一個識別符號foo。由於識別符號abcbar都屬於foo()的作用域,因此無法在外部對它們進行訪問。也就是說在全域性作用域中進行訪問,下面程式碼會導致錯誤:

    console.log(a, b, c);
    bar();
複製程式碼

函式作用域的含義是指,屬於這個函式的識別符號都可以在整個函式的範圍內使用及複用。

隱藏內部實現

對於函式的認知先宣告一個函式,然後向裡面新增程式碼。如果反過來,從程式碼中挑選一個片段,然後用函式宣告對它進行包裝。實際就是把這段程式碼內部“隱藏”起來。並且這個程式碼片段擁有自己的作用域。在實際開發中有很多情況也會使用這種作用域的隱藏方法。比如某個模組或API設計,只對外暴露方法和介面,不暴露內部的實現方法和變數。例如:

function foo(a) {
    b = a + bar(1);
    console.log(b * 2);
}
function bar(c) {
    return c + 1;    
}
var b;
foo(3);
複製程式碼

在這段程式碼中變數b和函式bar()應該是函式foo()內部的具體實現內容,但是外部作用域也有訪問 bbar()的許可權。因為它們有可能被有意或無意地以非預期的方式使用。這裡需要更合理的設計,例如:

function foo(a) {
    function bar(c) {
        return c + 1;    
    }
    var b;
    b = a + bar(1);
    console.log(b * 2);
}
foo(3);
複製程式碼

現在變數b和函式bar()都無法從外部直接被訪問,只能在foo()中使用,功能和結果都沒有受影響。設計良好的軟體都會將一些內容私有化。

變數衝突

隱藏內部實現的另一個好處就是可以避免同名識別符號的衝突,這在軟體設計中很常見,兩個識別符號可能具有相同的名字但是用途卻完全不一樣。無意間導致命名衝突,變數的值被意外覆蓋。例如:

function foo() {
    function bar(a) {
        i = 5;  //修改迴圈作用域i
        console.log(a + i);
    }
    var i = 1;
    while(i < 10) {
        bar(i); //無限迴圈了
        i ++;
    }
}
foo();
複製程式碼

bar()內部的賦值語句i = 5意外地覆蓋了宣告在foo()函式中的i,導致無限迴圈。bar()內部需要宣告一個本地變數來使用或者採用一個完全不同的識別符號,例如var j = 5,這樣就能避免變數衝突。

塊作用域

儘管大部分情況都普遍使用函式作用域,但也存在塊作用域。

with

with這裡不做詳細說明,with可以檢視mdn官方文件。

try/catch

try {
    empty(); // 執行一個不存在的方法來丟擲異常
} catch (error) {
    console.log(error);  // 能夠正常執行!
}
console.log(error);   // Uncaught ReferenceError: error is not defined
複製程式碼

try/catchcatch語句會建立一個塊作用域,其中宣告的變數只能在catch中訪問。

let

ES6引入了新的let關鍵字,let語句宣告一個塊級作用域的本地變數,並且可選的將其初始化為一個值。let關鍵字可以將變數繫結到所在的任意作用域中(通常用{...})。例如:

function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的變數
    console.log(x);  // 2
  }
  console.log(x);  // 1
}
letTest();
複製程式碼

const

ES6還引入了const關鍵字,宣告一個塊級作用域常量,其值是固定不可更改的,常量的值不能通過重新賦值來改變,並且不能重新宣告。試圖修改值的操作都會報錯。

function constTest() {
    if(true) {
        var a = 1;
        const b = 2;
        a = 3;
        b = 4; //  Uncaught TypeError: Assignment to constant variable.
    }
     console.log(a); 
     console.log(b);//  Uncaught ReferenceError: b is not defined
}
constTest();
複製程式碼

參考

結尾

學習JavaScript也有幾年了,一直都是很零碎的學習。寫此文的目的一方面是寫給自己看的筆記,一方面也是對知識的總結。

相關文章