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

前言

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

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

暈

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

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

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

變數的生存範圍

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

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

var g = 0
function printG(){
g = 1
console.log(`printG: `, g)
}
printG() // => printG: 1
console.log(`Global G:`, g) // => Global G: 1
view raw .js hosted with ❤ by GitHub

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

var 宣告

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

function printV1(){
var v = 2
console.log(`printV1:`, v)
}
printV1() // => pritnV1: 2
try{
console.log(v)// error
} catch {
console.log('not find v');
}
view raw .js hosted with ❤ by GitHub

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

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

function printV3(){
if(true) {
var v = 2
}
console.log(`printV3:`, v)
}
printV3() // => pritnV3: 2
view raw .js hosted with ❤ by GitHub

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

function printV2(){
console.log(`printV2:`, v)
var v = 2
}
printV2() // => undefined
view raw .js hosted with ❤ by GitHub

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

function printV2(){
console.log(`printV2:`, v)
var v = 2
var v = 3
var v = 4
}
printV2() // => undefined
view raw .js hosted with ❤ by GitHub

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

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

let 宣告

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

function printL1(){
let l = 3;
console.log(`printL1:`, l);
}
printL1() // => printL1: 3
try{
console.log(`Global l:`, l); //error
} catch {
console.log(`not find l`);
}
view raw .js hosted with ❤ by GitHub

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

function printL3(){
if(true){
let l = 3
}
try {
 console.log(`printL2:`, l)
} catch {
console.log(`can't find l in printL3`)
}
}
printL3() // => can't find l in printL2
view raw .js hosted with ❤ by GitHub

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

function printL2() {
console.log(l)
let l = 3
}
try {
printL2();
} catch {
console.log(`can't find l in printL2`)
}
view raw .js hosted with ❤ by GitHub

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

{
let table = [[1,2,3,4,5],
[2,4,6,8,10],
[3,6,9,12,15]];
for(let i = 0; i < table.length; i++) {
let row = table[i];
for(let i = 0; i < row.length; i++) {
console.log(row[i]);
}
}
}
view raw .js hosted with ❤ by GitHub

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

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

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

{
let v = 1;
{
let v = v + 1; // Error: TDZ
}
// recover v
}
view raw .js hosted with ❤ by GitHub

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

do
local v = 1
do
local v = v + 1
print(v) // 2
end
print(v) // 1
end
view raw .js hosted with ❤ by GitHub

小結: 生存範圍差異

也就是說,無宣告變數會被視為全域變數,除此之外,寫在全域環境的 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 操作刪除變數。

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

let o1 = {
attr1: 1,
attr2: 2,
};
o1.attr3 = 3;
delete o1.attr3; // true
delete o1.attr2; // true
Object.keys(o1); // ["attr1"]
view raw .js hosted with ❤ by GitHub

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

特殊的全域物件 globalThis

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

v1 = "global"
var v2 = "var"
Object.getOwnPropertyDescriptor(globalThis, "v1")
// Object { value: "global", writable: true, enumerable: true, configurable: true }
Object.getOwnPropertyDescriptor(globalThis, "v2")
// Object { value: "var", writable: true, enumerable: true, configurable: false }
view raw .js hosted with ❤ by GitHub

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

Object.defineProperty(globalThis, "v1", {
configurable: false,
})
delete v1 // false
view raw .js hosted with ❤ by GitHub

let, const 不會出現在 globalThis

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

let v3 = 3;
const v4 = 4;
v3; // 3
v4; // 4
globalThis.v3; // undefined
globalThis.v4; // undefined
view raw .js hosted with ❤ by GitHub

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 變數負責的範圍。

// 這個示例來自You Don't Know JS: https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/apA.md#var-and-let
function getStudents(data) {
var studentRecords = [];
for (let record of data.records) {
let id = `student-${ record.id }`;
studentRecords.push({
id,
record.name
});
}
return studentRecords;
}
view raw .js hosted with ❤ by GitHub

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

// 這個示例來自You Don't Know JS: https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/apA.md#var-and-let
function getStudents() {
try {
// not really a block scope
var records = fromCache("students");
}
catch (err) {
// oops, fall back to a default
var records = [];
}
// ..
}
view raw .js hosted with ❤ by GitHub

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