JavaScript 學(xué)習(xí)筆記——作用域

本人水平有限,如有敘述不當(dāng)或者錯(cuò)誤之處,望各位指出,十分感謝!

作用域

我們要知道,在 JavaScript 中,作用域分為三種類(lèi)型:

  • 全局作用域
  • 函數(shù)作用域
  • 塊級(jí)作用域

全局作用域

全局作用域,簡(jiǎn)而言之就是定義在所有函數(shù)外部,可以在程序任意地方訪問(wèn)到其中(全局)量的 規(guī)則(如瀏覽器窗體中可以隨處訪問(wèn) window;Web Service 或 Web Workers 中可以隨處訪問(wèn) self;Node.js 中可以隨處訪問(wèn) global)。在 JS 引擎一進(jìn)入一段有效的 JS 代碼,就會(huì)初始化一個(gè)全局對(duì)象,并使用 window/self/global 指向這個(gè)全局對(duì)象,這樣 全局作用域 便是最早出現(xiàn)的。

有趣的是 window = null 是無(wú)效操作,也就是說(shuō) window 是只讀的,有點(diǎn)像 ES5 的 InfinityNaN 是只讀的,不可修改(ES3 中可以修改),但是記這篇筆記時(shí) self = null、global = null 是有效的,危險(xiǎn)!

函數(shù)作用域

函數(shù)作用域,這又是什么呢?這里我們先引入新概念:

  • 詞法作用域(lexical scope / static scope)
  • 動(dòng)態(tài)作用域(dynamic scope)

我們知道,瀏覽器有一個(gè) JS 引擎線程,負(fù)責(zé) 編譯運(yùn)行 JS 代碼。因?yàn)?JS 文件在瀏覽器中下載后就需要生效,這種情景需求使得瀏覽器中的 JS 是即時(shí)編譯且即時(shí)執(zhí)行的。其中編譯階段會(huì)有一個(gè) 詞法分析 的過(guò)程,它讀到函數(shù)聲明時(shí)便會(huì)為此函數(shù)生成一個(gè) 符號(hào)表(Symbol Table,一種鍵值對(duì)形式的哈希表數(shù)據(jù)結(jié)構(gòu),保存了函數(shù)的作用域信息,包括標(biāo)識(shí)符、內(nèi)存地址、數(shù)據(jù)類(lèi)型、內(nèi)部變量等),如果函數(shù)內(nèi)還嵌套了子函數(shù),引擎便會(huì)為其生成一個(gè)子表,以此類(lèi)推,形成一個(gè)樹(shù)的結(jié)構(gòu),而一個(gè)符號(hào)表即為其節(jié)點(diǎn)。

這個(gè)過(guò)程是在編譯時(shí)完成的,我們可以看到這里已經(jīng)生成了一個(gè)樹(shù),而到執(zhí)行階段時(shí),遇到 取值操作 (RHS)時(shí)便會(huì)在其相應(yīng)的符號(hào)表中進(jìn)行查詢(xún)操作,如果查找到了變量,就會(huì)將其值返回作為取值操作的結(jié)果;如果找不到,就順著樹(shù)的結(jié)構(gòu)往上級(jí)節(jié)點(diǎn)(即,上級(jí)嵌套作用域)查詢(xún),直到找到這個(gè)變量對(duì)應(yīng)的標(biāo)識(shí)符,取得其值;否則,若到了根節(jié)點(diǎn)(全局作用域)都無(wú)法找到其值,就返回 ReferenceError 引用錯(cuò)誤。

這時(shí),我們發(fā)現(xiàn)自己似乎形成作用域的印象了。是的,函數(shù)作用域 就是上面所描述這樣的 詞法作用域,它在編譯階段就已經(jīng)綁定了,不過(guò)在編譯階段作用域已經(jīng)不存在了,取而代之的是一個(gè)可以查詢(xún)的符號(hào)表。但是我們?cè)诶斫獯a時(shí),仍然可以將其抽象成作用域來(lái)解釋代碼運(yùn)行過(guò)程。

