理解JavaScript的核心知識點:作用域

ushio發表於2019-02-16

關於作用域:About Scope

作用域是程式設計裡的基礎特性,是作用域使得程式執行時可以使用變數儲存值、記錄和改變程式的“狀態”。JavaScript 也毫不例外,但在 JavaScript 中作用域的特性與其他高階語言稍有不同,這是很多學習者久久難以理清的一個核心知識點。

定義:Definition

首先引用兩處我認為比較精闢的對作用域定義的總結:

Scope is the accessibility of variables, functions, and objects in some particular part of your code during runtime. In other words, scope determines the visibility of variables and other resources in areas of your code.

翻譯:作用域是在執行時對程式碼某些特定部分中的變數、函式和物件的可訪問性。換句話說,作用域決定程式碼區域中變數和其他資源的可見性。

Scope is the set of rules that determines where and how a variable (identifier) can be looked-up.

翻譯:作用域是一套規則,決定變數定義在何處以及如何查詢變數。

綜上所述,我們可以把作用域理解成是在一套在程式執行時控制變數訪問的管理機制。它規定了變數可見的區域、變數查詢規則、巢狀時的檢索方法。

目的:Purpose

利用作用域是為了遵循程式設計中的最小訪問原則,也稱最小特權原則,這是一種以安全性為考量的程式設計原則,可以便於快速定位錯誤,將發生錯誤時的損失控制在最低程度。這篇文章的這一部分舉了一個電腦管理員的例子來說明最小訪問原則在計算機領域的重要性。

在程式語言中,作用域還有另外兩個好處——規避變數名稱衝突和隱藏內部實現。

我們知道每個作用域具有自己的權利控制範圍,在不同的作用域中定義相同名稱的變數是完全可行的。實現這一可能性的底層機制叫做“遮蔽效益”。這一機制體在巢狀作用域下得到了更好的體現,因為變數查詢的規則是逐級向上,遇到匹配則停止,當內外層都有同名變數的時候,如已在內層找到匹配的變數,就不會再繼續向外層作用域查詢了,就像是內層的變數把外層的同名變數遮蔽住了一樣。是不是感覺非常熟悉?沒錯,這也是 JavaScript 中原型鏈查詢的內部機制!

隱藏內部實現其實是一種程式設計的最佳實踐,因為只要程式設計者願意,大可暴露出全部程式碼的內部實現細節。但眾所周知,這是不安全的。如果第三者在不可控的情況下修改了正常程式碼,影響程式的執行,這將帶來災難性的後果,這不僅是庫開發者們首先會考慮的安全性問題,也是業務邏輯開發者們需要謹慎對待的可能衝突,這就是模組化之所以重要的原因。其他程式語言在語法特性層面就支援共有和私有作用域的概念,而 JavaScript 官方暫時還沒有正式支援。目前用以隱藏內部實現的模組模式主要依賴閉包,所以閉包這一在JS領域具有獨特神祕性的機制被廣大開發者們又恨又愛。即便 ES6 的新模組機制支援以檔案形式劃分模組,仍然離不開閉包。

生成:Generate

作用域的生成主要依靠詞法定義,許多語言中有函式作用域和塊級作用域。JavaScript 主要使用的是函式作用域。怎麼理解詞法定義作用域?詞法就是書寫規則,編譯器會按照所書寫的程式碼確定出作用域範圍。

大多數程式語言裡都用 {} 來包裹一些程式碼語句,編譯器就會將它理解為一個塊級,它內部的範圍就是這個塊級的作用域,函式也是如此,寫了多少個函式就有相應數量的作用域。雖然 JavaScript 是少數沒有實現塊級作用域的程式語言,但其實在早期的 JavaScript 中就有幾個特性可以變相實現塊級作用域,如 withcatch 語句:with 語句會根據傳入的物件建立出一個特殊作用域,只在 with 中有效;而 catch 語句中捕捉到的錯誤變數在外部無法訪問的原因,正是因為它建立出了一個自己的塊級作用域,據 You Don`t Know JS 的作者說市面上支援塊級作用域書寫風格的轉譯外掛或 CoffeeScript 之類的轉譯語言內部都是依靠 catch 來實現的,that`s so tricky!

相關概念:Relevant Concepts

在這裡只討論 JavaScript 中以下概念的內容和實現方式。

詞法作用域:Lexical Scope

