JavaScript夯實基礎系列(一):詞法作用域

白馬笑西風發表於2019-03-18

  作用域是一組規則,規定了引擎如何通過識別符號名稱來查詢一個變數。作用域模型有兩種:詞法作用域動態作用域。詞法作用域是在編寫時就已經確定的:通過閱讀包含變數定義的數行原始碼就能知道變數的作用域。JavaScript採用的是詞法作用域,也稱為執行環境。動態作用域不是在程式碼編寫時靜態決定的,而是在執行過程中被確定。JavaScript實際上沒有動態作用域,但是this的用法有些像動態作用域。靜態作用域關心函式在何處被宣告,而動態作用域關心函式在何處被呼叫。

一、作用域鏈

  在ES6之前,一般認為JavaScript只有全域性作用域以及基於函式的作用域,沒有塊級作用域(有兩種特殊情況:with以及try/catch中的catch塊,with在嚴格模式下不能使用,基本已廢棄,不推薦使用。catch的塊級作用域性質不常用,可用來ES6塊級作用域的Polyfill)。ES規範強制規定全域性變數是全域性物件的屬性,但是對於區域性變數沒有類似的規定。區域性變數基本是函式內的變數(沒有使用varlet或者const宣告的變數為全域性變數)以及函式引數。一般認為,區域性變數是一個跟所在函式關聯的變數物件的屬性。這個變數物件在ES3中叫呼叫物件,在ES5中叫宣告上下文物件,該物件對我們是透明的,不可見。全域性物件我們可以通過this關鍵字來引用。
  當程式執行到函式中時,會產生一個跟該函式相關的作用域鏈,並把作用域鏈賦值給函式的一個特殊的內部屬性(即[[Scope]]),作用域鏈是一個物件列表或者連結串列。拿作用域鏈的連結串列實現來說,連結串列的尾結點是全域性物件,巢狀函式的每一層函式對應連結串列上的一個節點,該節點包含兩個指標,分別指向函式對應的變數物件和包含函式對應的節點。注意:每一個執行中得函式都有一個作用域鏈,包含函式與被包含函式並不是在同一個作用域鏈上。可以想象,當程式從包含函式流進被包含函式時,被包含函式的作用域鏈生成過程是:先複製包含函式的作用域鏈,然後生成一個節點,該節點包含一個指向自身函式對應的變數物件,最後以前插法的方式將該節點插入到新生成連結串列的頭部,所有連結串列的尾部節點都是指向全域性物件。
  JavaScript在查詢一個變數時,即變數解析,會從對應作用域鏈的頭部開始查,如果在頭部指標指向的變數物件中找到該變數,則停止查詢,採用找到的該變數的值。如果沒有找到,則沿著連結串列逐級查詢,直到查詢到連結串列尾部節點對應的變數物件,即全域性物件,如果最終沒有查詢到變數,則會報ReferenceError的錯誤。
  例如:下面程式碼的作用域鏈如下圖所示。全域性變數的作用域鏈只包含指向全域性物件的指標;函式a的作用域鏈有兩個節點,分別指向函式a對應的變數物件和全域性物件;函式b的作用域鏈有三個節點,分別對應指向b對應的變數物件、指向a對應的變數物件和全域性物件。函式a不能訪問函式b中的變數test3,因為在函式a的作用域鏈中並不包括函式b對應的變數物件。如果在函式a中使用test3,解析變數test3時,首先從a的作用域鏈頭部開始,查詢a對應的變數物件,然後查詢全域性物件,都沒有找到,程式會提示引用錯誤。

var test1 = 'global' // 全域性變數
a()

function a () {
    var test2 = 'a' // 函式a的區域性變數
    console.log(test2)
    b()

    function b () {
        var test3 = 'b' // 函式b的區域性變數
        console.log(test2 + test3)
    }
}
複製程式碼

作用域鏈圖片

  有一種特殊情況需要注意,函式的變數物件上存在兩個特殊的變數:thisarguments,函式搜尋這兩個變數時只會在對應的變數物件上搜尋,不會沿著作用域鏈搜尋外層環境的變數物件。

二、延長作用域鏈

  JavaScript中有兩種方式可以延長作用域鏈,ES3之前的with以及ES3新增的try/catch。程式執行到這兩個語句時,會在作用域鏈的最前端新增一個指向新變數物件的節點,該變數物件會在程式執行完畢後被移除。

1、with

  當我們多次使用一個物件的屬性時,每次都需要在需要呼叫的屬性前加上物件,寫法相對繁瑣,使用with可以減少所寫程式碼量。

