深入理解JavaScript作用域

木子星兮發表於2020-07-21

在上一篇文章 深入理解JavaScript 執行上下文 中提到 只有理解了執行上下文,才能更好地理解 JavaScript 語言本身,比如變數提升,作用域,閉包等,本篇文章就來說一下 JavaScript 的作用域。

這篇文章稱為筆記更為合適一些,內容來源於 《你不知道的JavaScript(上卷)》第一部分 作用域和閉包。講的很不錯,非常值得一看。

什麼是作用域

作用域是根據名稱查詢變數的一套規則

理解作用域

先來理解一些基礎概念:

  • 引擎:從頭到尾負責整個JavaScript程式的編譯及執行過程。
  • 編譯器:負責語法分析和程式碼生成。這部分也可以看 JavaScript程式碼是如何被執行的
  • 作用域:負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。

接下來來看看下面程式碼的執行過程:

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

總結:變數的賦值操作會執行兩個動作,首先編譯器會在當前作用域中宣告一個變數,然後在執行時引擎就會會作用域中查詢該變數,如果能夠找到就對它賦值。

編譯器在編譯過程的第二步中生成了程式碼,引擎執行它時,會通過查詢變數 a來判斷它是否已宣告過。查詢的過程中由作用域進行協助,但是引擎執行怎麼樣的查詢,會影響最終的查詢結果。

在我們的例子中,引擎會為變數 a 進行 LHS 查詢,另外一個查詢的型別叫做 RHS。 ”L“ 和 "R" 分別代表一個賦值操作左側和右側。當變數出現在賦值操作的左側時進行 LHS 查詢,出現在右側時進行 RHS 查詢。

LHS:試圖找到變數的容器本身,從而可以對其賦值;RHS: 就是簡單地查詢某個變數的值。
console.log(a);

對 a 的引用是一個 RHS 引用,因為這裡 a 並沒有賦予任務值,相應地需要查詢並取得 a 的值,這樣才能將值傳遞給 console.log(...)

a = 2;

這裡對 a 的引用是 LHS 引用,因為實際上我們並不關心當前的值是什麼,只是想要為 = 2這個賦值操作找到目標。

funciton foo(a) {
    console.log(a)
}

foo(2);
  1. 最後一行 foo 函式的呼叫需要對 foo 進行 RHS 引用,去找 foo的值,並把它給我
  2. 程式碼中隱式的 a = 2 操作可能很容易被你忽略掉,這操作發生在 2 被當做引數傳遞給 foo 函式時,2 會被分配給引數 a,為了給引數 a (隱式地) 分配值,需要進行一次 LHS 查詢。
  3. 這裡還有對 a 進行的 RHS 引用,並且將得到的值傳給了 console.log(...)console.log(...) 本身也需要一個引用才能執行,因此會對 console物件進行 RHS 查詢,並且檢查得到的值中是否有一個叫做 log的方法。

RHS查詢在所有巢狀的作用域中遍尋不到所需的變數,引擎就會丟擲 ReferenceError 異常。進行RHS查詢找到了一個變數,但是你嘗試對這個變數的值進行不合理的操作,比如試圖對一個非函式型別的值進行呼叫,後者引用null或 undefined 型別的值中的屬性,那麼引擎會丟擲一個另外一種型別的異常 TypeError。
引擎執行 LHS 查詢時如果找不到該變數,則會在全域性作用域中建立一個。但是在嚴格模式下,並不是自動建立一個全域性變數,而是會丟擲 ReferenceError 異常

補充JS幾種常見的錯誤型別

簡單總結如下:

作用域是一套規則,用於確定在哪裡找,怎麼找到某個變數。如果查詢的目的是對變數進行賦值,那麼就會使用 LHS查詢; 如果目的是獲取變數的值,就會使用 RHS 查詢;
JavaScript 引擎執行程式碼前會對其進行編譯,這個過程中,像 var a = 2 這樣的宣告會被分解成兩個獨立的步驟

  1. var a 在其作用域中宣告變數,這會在最開始的階段,也就是程式碼執行前進行
  2. 接下來,a = 2 會查詢 (LHS查詢)變數 a 並對其進行賦值。

詞法作用域

詞法作用域是你在寫程式碼時將變數寫在哪裡來決定的。編譯的詞法分析階段基本能夠知道全域性識別符號在哪裡以及是如何宣告的,從而能夠預測在執行過程中如果對他們查詢。

有一些方法可以欺騙詞法作用域,比如 eval, with, 這兩種現在被禁止使用,1是嚴格模式和非嚴格模式下表現不同 2是有效能問題, JavaScript引擎在編譯階段會做很多效能優化,而其中很多優化手段都依賴於能夠根據程式碼的詞法進行靜態分析,並預先確定所有變數和函式的定義位置,才能在執行過程中快速找到識別符,eval, with會改變作用域,所以碰到它們,引擎將無法做優化處理。

全域性作用域和函式作用域

全域性作用域

  • 在最外層函式和最外層函式外面定義的變數擁有全域性作用域
var a = 1;
function foo() {

}

變數a 和函式宣告 foo 都是在全域性作用域中的。

  • 所有未定義直接賦值的變數自動宣告為擁有全域性作用域
var a = 1;
function foo() {
    b = 2;
}
foo();
console.log(b); // 2
  • 所有 window 物件的屬性擁有全域性作用域

函式作用域

函式作用域是指在函式內宣告的所有變數在函式體內始終是可見的。外部作用域無法訪問函式內部的任何內容。

function foo() {
    var a = 1;
    console.log(a); // 1
}
foo();
console.log(a); // ReferenceError: a is not defined
只有函式的{}構成作用域,物件的{}以及 if(){}都不構成作用域;