動(dòng)態(tài)作用域 與之相對(duì)應(yīng),它不是在編譯階段綁定的,而是根據(jù)其 調(diào)用 時(shí)所在作用域來(lái)綁定當(dāng)前作用域,與聲明函數(shù)的位置無(wú)關(guān)。在 JS 中,只有 詞法作用域,沒(méi)有 動(dòng)態(tài)作用域,但是 this 可以實(shí)現(xiàn)類(lèi)似 動(dòng)態(tài)作用域 的效果。這個(gè)詳見(jiàn)有關(guān) this 的筆記。

塊級(jí)作用域

塊級(jí)作用域 在 JS 中比較特殊。因?yàn)樵谶@里 if、for、while 等這些我們以為會(huì)生成自己作用域的代碼塊,全都沒(méi)有按我們所想的那樣運(yùn)作,而是和上下文共享同一個(gè)作用域,所以很多時(shí)候我們不得不用匿名函數(shù)去手動(dòng)生成一個(gè)函數(shù)作用域來(lái)達(dá)到我們想要的效果。我相信大家曾經(jīng)都寫(xiě)過(guò)這樣的代碼:

var btn = document.getElementsByTagName('button');
for (var i = 0; i < 10; i++) {
  btn[i].onclick = function () {
    console.log(i);
  }
}

console.log(i); // 10

for 代碼塊外部訪問(wèn)到了內(nèi)部聲明的變量,而且我們點(diǎn)擊頁(yè)面上這個(gè)十個(gè)按鈕,控制臺(tái)打印出來(lái)的全是 10。即并沒(méi)有生成所謂的 塊級(jí)作用域i 在這十個(gè)點(diǎn)擊事件的公共作用域內(nèi)。為了實(shí)現(xiàn)我們想用的效果,我們只能使用 函數(shù)作用域 來(lái)模擬:

var btn = document.getElementsByTagName('button');
for (var i = 0; i < 10; i++) {
  (function (i) {
    btn[i].onclick = function () {
      console.log(i);
    }
  })(i)
}

console.log(i); // 10

這里我們可以發(fā)現(xiàn),使用了立即執(zhí)行的匿名函數(shù),我們?cè)诿看窝h(huán)時(shí)手動(dòng)生成了一個(gè) 函數(shù)作用域,并將 i 的當(dāng)前值綁定在相應(yīng)作用域內(nèi),從而實(shí)現(xiàn)了點(diǎn)擊第 N 個(gè) button 打印 N 的功能。

try {
  throw 1;
} catch(a) {

  var b = 2;
  console.log(a); // 1

}

console.log(b); // 2
console.log(a); // ReferenceError

從這個(gè)例子中我們可以看到,在 try/catch 代碼塊中的變量 a 外部訪問(wèn)不到,而 b 卻能訪問(wèn)到,也是就是說(shuō),這個(gè)在 ES3 標(biāo)準(zhǔn)中就有的 塊級(jí)作用域 是相當(dāng)畸形的……還不如我們使用立刻執(zhí)行的匿名函數(shù)來(lái)模擬 塊級(jí)作用域 的效果。

不過(guò)好在,ES6 標(biāo)準(zhǔn)的到來(lái),引入了 let、const 兩種新的聲明方式,以及顯式代碼塊結(jié)構(gòu) { ... },使得 塊級(jí)作用域 的標(biāo)準(zhǔn)完善不少。

let btn = document.getElementsByTagName('button');
for (let i = 0; i < 10; i++) {
  btn[i].onclick = function () {
    console.log(i);
  }
}
console.log(i); // ReferenceError

現(xiàn)在我們想要的效果終于來(lái)了,使用 let 聲明索引 i 后,我們發(fā)現(xiàn)每次循環(huán)都在 for 代碼塊內(nèi)生成了新的作用域,這就是 塊級(jí)作用域 了,在外部對(duì) i 的取值操作已經(jīng)變成了 ReferenceError 引用錯(cuò)誤,也就是說(shuō)外部作用域沒(méi)有 i 的聲明信息。