function getUrl () {
    var obj = {}
    obj.a = location.hash
    obj.b = location.href
    return obj
}

// 使用 with 改寫
function useWith () {
    var obj = {}
    with(location) {
        obj.a = hash
        obj.b = href
    }
    return obj
}
複製程式碼

  with會將指定的物件新增到作用域鏈中。值得注意的事,在查詢變數的時候才會用到作用域鏈,建立新變數的時候不使用。因此在使用with時,有些時候會遇到一些奇怪的情況。如下程式碼所示:

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

var obj1 = { a: 3 };
var obj2 = { b: 3 };

test( obj1 );
console.log( obj1.a ); // 2

test( obj2 );
console.log( obj2.a ); // undefined
console.log( a ); // 2 ---全域性作用域被洩漏了!
複製程式碼

  在函式test()執行時,with語句延長了函式test的作用域鏈,物件obj將被新增到作用域鏈中。分別將obj1obj2作為引數傳遞給函式時,JavaScript引擎會沿著作用域鏈查詢變數。當引數是obj1時,引擎查詢到其擁有該變數,因此給obj1a屬性複製為2。當引數是obj2時,引擎沒有在obj2中找到該變數,接著在函式test的變數物件上查詢,仍然沒有找到該變數,最後在全域性物件上查詢。在都沒有找到情況下,a=2語句分兩步執行,首先建立一個新的全域性變數a,然後從作用域鏈重新執行一遍查詢,最終在全域性物件上找到變數a,執行賦值操作,導致全域性作用域被洩漏。
  with語句在嚴格模式下不能使用,在非嚴格模式下也不提倡使用,該語句被廢棄的原因主要是效能問題。JavaScript引擎在編譯階段會做很多效能優化的工作,優化的方法之一就是在詞法分析的時候靜態的分析程式碼,提前決定變數和函式宣告在什麼位置,執行的時候能夠更快速。with語句可以新增指定物件到作用域鏈,JavaScript引擎沒辦法提前分析你會往這個作用域鏈的頂端放什麼樣的變數物件,因此在詞法分析時通過分析程式碼來決定變數和函式宣告位置的結果將被全部推翻,使得優化變的毫無意義,沒有經過優化的程式碼肯定比優化過的程式碼慢。

2、try/catch

  try/catch/finally語句是JavaScript的異常處理機制。try從句定義了需要處理的異常所在的程式碼塊,當try塊程式碼出現異常時,就會呼叫catch塊中的的程式碼。不論try塊中的程式碼是否出現異常,finally塊內的程式碼必定執行。

try {
    undefined(); // 用非法的操作強制產生一個異常!
}
catch (e) {
    e = 1
    console.log(e); // 1
}

console.log(e); // ReferenceError: `err` not found
複製程式碼

  catch塊會建立一個新的變數物件,將變數物件新增到作用域鏈頂端。換而言之,catch塊中定義的變數擁有塊級作用域。上面程式碼顯示的變數e只存在於catch塊,在外部引用會報錯。catch塊的塊級作用域性質可以shim ES6中新新增的letconst等塊級作用域。在IE8以及之前的版本中,catch塊捕獲的變數物件會新增到所在函式的變數物件上,如果不被函式包裹,則會新增到全域性物件上。

三、動態宣告變數

  eval()是全域性物件的一個函式屬性,eval()函式會將傳入的字串當做JavaScript程式碼執行,這種動態生成程式碼的方式有能力修改詞法作用域。如下程式碼:

function test(str) {
    eval( str );
    console.log( a ); // 2
}

test( "var a = 2" );
複製程式碼

  在嚴格模式下,傳入eval()的程式碼不能在呼叫程式的上下文中宣告變數和定義函式,變數和函式的定義是在eval()建立的新作用域中,這個作用域在eval()返回時就棄用了。如下程式碼:

"use strict"
function test(str) {
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
}

test( "var a = 2" );
複製程式碼

  另外,setTimeout()和setInterval()的第一個引數以及Function()的第最後一個引數都是接收一個字串來動態生成程式碼。
  動態生成程式碼會使JavaScript引擎在編譯階段幾乎不能通過做詞法分析來進行優化,因此使用動態生成程式碼會導致很差的效能,一般不提倡使用。

四、塊級作用域

  ES6新增了塊級作用域,let和const可以用來宣告塊級變數。

1、let

  let關鍵字將變數宣告附著在所在的塊作用域,使得變數只在所在的塊有定義。如下程式碼所示:

var test = true
if(test){
    let a = 1
    var b = 2
}
console.log(a) // ReferenceError: a is not defined
console.log(b) // 2
複製程式碼

  與var關鍵字不同,let不存在變數提升,let關鍵字宣告的變數一定要在宣告之後再使用。如下程式碼中可以看到var宣告的變數在宣告前就可以使用,let宣告的則不然。

console.log(a) // undefined
var a = 1

console.log(b) // ReferenceError: b is not defined
let b = 2
複製程式碼

  let關鍵字會產生一種叫做暫時性死區的情況。簡而言之,在程式碼塊中,使用let關鍵字宣告變數之前,該變數都是不可用的。如下程式碼所示:

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

  let關鍵字不允許在相同作用域內重複宣告變數。如下程式碼所示:

let a = 1
let a = 1  // Uncaught SyntaxError: Identifier 'a' has already been declared
複製程式碼

  let在迴圈中會與var有很大的區別,形如for (let x...)的迴圈在每次迭代時都為x建立新的繫結,改善了迴圈內變數過度共享的情況。如下程式碼所示:

var a = [];
var b = [];

for (var i = 0; i < 10; i++) {
    a[i] = function () {
        console.log(i);
    };
}
for (let j = 0; j < 10; j++) {
    b[j] = function () {
        console.log(j);
    };
}

a[6](); // 10
b[6](); // 6
複製程式碼

  另外,let還有一個很重要的特性。在JavaScript中頂層變數的屬性跟全域性變數掛鉤。頂層物件,在瀏覽器環境指的是window物件,在 Node 指的是global物件。ES6規定:let關鍵字、const關鍵字、class關鍵字宣告的全域性變數,不屬於頂層物件的屬性。也就是說,從 ES6 開始,全域性變數將逐步與頂層物件的屬性脫鉤。

2、const

  const關鍵字擁有上述let關鍵字的全部特性。此外,const關鍵字宣告變數時,必須賦值。如下程式碼則會報錯:

const a
console.log(a)  // Uncaught SyntaxError: Missing initializer in const declaration
複製程式碼

  const關鍵字宣告的變數一旦賦值後不能改變,實際是指該變數指向的記憶體地址不變。簡單資料型別,值就儲存在指向的記憶體地址中,而對於物件型別,該變數指向的記憶體地址中存放的是一個指標,該指標指向儲存在堆記憶體中的物件。const關鍵字保證了變數指向的記憶體地址不變,但是不能保證堆記憶體中物件的資料結構不發生變化。如下程式碼所示:

const a = 0
a = 1
console.log(a) // Uncaught TypeError: Assignment to constant variable.

const b = {}
b.val = 1
console.log(b) // {val:1}
複製程式碼

五、總結

  JavaScript中變數分為區域性變數和全域性變數,區域性變數是在函式使用關鍵字宣告的變數,全域性變數是指在全域性環境下宣告或者在函式中沒有通過關鍵字宣告的變數。區域性變數是所在函式對應的變數物件上的屬性,全域性變數是全域性變數物件的屬性,ES規範規定全域性變數物件為全域性物件。每個執行中的函式都有一個對應的作用域鏈,作用域鏈最前端是該函式對應的變數物件,其次是巢狀函式的變數物件,最後是全域性物件。JavaScript引擎查詢變數時,會沿著作用域鏈查詢,從所處函式的變數物件開始,直至全域性物件。在查詢過程中,找到要該的變數後查詢就會停止,如果直到全域性物件都沒查詢到該變數,則該變數不存在。
  通過with語句和try/catch語句中的catch可以延長作用域鏈,這兩條語句都是在作用域鏈最前端新增一個新的變數物件。eval()函式在非嚴格模式下可以動態宣告變數來修改作用域,類似的還有setTimeout()、setInterval()以及Function()函式。延長作用域鏈以及動態宣告變數來修改作用域鏈都會導致效能的降低,詞法作用域規則本質上是靜態的,JavaScript引擎在編譯程式碼時能通過確定變數在什麼位置來進行優化,一旦動態修改作用域鏈,優化工作很難進行。
  ES6新增letconst來宣告塊級作用域變數。與var相比,letconst不能重複定義,不存在變數提升,存在暫時性死區。在for迴圈的時候,var關鍵字宣告的計數變數會被共享,let則不會。const關鍵字宣告變數的時候必須賦值,如果值為基本型別則值不能改變,引用型別則引用型別的地址不能改變。
如需轉載,煩請註明出處:www.cnblogs.com/lidengfeng/…

相關文章