通過上面所說的相關知識可以總結出詞法作用域就是按照書寫時的函式位置來決定的作用域

看看下面這段程式碼,這段程式碼展示了除全域性作用域之外的 3 個函式作用域,分別是函式 a 、函式 b 、函式 c 所各自擁有的地盤:

function a () {
    var aa = `aa`;
    function b () {
        var bb = `bb`
        console.log(aa, bb)
        c();
    }
    b();
}

function c () {
    var cc = `cc`
    console.log(aa, bb, cc)
}
a();

各個變數所屬的作用域範圍是顯而易見的,但這段程式碼的執行結果是什麼呢?一但面臨巢狀作用域的情景,或許很多人又要猶疑了,接下來才是詞法作用域的重點。

上面程式碼的執行結果如下所示:

// b():
aa bb
// c():
Uncaught ReferenceError: aa is not defined

函式 c 的執行報錯了!錯誤說沒有找到變數 aa。按照函式呼叫時的程式碼來看,函式 c 寫在函式 b 裡,按道理來講,函式 c 不是應該可以訪問它巢狀的兩層父級函式作用域麼?從執行結果得知,詞法作用域不關心函式在哪裡呼叫,只關心函式定義在哪裡,所以函式 c 其實直接存在全域性作用域下,與函式 a 同級,它倆根本就是沒有任何交點的世界,無法互相訪問,這就是詞法作用域的法則!

請謹記 JavaScript 就是一個應用詞法作用域法則的世界。而按照函式呼叫時決定的作用域叫做動態作用域,在 JavaScript 裡我們不關心它,所以把它扔出字典。

函式作用域:Function Scope

很長時間以來,JavaScript 裡只存在函式作用域(讓我們暫時忽略那些裡世界的塊級作用域 tricky),所有的作用域都是以函式級別存在。對此做出最明顯反證的就是條件、迴圈語句。函式作用域的例子在上述詞法作用域中已經得到了很好的體現,就不再贅述了,這裡主要探討一下函式作用域鏈的機制。

以下面一段程式碼為例:

function c () {
    var cc = `cc`
    console.log(cc)
}
function a () {
    var aa = `aa`
    console.log(aa)
    b();
}
function b () {
    var bb = `bb`
    console.log(aa, bb)
}
a();
c();

一個程式裡可以有很多函式作用域,引擎怎麼確定先從哪個作用域開始,按照詞法規則先寫先執行?當然不,這時就看誰先呼叫。函式在作用域中的宣告會被提升,函式宣告的書寫位置不會影響函式呼叫,參照上例,即便是函式 a 定義在函式 c 後面,由於它會被先呼叫,所以在全域性作用域之後還是會先進入函式 a 的作用域,那函式 b 和函式 c 的順序又如何,為了解釋清楚詞法作用域是如何與函式呼叫機制結合起來,接下來要分兩部分研究程式執行的細節。

都說 JavaScript 是個動態程式語言,然而它的作用域查詢規則又是按照詞法作用域(也是俗稱的靜態作用域)規則來決定的,實在讓人費解。理解它動(執行時編譯)靜(執行前編譯)結合的關鍵在於引擎在執行程式時的兩個階段:編譯和執行。為了避免歧義,區分了兩個詞:

  • 執行:引擎對程式的整體執行過程,包括編譯、執行階段。
  • 執行:具體程式碼的執行或函式呼叫的過程。

JavaScript指的是在程式被執行時才進行編譯,僅在程式碼執行前。而一般語言是先經過編譯過程,隨後才會被執行的,編譯器與引擎執行是繼時性的。指函式作用域是根據編譯時按照詞法規則來確定的,不由呼叫時所處作用域決定。

簡單來說,函式的執行和其中變數的查詢是兩套規則:函式作用域中的變數查詢基於作用域鏈,而函式的呼叫順序依賴函式呼叫的背後機制——呼叫棧來決定。在編譯階段,編譯器收集了函式作用域的巢狀層級,形成了變數查詢規則依賴的作用域鏈。函式呼叫棧使函式像棧的資料結構一樣排成佇列按照先進後出的規則先後執行,再根據JavaScript 的同步執行機制,得出正確的執行順序是:函式 a =>函式 b =>函式 c。最後再結合詞法作用域法則推斷出上面示例的執行結果僅僅是一句報錯提示:Uncaught ReferenceError: aa is not defined。把函式 b 引用的變數 aa 去掉,就可以得到完整的執行順序的展示。

