理解 JavaScript 中的作用域

4Ark發表於2019-01-11

前言

學習 JavaScript 也有一段時間,今天抽空總結一下作用域,也方便自己以後翻閱。

什麼是作用域

如果讓我用一句簡短的話來講述什麼是作用域,我的回答是:

其實作用域的本質是一套規則,它定義了變數的可訪問範圍,控制變數的可見性和生命週期。

既然作用域是一套規則,那麼究竟如何設定這些規則呢?

先不急,在這之前,我們先來理解幾個概念。

編譯到執行的過程

下面我們就拿這段程式碼來講述 JavaScript 編譯到執行的過程。

var a = 2;
複製程式碼

首先我們來看一下在這個過程中,幾個功臣所需要做的事。

  1. 引擎(總指揮):

    從頭到尾負責整個 JavaScript 程式的編譯及執行過程。

  2. 編譯器(勞工):

    1. 詞法分析(分詞)

      解析成詞法單元,vara=2

    2. 語法分析(解析)

      將單詞單元轉換成抽象語法樹(AST)。

    3. 程式碼生成

      將抽象語法樹轉換成機器指令。

  3. 作用域(倉庫管理員):

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

然後我們再來看,執行這段程式碼時,每個功臣是怎麼協同工作的。

引擎:

其實這段程式碼有兩個完全不同的宣告,var aa = 2,一個由編譯器在編譯時處理,另一個則由引擎在執行時處理。

編譯器:

  1. 一套編譯器常規操作下來,到程式碼生成步驟。
  2. 遇到var a,會先詢問作用域中是否已經存在同名變數,如果是,則忽略該宣告,繼續進行編譯;否則它會要求作用域宣告一個新的變數a
  3. 為引擎生成執行a = 2時所需的程式碼。

引擎:

會先詢問作用域是否存在變數a,如果是,就會使用這個變數進行賦值操作;否則一直往外層巢狀作用域找(詳見作用域巢狀),直至到全域性作用域都沒有時,丟擲一個異常。

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

LHS & RHS 查詢

從上面可知,引擎在獲得編譯器給出的程式碼後,還會對作用域進行詢問變數。

聰明的你肯定一眼就看出,LR的含義,它們分別代表左側和右側。

現在我們把程式碼改成這樣:

var a = b;
複製程式碼

這時引擎對a進行 LHS 查詢,對b進行 RHS 查詢,但是LR並不一定指操作符的左右邊,而應該這樣理解:

LHS 是為了找到賦值的目標。 RHS 是賦值操作的源頭。也就是 LHS 是為了找到變數這個容器本身,給它賦值,而 RHS 是為了取出這個變數的值。

作用域巢狀

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

詞法作用域

作用域分為兩種:

  1. 詞法作用域(較為普遍,JavaScript所使用的也是這種)
  2. 動態作用域(使用較少,比如 Bash 指令碼、Perl 中的一些模式等)

詞法作用域是由你在寫程式碼時將變數和塊作用域寫在哪裡來決定的。

看以下程式碼,這個例子中有三個逐級巢狀的作用域。

var a = 2; // 作用域1 全域性
function foo(){ 
    var b = a * 2; // 作用域2 區域性
    function bar(){
		var c = a * b; // 作用域3 區域性
    }
}
複製程式碼
  1. 作用域是由你書寫程式碼所在位置決定的。
  2. 子級作用域可以訪問父級作用域,而父級作用域則不能訪問子級作用域。

引擎對作用域的查詢

作用域查詢會在找到第一個匹配的識別符號時停止,在多層的巢狀作用域中可以定義同名的識別符號,這叫做“遮蔽效應”(內部的識別符號“遮蔽”了外部的識別符號)。也就是說查詢時會從執行所在的作用域開始,逐級往上查詢,直到遇見第一個識別符號為止。

全域性變數(全域性作用域下定義的變數)會自動變成全域性物件(比如瀏覽器中的 window物件)。

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

非全域性的變數如果被遮蔽了,就無論如何都無法被訪問到,所以在上述程式碼中,bar內的作用域無法訪問到foo下定義的變數a

詞法作用域查詢只會查詢一級識別符號,比如ab,如果是foo.bar,詞法作用域查詢只會試圖查詢foo識別符號,找到這個變數後,由物件屬性訪問規則接管屬性的訪問。

欺騙語法

雖然詞法作用域是在程式碼編寫時確定的,但還是有方法可以在引擎執行時動態修改詞法作用域,有兩種機制:

  1. eval
  2. with

eval

JavaScript 的 eval函式可以接受一個字串引數並作為程式碼語句來執行, 就好像程式碼是原本就在那個位置一樣,考慮以下程式碼:

function foo(str){
    eval(str) // 欺騙
    console.log(a);
}
var a = 1;
foo("var a = 2;"); // 2
複製程式碼

彷彿eval中傳入的引數語句原本就在那一樣,會建立一個變數a,並遮蔽了外部作用域的同名變數。

注意

  • eval通常被用來執行動態建立的程式碼,可以根據程式邏輯動態地將變數和函式以字串形式拼接在一起之後傳遞進去。
  • 在嚴格模式下,eval無法修改所在的作用域。
  • eval相似的還有,setTimeoutsetIntervalnew Function

with

with通常被當作重複引用同一個物件中的多個屬性的快捷方式, 可以不需要重複引用物件本身。

使用方法如下:

var obj1 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
    }
}
foo(obj1);
console.log(obj1); // {a: 2, b: 3}
複製程式碼

然而考慮以下程式碼:

var obj2 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
        c = 4;
    }
}
foo(obj2);
console.log(obj2); // {a: 2, b: 3}
console.log(c); // 4 不好,c被洩露到全域性作用域下
複製程式碼

儘管with可以將物件處理為詞法作用域,但是這樣塊內部正常的var操作並不會限制在這個塊的作用域下,而是被新增到with所在的函式作用域下,而不通過var宣告變數將視為宣告全域性變數。

效能

evalwith會在執行時修改或建立新的作用域,以此來欺騙其他書寫時定義的詞法作用域,然而 JavaScript 引擎會在編譯階段進行效能優化,有些優化依賴於能夠根據程式碼的詞法進行靜態分析,並預先確定所有的變數和函式的定義位置,才能在執行過程中快速找到識別符號。但是通過evalwith來欺騙詞法作用域會導致引擎無法知道他們對詞法作用域做了什麼樣的改動,只能對部分不進行優化,因此如果在程式碼中大量使用evalwith就會導致程式碼執行起來變得非常慢。

函式作用域和塊作用域

函式作用域

在 JavaScript 中每宣告一個函式就會建立一個函式作用域,同時屬於這個函式的所有變數在整個函式的範圍內都可以使用。

塊作用域

從 ES3 釋出以來,JavaScript 就有了塊作用域,建立塊作用域的幾種方式有:

  • with

    上面已經講了,這裡不再複述。

  • try/catch

    try/catchcatch 分句會建立一個塊作用域,其中宣告的變數僅在 catch 內部有效。

    try{
    	throw 2;
    }catch(a){
    	console.log(a);
    }
    複製程式碼
  • letconst

    ES6 引入的新關鍵詞,提供了除 var 以外的變數宣告方式,它們可以將變數繫結到所在的任意作用域中(通常是{}內部)。

    {
        let a = 2;
    }
    console.log(a); // ReferenceError: a is not defined
    複製程式碼

    注意:使用 letconst 進行的宣告不會在塊作用域中進行提升。

提升

考慮這段程式碼:

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

輸入結果是undefined,而不是ReferenceError

為什麼呢?

前面說過,編譯階段時,會把宣告分成兩個動作,也就是隻把var a部分進行提升。

所以第二段程式碼真正的執行順序是:

var a; // 這時 a 是 undefined
console.log(a);
a = 2;
複製程式碼
  • 編譯階段時會把所有的宣告操作提升,而賦值操作原地執行。
  • 函式宣告會把整個函式提升,而不僅僅是函式名。

函式優先

雖然函式和變數都會被提升,但函式宣告的優先順序高於變數宣告,所以:

foo(); // 1
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}
複製程式碼

因為這個程式碼片段會被引擎理解為如下形式:

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

這個值得一提的是,儘管var foo出現在function foo()...之前,但由於函式宣告會被優先提升,所以它會被忽略(因為重複宣告瞭)。 注意:

JavaScript 會忽略前面已經宣告過的宣告,不管它是變數還是函式,只要其名稱相同。

後記

因為篇幅原因,有一部分內容只是大概提到,並沒有太過於詳細的講解,如果你感興趣,那麼我推薦你看看**《你不知道的 JavaScript(上)》**這本書,書上對此內容有很詳細的說明。

本文也是作者一邊檢視此書一邊結合自己的理解來進行編寫的。

其實作用域還有一個非常重要的概念,那就是閉包。但閉包也是 JavaScript 中的一個非常重要卻又難以掌握的,所以需要另開一篇文章來介紹。

最後,我想說的就是,在這個框架工具流行的時代,我們往往會被這些新東西所吸引,卻忽略了最本質的東西,諸諸不知,恰恰是這些我們所忽略的東西才是最重要的,所有的 JavaScript 框架工具都是基於這些內容。所以,不妨回過頭來看看這些原生的東西,相信你會更上一層樓。

謝謝觀看!

注:此文為原創文章,如需轉載,請註明出處。

相關文章