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

關於作用域: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

You Don't Know JS: Scope & Closures

Understanding Scope in JavaScript

Understanding ECMAScript 6

Everything you wanted to know about JavaScript scope

Understanding scope and visibility

JavaScript 的 this 原理

Stack的三種含義

TEMPORAL DEAD ZONE (TDZ) DEMYSTIFIED

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章