這樣,在除了在函數(shù)內(nèi)部,我們現(xiàn)在又可以在 { ... } 這種結(jié)構(gòu)(包括 for、while、if、try/catch、甚至直接使用 { ... } 等代碼塊結(jié)構(gòu)語(yǔ)法)的內(nèi)部使用 塊級(jí)作用域 的規(guī)則了,這樣有助于我們輕松地編寫(xiě)出更安全的高質(zhì)量代碼。

{
  let b = 1;
  var a = 2;
}
console.log(a); // 2
console.log(b); // ReferenceError

我相信你已經(jīng)明白了上面這個(gè)例子輸出這樣結(jié)果的原因。

變量提升

只要是一個(gè)作用域,就會(huì)有 變量提升 這個(gè)規(guī)則。我們先來(lái)看一個(gè)例子:

a(); // 3

// 函數(shù)聲明
function a() {
    console.log(1);
}

// 函數(shù)表達(dá)式
var a = function () {
    console.log(2);
}
a(); // 2

function a() {
    console.log(3);
}
a(); // 2

// 具名函數(shù)表達(dá)式
var a = function a() {
    console.log(4);
}
a(); // 4

在這個(gè)例子中,我們可以看到輸出的數(shù)字順序好像雜亂無(wú)章,但其實(shí)理解 變量提升 這個(gè)概念后你就明白了。

我們知道 JS 在下載后分兩個(gè)階段——編譯和執(zhí)行階段。在編譯過(guò)程中,作用域會(huì)先記錄下聲明的標(biāo)識(shí)符。比如 var a = 1; 這句話,在編譯時(shí)相應(yīng)作用域會(huì)記錄下聲明 var a, 不管這句話寫(xiě)在同一級(jí)作用域的哪里,都會(huì)在編譯階段就記錄在了這個(gè)作用域中,這就相當(dāng)于把聲明提到了此作用域的最上面,形成了 變量提升。而后,進(jìn)入一行一行執(zhí)行階段時(shí),讀到的是 a = 1 這樣的 賦值操作(LHS),引擎便查詢(xún)作用域中變量 a 的信息,將值寫(xiě)入其內(nèi)存地址。我們可以看到,存在編譯和執(zhí)行這樣的先后順序,在同一作用域的前提下,不管變量聲明寫(xiě)在哪里,都相當(dāng)于寫(xiě)在了這個(gè)作用域的開(kāi)頭,這就是一種 變量提升。

這里的例子也是這樣,只不過(guò)不是單純的變量聲明提升,而是針對(duì)函數(shù)。提到函數(shù),就不得不說(shuō)說(shuō)定義函數(shù)的兩種形式:

  • 函數(shù)聲明
  • 函數(shù)表達(dá)式

函數(shù)聲明 主要就是我們常用的定義函數(shù)方式,如例子中的注釋所示。這樣聲明的函數(shù)在編譯時(shí)作用域會(huì)記錄下函數(shù)的標(biāo)識(shí)符,同時(shí)將其標(biāo)記為函數(shù)類(lèi)型,并將函數(shù)內(nèi)容等信息全部記錄在這個(gè)標(biāo)識(shí)符下。這樣之后調(diào)用此函數(shù)便可以做到直接執(zhí)行函數(shù)內(nèi)容。但是我們知道 JS 沒(méi)有顯式支持 重載 這個(gè)機(jī)制,如果編譯到后面,JS 引擎發(fā)現(xiàn)我們又聲明了一次同樣函數(shù)名的函數(shù),便會(huì)直接覆蓋之前的函數(shù)內(nèi)容。這里,最后一個(gè)這樣 函數(shù)聲明 形式定義的同名函數(shù)輸出 3,在編譯階段覆蓋了之前聲明的函數(shù)內(nèi)容,那么第一行調(diào)用 a() 便會(huì)直接在作用域中查詢(xún)到這個(gè)函數(shù)打印 3。

