隨手扎
你可能都不瞭解的JS變數祕密 - 一文了解無宣告、var、let、const變數細節
本文優先發表於ALPHAcamp
前言
這類問題我被問到不止一次。不得不說JS的變數蠻特別的。新手菜鳥會問,連老鳥也都常搞錯。
更甚者…近日更是聽到一個自稱有五年經驗的軟體工程師稱: var
宣告的變數是全域變數
我知道我身邊的朋友,也有不少可能不清楚,或是沒探究這麼深入,相關文章有但不多。 於是乎…感覺我再想拖延,也應該把這篇文章寫出來。
這篇文章對於你寫更好的JS並沒有太多幫助,有許多部分平常根本不太會用到。但卻是非常基礎的概念。儘管不知道,通常按照當前常見規範,程式碼亦不會太糟糕。
這篇文章主要是從一份回覆修改而來。
變數的生存範圍
無關鍵字賦值、var
宣告、let
宣告最大的差別在於生存區域的不同。無關鍵字賦值 - 這意味著全域變數的宣告,當然你在全域範圍使用var
/let
宣告也是全域的。只是無關鍵字可能引發意外的情況,像是你預期變數應該是函數區域的:
function printG(){
g = 1
console.log(`printG: `, g)
}
printG() // => printG: 1
console.log(`Global G:`, g) // => Global G: 1
上例中全域情況也取得到在printG
函數裏定義的全域變數。這相當於你顯式定義g
於全域:
var g = 0
function printG(){
g = 1
console.log(`printG: `, g)
}
printG() // => printG: 1
console.log(`Global G:`, g) // => Global G: 1
對於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');
}
所以不同於g
的情況,v
並不會因爲printV1
存在於全域。(printV1
裏的v
並不會影響到全域,同樣,如果你全域有宣告v
的話,亦不會影響到printV1
裏的v
)
此外,var
的宣告是屬於函數內的,就算下面這樣寫也沒有問題:
function printV3(){
if(true) {
var v = 2
}
console.log(`printV3:`, v)
}
printV3() // => pritnV3: 2
而且你只要再函式內宣告,就不會有問題。所以下面情況也不會報錯:
function printV2(){
console.log(`printV2:`, v)
var v = 2
}
printV2() // => undefined
你甚至可以使用var
多次宣告:
function printV2(){
console.log(`printV2:`, v)
var v = 2
var v = 3
var v = 4
}
printV2() // => undefined
實際上,這是因為變數提升(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`);
}
不同的是不能像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 printL3
而且存在暫時執行死區(TDZ)。不能在宣告前使用,不能多次宣告(內部區塊覆蓋外部區塊可以)。
function printL2() {
console.log(l)
let l = 3
}
try {
printL2();
} catch {
console.log(`can't find l in printL2`)
}
因爲你可以將內部區塊變數暫時覆蓋外部區這個原因,所以可以用上一些技巧:
{
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]);
}
}
}
上面會迭代印出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
}
如果可以的話,就可以拿原本的變數先做一些變化,還回復到原本的變數值。以下使用Lua示例:
do
local v = 1
do
local v = v + 1
print(v) // 2
end
print(v) // 1
end
小總結: 生存範圍差異
也就是說無宣告變數會被視為全域變數,除此之外,寫在全域環境的var
、let
、const
變數同樣可以視為全域變數。
但是真正作用上,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
最大的差異:就是能不能通過delete
操作刪除變數。
同樣可以被刪除的,還有物件的屬性:
let o1 = {
attr1: 1,
attr2: 2,
};
o1.attr3 = 3;
delete o1.attr3; // true
delete o1.attr2; // true
Object.keys(o1); // ["attr1"]
特殊的全域物件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 }
可以發現,其差異在於其屬性描述器的configurable
。透過修該該項,同樣可以讓未宣告變數無法被刪除:
Object.defineProperty(globalThis, "v1", {
configurable: false,
})
delete v1 // false
let
, const
不會出現在globalThis
有意思的是let
, const
不會出現在globalThis
。儘管在交互式環境裡你仍然可以存取到他,但他卻存在於另外一塊執行環境。
let v3 = 3;
const v4 = 4;
v3; // 3
v4; // 4
globalThis.v3; // undefined
globalThis.v4; // undefined
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;
}
你可以用他來初始化可能出錯的變數:
// 這個示例來自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 = [];
}
// ..
}
或是達成Uncle Bob在Clean Code所提到的:「養成將try
-catch
-finally
寫在程式碼開頭的習慣」。這是因為var
宣告的是函數變數,在catch
區塊依然可以使用。
function E() {
try {
var a = 1;
throw new Error();
}
catch (err) {
console.log(a); // 1
}
// ..
}
程式範例
上面範例程式比較的對應表:
- printV1() <-> printL1()
- printV2() <-> printL2()
- printV3() <-> printL3()
最後附上完整程式碼,可以去玩玩看:
function printG(){
g = 1
console.log(`printG: `, g)
}
printG() // => printG: 1
console.log(`Global G:`, g) // => Global G: 1
////////////////////////////////
function printV1(){
var v = 2
console.log(`printV1:`, v)
}
printV1() // => pritnV1: 2
try{
console.log(v)// error
} catch {
console.log('not find v');
}
function printV2(){
console.log(`printV2:`, v)
var v = 2
}
printV2() // => undefined
function printV3(){
if(true) {
var v = 2
}
console.log(`printV3:`, v)
}
printV3() // => pritnV3: 2
//////////////////////////////////
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`);
}
function printL3(){
if(true){
let l = 3
}
try {
console.log(`printL3:`, l)
} catch {
console.log(`can't find l in printL3`)
}
}
printL3() // => can't find l in printL3
function printL2() {
console.log(l)
let l = 3
}
try {
printL2();
} catch {
console.log(`can't find l in printL2`)
}
小練習
下面還有一個可以想想看的,問過朋友答出正確的不多。先想過後,在執行看看:
var v1 = 9
function printV0(){
console.log(`printV2:`, v1)
var v1 = 2
console.log(`printV2:`, v1)
}
printV0()
參考資料
- You-Dont-Know-JS
- 關掉 no-var——決定變數宣告關鍵字的良好方法
- 淺談 var 與 let 的差異以及有無宣告變數的差異
- [JS奇怪的世界]No.3 全域環境與全域物件
- MDN - 關於var變數宣告語句
