JS var 變數的秘密:一文看懂 var、let、const 無宣告變數細節

前言

這類問題我被問到不止一次。不得不說 JS 的變數蠻特別的。新手菜鳥會問,連老鳥也都常搞錯。

更甚者⋯⋯近日更是聽到一個自稱有五年經驗的軟體工程師稱:var 宣告的變數是全域變數⋯⋯

暈

我知道我身邊的朋友,也有不少可能不清楚,或是沒探究這麼深入,相關文章有但不多。於是乎,感覺我再想拖延,也應該把這篇文章寫出來。

這篇文章對於你寫更好的 JS 並沒有太多幫助,有許多部分是你平常不太會用到,但卻是非常基礎的概念。儘管不知道,通常按照當前常見規範,程式碼亦不會太糟糕。

這篇文章主要是從一份回覆修改而來。

變數的生存範圍

無關鍵字賦值、var 宣告、let 宣告最大的差別在於生存區域的不同。無關鍵字賦值 意味著全域變數的宣告,當然你在全域範圍使用 var/let 宣告也是全域的。只是無關鍵字可能引發意外的情況,像是你預期變數應該是函數區域的:

上例中全域情況也取得到在 printG 函數裏定義的全域變數。這相當於你顯式定義 g 於全域:

https://gist.github.com/ACbosong/caabc242fbd0350fae86c5d327bebd74

對於 printG 來看,就是去外部找一個 g 來用,找不到就在全域建立一個。

var 宣告

透過 var 宣告的變數,其生存範圍存在於函數內:

https://gist.github.com/ACbosong/a9b434b899f98e6a267cbf5c358c6555

所以不同於 g 的情況,v 並不會因爲 printV1 存在於全域。(printV1 裡的 v 並不會影響到全域,同樣,如果你全域有宣告 v 的話,亦不會影響到 printV1 裡的 v)

此外,var 的宣告是屬於函數內的,就算下面這樣寫也沒有問題:

https://gist.github.com/ACbosong/5379ab6f5c89381f891e8b792f573537

而且你只要在函式內宣告,就不會有問題。所以下面情況也不會報錯:

https://gist.github.com/ACbosong/f7134f801be209601da00d183b96846f

你甚至可以使用 var 多次宣告:

https://gist.github.com/ACbosong/4d746be44ae4b2cb27178890d286f3d2

實際上,這是因為變數提升(Hosting)的關係。在執行函數之前,會優先將var變數放入記憶體。要注意的是:這只是在記憶體有這變數的空間,但尚未初始化。這也是為何會拿到undefined的原因。

JavaScript 僅提升宣告的部分,而不是初始化。如果在使用該變數後才宣告和初始化,那麼該值將是 undefined。
MDN

let 宣告

使用 let 宣告,和 var 宣告很像,但生存區域更小一些,變數是屬於區塊的(block),同樣不會影響到全域的變數狀態:

https://gist.github.com/ACbosong/82e81afe3f18ed91e6c3bc34cbaa7471

不同的是不能像 printV3 在函式任意位置宣告:

https://gist.github.com/ACbosong/7a328ef7e6fade1408e0bdc580a48afc

而且存在暫時執行死區(TDZ)。不能在宣告前使用,不能多次宣告(內部區塊覆蓋外部區塊可以)。

https://gist.github.com/ACbosong/d125ddcc4fd0c8d52e7cd817370b6f0b

因爲你可以將內部區塊變數暫時覆蓋外部區這個原因,所以可以用上一些技巧:

https://gist.github.com/ACbosong/2952d922d39cab4d624e64a28fe06682

上面會迭代印出 table 裡的所有元素,儘管他是二維的,更注意到內 部for 迴圈變數也同樣使用 i,避免了 i、j、k、l,和 table[i][j] 這樣的窘境。這在尤其真的有必要使用大量巢狀迴圈的時候特別有用。但如果真的發生了那種事,或許你應該思考是否真得這樣做,有沒有更好的寫法。

誰會記得自己迴圈變數用到那了呢? i、j、k、l…z ?

只可惜 JavaScript 直譯時的特性,沒辦法同名暫時性覆蓋還使用原本的變數賦值:

https://gist.github.com/ACbosong/019ecb8cfe5944bcd9b7d1f214d3c11c

如果可以的話,就可以拿原本的變數先做一些變化,還回復到原本的變數值。以下使用 Lua 示例