塊級作用域:Block Scope

letconst 宣告的出現終於打破了 JavaScript 裡沒有塊級作用域的規則,我們可以顯示使用塊級語法 {} 或隱式地與 letconst 相結合實現塊級作用域。

隱式(letconst 宣告會自動劫持所在作用域形成繫結關係,所以下例中並不是在 if 的塊級定義,而是在它的程式碼塊內部建立了一個塊級作用域,注意在 if 的條件語句中 a 尚未定義):

if (a === `a`) {
    let a = `a`
    console.log(a)
} else {
    console.log(`a is not defined!`)
}

顯式(顯式寫法揭露了塊級變數定義的真實所在):

// 普通寫法,稍顯囉嗦
if (true) {
    {
        let a = `a`
        ...
    }
}

// You Don`t Know JS的作者提倡的寫法,保持let宣告在最前,與程式碼塊語句區分開
if (true) {
    { let a = `a`
        ...
    }
}

// 希望未來官方能支援的寫法
if (true) {
    let (a = `a`) {
        ...
    }
}

關於塊級作用域最後要關注的一個問題是暫時性死區,這個問題可以描述為:當提前使用了以 var 宣告的變數得到的是 undefined,沒有報錯,而提前使用以 let 宣告的變數則會丟擲 ReferenceError。暫時性死區就是用來解釋這個問題的原因。很簡單,規範不允許在還沒有執行到宣告語句時就引用變數。來看一下根據官方非正式規範得出的解釋:

When a JavaScript engine looks through an upcoming block and finds a variable declaration, it either hoists the declaration to the top of the function or global scope (for var) or places the declaration in the TDZ (for let and const). Any attempt to access a variable in the TDZ results in a runtime error. That variable is only removed from the TDZ, and therefore safe to use, once execution flows to the variable declaration.

翻譯:當 JavaScript 引擎瀏覽即將出現的程式碼塊並查詢變數宣告時,它既把宣告提升到了函式的頂部或全域性作用域(對於 var ),也將宣告放入暫時性死區(對於 letconst)。任何想要訪問暫時性死區中變數的嘗試都會導致執行時錯誤。只有當執行流到達變數宣告的語句時,該變數才會從暫時性死區中移除,可以安全訪問。

另外,把 letvar 宣告作兩點比較能更好排除其他疑惑。以下述程式碼為例:

console.log(a);
var a;
console.log(b);
let b;
  • 變數提升letvar 定義的變數一樣都存在提升。
  • 預設賦值letvar 宣告卻未賦值的變數都相當於預設賦值 undefined

letvar 宣告提前引用導致的結果的區別僅僅是因為在編譯器在詞法分析階段,將塊級作用域變數做了特殊處理,用暫時性死區把它們包裹住,保持塊級作用域的特性。

全域性作用域:Global Scope

全域性作用域彷彿是透明存在的,容易受到忽視,就像人們經常忘記身處氧氣包裹中一樣,變數無法超越全域性作用域存在,人們也無法脫離地球給我們提供的氧氣圈。簡而言之,全域性作用域就是執行時的頂級作用域,一切的一切都歸屬於頂級作用域,它的地位如同宇宙。

我們在所有函式之外定義的變數都歸屬於全域性作用域,這個“全域性”視 JavaScript 程式碼執行的環境而定,在瀏覽器中是 window 物件,在 Node.js 裡就是 global 物件,或許以後還會有更多其他的全域性物件。全域性物件擁有的勢力範圍就是它們的作用域,定義在它們之中的變數對所有其他內層作用域都是可見的,即共享,所以開發者們都非常討厭在全域性定義變數,這繼承自上面所說的最小特權原則的思想,為安全起見,定義在全域性作用域裡的變數越少越好,於是一個叫做全域性汙染的話題由此引發。

全域性作用域在執行時會由引擎建立,不需要我們自己來實現。

區域性作用域:Local Scope

與全域性作用域相對的概念就是區域性作用域,或者叫本地作用域。區域性作用域就是在全域性作用域之下建立的任何內層作用域,可以說我們定義的任何函式和塊級作用域都是區域性作用域,一般在用來與全域性作用域做區別的時候才會採用這種概括說法。在開發中,我們主要關心的是使用函式作用域來實現區域性作用域的這一具體方式。