函數(shù)表達(dá)式 編譯時(shí)待遇和上面 var a = 1; 差不多,編譯時(shí)作用域只會(huì)記錄下 變量聲明 var a,值為 undefined(不過(guò)在 JS 中,函數(shù)聲明的 優(yōu)先級(jí) 高于變量聲明,所以這個(gè) undefined 并沒(méi)有覆蓋之前的函數(shù)聲明內(nèi)容,否則的話第一行調(diào)用函數(shù)會(huì)報(bào) TypeError 這個(gè)錯(cuò)誤),等到執(zhí)行賦值操作時(shí),才會(huì)把后面的函數(shù)賦給這個(gè)變量,所以這個(gè) 函數(shù)表達(dá)式 之后執(zhí)行的函數(shù)調(diào)用全為此 函數(shù)表達(dá)式 的內(nèi)容——打印 2

那么最后一個(gè)具名函數(shù)表達(dá)式是怎么回事呢?原來(lái)編譯時(shí)因?yàn)樽饔糜蛑粫?huì)先記錄下最后一個(gè)函數(shù)的 var a,而并不能記錄下等號(hào)后具名函數(shù)的標(biāo)識(shí)符 a,相當(dāng)于這個(gè)具名函數(shù)的標(biāo)識(shí)符是完全被忽略的,從下面這個(gè)例子我們就能看出這一點(diǎn):

a(); // TypeError
b(); // ReferenceError

var a = function b() {
    console.log(4);
}
a(); // 4
b(); // ReferenceError

變量提升 的規(guī)則概括成一句話就是:“只有聲明本身會(huì)被提升,而賦值或其它運(yùn)行邏輯會(huì)留在原地?!?/p>

let 和 const 的變量提升

當(dāng)然,上面所說(shuō)的變量聲明使用的是關(guān)鍵詞 var,那么 ES6 中的 letconst 是怎么處理變量提升的呢?

為了解釋 letconst 變量提升,我們還要再引入概念,即聲明過(guò)程是存在一個(gè) 生命周期

  • 創(chuàng)建(Declaration phase):將變量注冊(cè)到作用域中。
  • 初始化(Initialization phase):為作用域中查詢(xún)到的相應(yīng)變量分配內(nèi)存,并將地址的引用綁定到這個(gè)變量上來(lái)。在這一步,變量會(huì)默認(rèn)地被初始化為 undefined。
  • 賦值(Assignment phase):將值賦給已經(jīng)初始化的變量。

JS 編譯階段遇到 var 聲明時(shí)會(huì)創(chuàng)建這個(gè)變量并初始化一個(gè)值 undefined,然后在執(zhí)行階段再將后面的賦值操作進(jìn)行下去。所以會(huì)出現(xiàn)下面的情景:

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

但是編譯階段遇到 let 聲明時(shí),JS 引擎只會(huì)創(chuàng)建這個(gè)變量,但并不會(huì)初始化它,而是在之后的執(zhí)行階段再初始化、賦值。所以會(huì)出現(xiàn)這樣的情景:

console.log(a); // ReferenceError: can't access lexical declaration `a' before initialization
let a; // 這里初始化成 `undefined`,但是沒(méi)有賦值操作
console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
let c = 1; // 這里不僅有初始化,還有賦值
console.log(c); // 1

對(duì)比變量 ab 的例子(火狐瀏覽器控制臺(tái)中報(bào)錯(cuò)更詳細(xì),可以看到區(qū)別,但是 Chrome 不會(huì)區(qū)別報(bào)錯(cuò)),我們可以看到 let 在編譯階段創(chuàng)建的變量是有提升的,但是沒(méi)有初始化,執(zhí)行時(shí)取值操作發(fā)現(xiàn)這個(gè)變量沒(méi)有值可以引用,便會(huì)報(bào)錯(cuò)……而 const 的變量提升也是同理了……

至此,我們可以看到,letvar 不僅有作用域上的區(qū)別,還有聲明生命周期上的區(qū)別。

參考

- 《你不知道的 JavaScript (上卷)》

- 深入淺出ES6(十四):let 和 const

- 認(rèn)識(shí) V8 引擎

- 我用了兩個(gè)月的時(shí)間才理解 let

- JavaScript variables lifecycle: why let is not hoisted

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容