變數提升

提升是指宣告會被視為存在與其所出現的作用域的整個範圍內。

JavaScript編譯階段是找到找到所有宣告,並用合適的作用域將他們關聯起來(詞法作用域核心內容),所以就是包含變數和函式在內的所有宣告都會在任何程式碼被執行前首先被處理。

每個作用域都會進行提升操作。

function foo() {
    var a;
    console.log(a); // undefined
    a = 2;
}
foo();
注意,函式宣告會被提升,但是函式表示式不會被提升。

關於 塊級作用域和變數提升的內容之前在 從JS底層理解var、let、const這邊文章中詳細介紹過,這裡不再贅述。

塊級作用域

我們來看下面這段程式碼

for(var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);
    })
}
console.log(`當前的i為${i}`); // 當前的i為5

上面這段程式碼我們希望是輸出 0,1, 2, 3, 4 ,但是實際上輸出的是 5,5, 5, 5, 5。我們在 for 迴圈的頭部直接定義了變數 i,通常是因為只想在 for 迴圈內部的上下文中使用 i,但是實際上 此時的 i 被繫結在外部作用域(函式或全域性)中。

,塊級作用域是指在指定的塊級作用域外無法訪問。在ES6之前是沒有塊級作用域的概念的,ES6引入了 let 和 const。我們可以改寫上面的程式碼,使它按照我們想要的方式執行。

for(let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);
    })
}
// 0 1 2 3 4
console.log(`當前的i為${i}`); // ReferenceError: i is not defined

此時 for 迴圈頭部的 let 不僅將 i 繫結到了 for 迴圈的迭代中,事實上將它重新繫結到了迴圈的每一個迭代中,確保使用上一次迴圈迭代結束的值重新進行賦值。

let宣告附屬於一個新的作用域而不是當前的函式作用域(也不屬於全域性作用域)。但是其行為是一樣的,可以總結為:任何宣告在某個作用域內的變數,都將附屬於這個作用域。
const也是可以用來建立塊級作用域變數,但是建立的是固定值。

作用域鏈

JavaScript是基於詞法作用域的語言,通過變數定義的位置就能知道變數的作用域。全域性變數在程式中始終都有都定義的。區域性變數在宣告它的函式體內以及其所巢狀的函式內始終是有定義的。

每一段 JavaScript 程式碼都有一個與之關聯的作用域鏈(scope chain)。這個作用域鏈是一個物件列表或者連結串列。當 JavaScript 需要查詢變數 x 的時候(這個過程稱為變數解析),它會從鏈中的第一個變數開始查詢,如果這個物件上依然沒有一個名為 x 的屬性,則會繼續查詢鏈上的下一個物件,如果第二個物件依然沒有名為 x 的屬性,javaScript會繼續查詢下一個物件,以此類推。如果作用域鏈上沒有任何一個物件包含屬性 x, 那麼就認為這段程式碼的作用域鏈上不存在 x, 並最終丟擲一個引用錯誤 (Reference Error) 異常。

下面作用域中有三個巢狀的作用域。

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log(a, b, c)
    }
    bar( b * 3);
}
foo(2);

氣泡1包含著整個全域性作用域,其中只有一個識別符號:foo;
氣泡2包含著foo所建立的作用域,其中有三個識別符號:a、bar 和 b;
氣泡3包含著 bar所建立的作用域,其中只有一個識別符號:c

執行 console.log(...),並查詢 a,b,c三個變數的引用。下面我們來看看查詢這幾個變數的過程.
它首先從最內部的作用域,也就是 bar(..) 函式的作用域氣泡開始找,引擎在這裡無法找到 a,因此就會去上一級到所巢狀的 foo(...)的作用域中繼續查詢。在這裡找到了a,因此就使用了這個引用。對b來說也一樣,而對 c 來說,引擎在 bar(..) 中就找到了它。

如果 a,c都存在於 bar(...) 內部,console.log(...)就可以直接使用 bar(...) 中的變數,而無需到外面的 foo(..)中查詢。作用域會在查詢都第一個匹配的識別符號時就停止。

在多層的巢狀作用域中可以定義同名的識別符號,這叫”遮蔽效應“。

var a = '外部的a';
function foo() {
    var a = 'foo內部的a';
    console.log(a); // foo內部的a
}
foo();

作用域與執行上下文

JavaScript的執行分為:解釋和執行兩個階段

解釋階段

  • 詞法分析
  • 語法分析
  • 作用域規則確定

執行階段

  • 建立執行上下文
  • 執行函式程式碼
  • 垃圾回收

作用域在函式定義時就已經確定了,而不是在函式呼叫時確定,但執行上下文是函式執行之前建立的。

總結

  1. 作用域就是一套規則,用於確定在哪裡找以及怎麼找到某個變數。
  2. 詞法作用域在你寫程式碼的時候就確定了。JavaScript是基於詞法作用域的語言,通過變數定義的位置就能知道變數的作用域。ES6引入的let和const宣告的變數在塊級作用域中。
  3. 宣告提升是指宣告會被視為存在與其所出現的作用域的整個範圍內。
  4. 查詢變數的時候會先從內部的作用域開始查詢,如果沒找到,就往上一級進行查詢,依次類推。
  5. 作用域在函式定義時就已經確定了,執行上下文是函式執行之前建立的。

參考

其他

最近發起了一個100天前端進階計劃,主要是深挖每個知識點背後的原理,歡迎關注 微信公眾號「牧碼的星星」,我們一起學習,打卡100天。同時也會分享一些自己學習的一些心得和想法,歡迎大家一起交流。

相關文章