https://gist.github.com/ACbosong/e6ba4b029f10ea1251fa853c26d3e608

小結: 生存範圍差異

也就是說,無宣告變數會被視為全域變數,除此之外,寫在全域環境的 var、let、const 變數同樣可以視為全域變數。

但是真正作用上,var 是函數變數,在函數生存範圍都可以存取;let、const 則是區塊變數,只能在宣告後使用,宣告前使用則會報錯。

無宣告變數的特別之處

MDN 這樣寫到:

In the global context, a variable declared using var is added as a non-configurable property of the global object. This means its property descriptor cannot be changed and it cannot be deleted using delete. The corresponding name is also added to a list on the internal [[VarNames]] slot on the global environment record (which forms part of the global lexical environment). The list of names in [[VarNames]] enables the runtime to distinguish between global variables and straightforward properties on the global object.

其中最明顯的差異,除了不管你到哪裡,只要直接賦值就是全域變數外,與 var 最大的差異:就是能不能通過 delet 操作刪除變數。

同樣可以被刪除的,還有物件的屬性:

https://gist.github.com/ACbosong/736879e1a430ecb500552a3a9a90696f

想轉職網頁開發工程師,該怎麼開始?3 分鐘小測驗,找到你的學習起點

特殊的全域物件 globalThis

這時候我們就需要認識到一個特殊的全域物件:globalThis。在瀏覽器的話是 window;Node.js 的話是 global。

https://gist.github.com/ACbosong/b6ed9c5d3fdf2ee2cea755abad06a553

可以發現,其差異在於其屬性描述器的 configurable。透過修該該項,同樣可以讓未宣告變數無法被刪除:

https://gist.github.com/ACbosong/da7fdb1a8b03fa3cd74d62d40be78b39

let, const 不會出現在 globalThis

有意思的是 let, const 不會出現在 globalThis。儘管在交互式環境裡你仍然可以存取到他,但他卻存在於另外一塊執行環境。

https://gist.github.com/ACbosong/080235aec43c0af45dbb10611589fe2b

var 是罪惡嗎?

多數情況下,使用 let 取代 var 絕對沒問題。但去了解兩者的差異也絕對沒有壞處。var 的設計是歷史遺留下來的結果。很多起初作為腳本語言的設計,最初就是為了方便;方便寫腳本、方便語言的實現。

You Don’t Know JS 的作者 Kyle Simpson 認為 var 也是很有用的。

在 ECMAScript(JavaScript)裡,嚴謹/限制程度由高到低爲:const 變數、let 變數、var 變數、全域變數。

以前沒有 const 和 let,現在通常會建議使用 let 而非 var,因爲這樣會在執行時期(Runnint Time)檢查是否有邏輯錯誤。

如果你希望你的程式就算遇到非預期性錯誤,執行起來很怪,也不該報錯。那仍可以考慮使用 var,這也是早期瀏覽器設計的考量。只是這樣的程式並不好除錯與維護。

所以就簡單來說,使用 let 取代 var 通常能夠得到比較健全的程式碼。但是在你了解了var、let 差異後,知道一個是函數變數,另一個是區塊變數後,你可以將變數賦予不同意義。下面我們可以很清楚的知道 studentRecords 和 id、record 變數負責的範圍。

https://gist.github.com/ACbosong/3613d8b350e1d8ed2e138ad1b887658c

你可以用他來初始化可能出錯的變數:

https://gist.github.com/ACbosong/243662c7a70c7d445599056c961d10c9

或是達成Uncle Bob在Clean Code所提到的:「養成將try-catch-finally寫在程式碼開頭的習慣」。這是因為var宣告的是函數變數,在catch區塊依然可以使用。

https://gist.github.com/ACbosong/2a049be821460a61b2316e4d01859f7c

程式範例

上面範例程式比較的對應表:

  • printV1() <-> printL1()
  • printV2() <-> printL2()
  • printV3() <-> printL3()

最後附上完整程式碼,可以去玩玩看:

https://gist.github.com/ACbosong/f53d3f6416b9248502139d601e8d0638

小練習

下面還有一個可以想想看的,問過朋友答出正確的不多。先想過後,再執行看看:

https://gist.github.com/ACbosong/9558c208522fa0796517cf566cc6ea6e

參考資料

JavaScript 全端開發課程,16 週進度班帶你半年轉職工程師

點我免費領取非本科轉職工程師指南!