公有作用域:Public Scope

公有作用域存在於模組中,它是提供專案中所有其他模組都可以訪問的變數和方法的範圍或名稱空間。公私作用域的概念與模組化開發息息相關,我們通常關心的是定義在公私作用域中的屬性或方法。

模組化提供給程式更多的安全性控制,並隱蔽內部實現細節,但是要讓程式很好的實現功能,我們有訪問模組內部作用域中資料的需要。從作用域鏈的查詢機制可知,外層作用域是無法訪問內層作用域變數的,而JavaScript 中公私作用域的概念也不像其他程式語言中那麼完整,不能通過詞法直接定義公有和私有作用域變數,所以閉包成為了模組化開發中的核心力量。

閉包實現了在外層作用域中訪問內層作用域變數的可能,其方法就是在內層函式裡再定義一個內層函式,用來保留對想要訪問的函式作用域的記憶體引用,這樣外層作用域就可以通過這個保留引用的閉包來訪問內層函式裡的資料了。

通過下面兩段程式碼的執行結果就能看出區別:

function a () {
    var aa = `aa`
    function b () {
        var bb = `bb`
    }
    b()
    console.log(bb)
}
a()

控制檯報錯:Uncaught ReferenceError: bb is not defined,因為函式 b 在執行完後就從執行棧裡出棧了,其記憶體引用也被記憶體回收機制清理掉了。

function a () {
    var aa = `aa`
    function b () {
        var bb = `bb`
        return function c () {
            console.log(bb)
        }
    }
    var c = b()
    console.log(c())
}
a()

而這段程式碼中用變數 c 保留了對函式 b 中返回的函式 c 的引用,函式 c 又根據詞法作用域法則,能夠進入函式 b 的作用域查詢變數,這個引用形成的閉包就被儲存在函式 a 中變數 c 的值中,函式 a 可以在任何想要的時候呼叫這個閉包來獲取函式 b 裡的資料。此時這個被返回的變數 bb 就成為了暴露在函式 a 的作用域範圍內,定義在函式 b 裡的公有作用域變數。

更加通用的實現公有作用域變數或 API 的方式,稱為模組模式:

var a = (function a () {
    var aa = `aa`
    function b () {
        var bb = `bb`
        console.log(bb)
    }
    return {
        aa: aa,
        b: b
    }
})()
console.log(a.aa)
a.b()

使用閉包實現了一個單例模組,輸出了共有變數 a.aa 和 共有方法也稱 APIa.b

私有作用域:Private Scope

相對於公有作用域,私有作用域是存在於模組中,只能提供由定義模組直接訪問的變數和方法的範圍或名稱空間。要澄清一個關於私有作用域變數的的誤會,定義私有作用域變數,不一定是要完全避免被外部模組或方法訪問,更多時候是禁止它們被直接訪問。大多時候可以通過模組暴露出的公有方法來間接地訪問私有作用域變數,當然想不想讓它被訪問或者如何限制它的增刪改查就是開發者自己掌控的事情了。

接著上述公有作用域的實現,來看看私有作用域的實現。

var a = (function a () {
    var bb = `bb`
    var cc = `c`
    function b () {
        console.log(bb)
    }
    function c () {
        cc = `cc`
        console.log(cc)
    }
    return {
        b: b,
        c: c
    }
})()
a.b()
a.c()

在模組 a 中定義的屬性 bbcc 都是無法直接通過引用來獲取的。但是模組暴露的兩個方法 bc,分別實現了一個查詢操作和修改操作,間接控制模組中上述兩個私有作用域變數。

作用域與This:Scope vs This

在對作用域是什麼的理解中,最大的一個誤區就是把作用域當作 this 物件。

一個鐵打的證據是函式作用域的確定是在詞法分析時,屬於編譯階段,而 this 物件是在執行時動態繫結到函式作用域裡的。另一個更明顯的證據是當函式呼叫時,它們內部的 this 指的是全域性物件,而不是函式本身, 想必所有開發者都踩過這一坑,能夠理解作用域與 this 本質上的區別。從這兩點就可以肯定決不能把作用域與 this 等同對待。

this 到底是什麼?它跟作用域有很大關係,但具體留到以後再討論吧。在此之前我們先要與作用域成為好朋友。

參考文獻:Reference

相關文章