JavaScript 修練 (1) | 變數的宣告、作用域與提升

沒有宣告變數會怎樣?

宣告變數的目的在於賦予變數一個初始值,並限制它的作用域。

在學習 JavaScript 的過程中,總是會一直被重複提醒變數需要被宣告,但是一開始學習 JavaScript 並不會了解為什麼要這樣做,這背後的原因到底是什麼

這篇文章將介紹有關變數宣告、作用域及提升相關的內容,接下來就用幾個範例,透過比較來了解到底為什麼吧 ~

沒有宣告的變數會變成全域「屬性」

相信在學習過程中常常會聽到有人說:「沒有宣告的變數會變成全域『變數』」,這句話其實嚴格來說不太正確,正確來說是:「沒有宣告的變數會變成全域『屬性』」

先來比較有宣告的變數 a 及沒有宣告的變數 b,到底有什麼差異

1
2
3
4
var a = 1;
b = 2;

console.log(window); // 執行結果如下圖

展開執行結果會發現全域物件也就是 window 裡面,出現了 a 跟 b 兩個屬性

window 物件內容

這時候你就會感覺到混亂,所以 a 跟 b 誰是屬性?誰是變數?

先別急,接著往下走,如果你有一些物件的基礎知識,應該會知道物件內有屬性這個東西,而 window 是一個全域的物件,所以 window 內也會有它的屬性,而沒有宣告的所謂「變數 b」,其實在 JavaScript 眼中是這樣的,前面省略了 window

1
window.b = 2;

所以你以為你在幫變數賦予值,但其實是在幫 window 這個全域物件新增一個屬性 b 並賦予它值為 2,所以 b 其實是全域的屬性不是變數

可是用 var 宣告的變數 a 呢?它也在 window 裡面,怎麼證明他們不一樣?

接下來就派出 delete 這個運算子了,delete 運算子可以用來刪除物件中的屬性,而變數是無法被刪除的,那把它拿來刪除 a 跟 b 會發生什麼事呢?

1
2
3
4
5
6
7
var a = 1;
b = 2;

delete a;
delete b;

console.log(window); // 執行結果如下圖

執行完你會發現 window 裡面的 b 被成功刪除了,但是 a 卻沒有

 window 裡面的 b 被成功刪除了,但是 a 卻沒有

所以 b 其實是全域 window 屬性,可以使用 delete 刪除它;而 a 在全域環境使用 var 宣告,則為全域變數,無法使用 delete 刪除它

未宣告的變數其實是全域 window 下的一個屬性不是真的變數,而全域的 window 沒有作用域限制,在哪都可以被存取

在全域環境下用 var 宣告的變數,雖然是變數但也會在 window 下建立屬性,它並不會被 delete 刪除,所以無論用 window.a 或是用 a 都能取得變數的值

小補充:let、const 宣告的全域變數

這邊補充在全域環境使用 let 或 const 宣告的全域變數,不會在 window 下建立屬性

1
2
3
4
5
var a = 1;
let abc = 3;
const abcd = 4;

console.log(window); // 執行結果如下圖

console.log(window) 執行結果


我就不宣告怎麼樣

看了以上這個多解釋,可能還是不太理解到底不宣告變數的後果是什麼,那就讓我們叛逆一次示範一下 (不聽話的下場) 會發生什麼事

前面有提到未宣告會變成全域屬性,而全域的 window 沒有作用域限制,所以在每個函式和作用域內都可以取得 window 及 window 下的所有屬性

如果你跟我一樣 剛好是金魚腦,在寫了幾百行程式碼之後可能會發生以下慘劇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function fn() {
family = [];
newMember();
console.log(family); // [{name: "小美"}]
}

function newMember() {
family.push({
name: "小美"
});
}

fn();

// 中間寫了幾百行程式碼,然後...

setTimeout(() => {
family = {}; // 這行程式碼把小美家直接清空
console.log(family); // {}
}, 1000);

以上程式碼中 family 在沒有宣告的情況下,它變成了全域屬性,開發時在任何函式中都可以輕鬆呼叫 family,而開發過程中寫了幾百行程式碼後,有可能會忘記定義了哪些全域屬性,所以又用了同樣的名稱來進行開發,結果就是小美家直接變成空物件,程式碼無法運作但你找不到原因

一個人開發都有可能會因為忘記而出錯,更不要說跟團隊一起開發,未宣告的變數會變成全域屬性,造成全域環境的作用域汙染,導致其他命名覆蓋問題,這可能會導致除錯上的困難


變數的作用域

前面提到未宣告變數的後果時,有說到作用域這些詞,接下來就來說說變數的「作用域」( Scope ) 吧 ~

JavaScript 有一個特別的機制稱為「語法作用域」( Lexical scope ),又稱為「靜態作用域」,意思是原始碼經過直譯器執行後就已經確定了作用域且不會再改變,這讓開發者可以直接透過原始碼了解當前的變數作用域

簡單來說所謂的語法作用域就是你宣告時就已經決定好他的作用域,而變數宣告後會被限制其作用域,作用域會因為宣告語法不同 ( var、let、const ) 而有變化

全域變數與區域變數

如果舉例來形容全域變數與區域變數,全域變數就像全國的補助、而區域變數則像縣市補助

  • 全域變數 :全國補助,全國各地都能申請
  • 區域變數 :縣市補助,只有在特定縣市內才能申請

兩者作用域範圍不同,全域代表的是最外層,內層可能是各個函式或作用域,而 在內層可以存取在外層宣告的變數,在外層則無法存取在內層宣告的變數

另外,假設在內層沒有宣告過 b 變數,則會透過「向外查找」的機制,一層一層向外尋找是否有叫 b 的變數,直到最外層全域,如果連全域都沒有 b 變數才會跳出「b is not defined」的錯誤訊息

接下來看幾個範例

在外層無法存取在 fn 函式內宣告的區域變數 b

1
2
3
4
5
6
7
8
9
// 全域變數
var a = '小春';

function fn() {
// 區域變數
var b = '小夏';
}

console.log(b); // Uncaught ReferenceError: b is not defined

在內層 fn2 函式內可以存取在 fn 函式內宣告的區域變數 b

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 全域變數
var a = '小春';

function fn() {
// 區域變數
var b = '小夏';
console.log(a, b); // "小春" "小夏"

function fn2() {
// 區域環境,可存取外層作用域的變數
console.log(a, b); // "小春" "小夏"
}
fn2();
}

fn();

var 屬於函式作用域

前面一直用 var 在宣告變數,接下來就來說說 var 宣告的變數作用域範圍

var 的作用域範圍在該函式內 ,在該函式內宣告的變數會被限制在該函式作用域中,也就是該變數只能在該函式中取得,有點像是把變數關在叫做函式的小房間中,變數的活動範圍就被限制了

以下範例在 fn 函式內被宣告的變數 b,無法在 fn2 函式內被存取

1
2
3
4
5
6
7
8
9
10
11
var a = '小明';

function fn() {
var b = '杰倫';
}
function fn2() {
var c = '漂亮阿姨';
console.log(b); // Uncaught ReferenceError: b is not defined
}
fn();
fn2();

let、const 屬於區塊作用域

隨著 ES6 成為主流,現在都會建議使用 let、const 來宣告變數,其中 let 宣告的變數可以重新賦值、const 則無法重新賦值

前面有提到 var 屬於「函式作用域」( function scope ),而 let、const 的作用域則屬於「區塊作用域」( block scope )

區塊其實就是大括號 {},前面有比喻函式作用域像是把變數關進叫做函式的小房間,那麼這裡的區塊作用域就像是把變數關進大括號裡面,限制它的活動範圍 ( 變數:結果還是把我關起來了QAQ

除了前面函式的 {} 大括號可以被認定為是一個區塊之外,還有其他在實戰中常見的區塊作用域:

  • 函式function fn() {...}
  • for 迴圈for(let i=0; i<10; i++) {...}
  • 判斷式if() {...}
  • 純粹的區塊{}

其中用常見的 for 迴圈來比較 var 和 let 宣告變數的作用域差異

1
2
3
4
5
for(var i = 0; i < 10; i++){}
console.log(i); // 10

for(let j = 0; j < 10; j++){}
console.log(j); // Uncaught ReferenceError: j is not defined

這邊可以看見 var 因為屬於函式作用域,顯然 {} 困不住裡面的變數,所以當在 for 迴圈的小括號中用 var 宣告變數時,這個變數會成為全域變數,或是其他函式內的區域變數 ( 如果迴圈在其他函式內的話 )

在上面的範例,變數 i 會變成全域變數,在其他地方也能被存取修改,容易汙染作用域環境,而用屬於區塊作用域的 let 宣告,變數的作用域不需要用函式,只要有 {} 就能被限制住,避免產生全域變數,所以使用 let、const 宣告的變數,相對於 var 來說更能避免產生不必要的問題


提升

「提升」( Hoisting ) 一詞並未在 ECMAScript 2015 之前的規範中被提到,它通常用於解釋 JavaScript 變數在記憶體中的一種運作形式

為什麼要把宣告放在前面?

學習過程中常常會聽到要先宣告變數賦予值,才能使用該變數,確實先宣告後取用很符合邏輯,但如果不這樣做會發生什麼事呢?先看看幾個範例

我們知道先宣告變數 a 賦予值為 1,再用 console.log(a) 就能印出 1,但反過來先使用再宣告變數,印出的值為什麼是 undefined

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

這要說到 JavaScript 提升的觀念了,程式碼在瀏覽器執行時分為兩個階段:

  • 創造階段 ( creation ) :這個階段會準備記憶體空間
  • 執行階段 ( execution ) :這個階段才會實際執行程式碼

JavaScript 程式碼在執行過程中,會先進入創造階段準備好記憶體空間存放變數的值,再進入執行階段實際將值賦予到變數上

而以上程式碼因為在宣告變數前就使用變數,這時候 JavaScript 會開始尋找變數 a,找到確實有宣告變數 a,在創造階段就把變數 a 提升到前方,但因為變數還沒賦值,所以執行階段先執行的 console.log(a) 印出來的是 undefined,最後才執行變數 a 的賦值

所以以上程式碼其實是這樣的

1
2
3
4
5
6
// 創造階段
var a;

// 執行階段
console.log(a); // undefined
a = 1;

而 JavaScript 這樣的特性就稱為「提升」,但以上這個範例說明的僅僅是 var 宣告變數時的狀態,如果遇到函式就不一樣了

相比於 var 宣告的變數,函式在提升上就多了不同的規則:

  • 函式陳述式會提升完整的函式 :函式陳述式在創造階段的提升會包含整個完整的函式
  • 函式陳述式優先 :函式陳述式會優先於變數的提升

等等,怎麼會突然冒出一個「函式陳述式」?

補充一下,宣告函式時通常有兩種方式:「函式陳述式」與「函式表達式」,這兩種方式寫法不同之外,在提升方式上也不同,先來看看以下範例了解兩者不同的地方

1
2
3
4
5
6
7
8
9
10
11
// 函式表達式
var callAnimal = function () {
console.log('山羊');
}

// 函式陳述式
function callAnimal() {
console.log('獅子');
}

callAnimal(); // '山羊'

什麼?為什麼執行程式碼的結果是「山羊」?不是「獅子」?這邊一樣用創造、執行階段來拆解程式碼,但要加入函式陳述式的規則:提升完整的函式、優先提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 創造階段
// 函式陳述式優先提升到前方
function callAnimal() {
console.log('獅子');
}

var callAnimal; // var 宣告的變數在函式陳述式後提升

// 執行階段
// 執行函式表達式的賦值 ( 將函式賦予到變數上 )
callAnimal = function () {
console.log('山羊');
}

callAnimal(); // '山羊'

透過以上程式碼可以看出兩者在提升上的不同,函式表達式會先宣告變數,再將函式賦予到變數上,而函式陳述式的提升又優先於變數,所以執行的反而是最後才賦值的函式表達式

因為這樣的不同所以在實戰中大多會擇一使用,但如果希望將函式宣告統一至於後方,那就只能使用函式陳述式方式宣告函式,這樣提升時才是完整的函式


let、const 的 TDZ

前面提到的 var 是 ECMAScript 早期的變數宣告方式,ES6 之後會避免使用 var 宣告,改用 let、const 新的宣告方式,當遇到不明問題時,新語法也能提供適當回饋提示開發者

前面提到用 var 先取值後宣告,會取得 undefined 的值、不會跳出錯誤訊息,但是用 let、const 如果先取值後宣告,就會出現類似以下的錯誤訊息,提示開發者無法在宣告前取得變數的值

 let、const 先取值後宣告錯誤訊息

這就是 let、const 宣告才有的「暫時性死區」( Temporal Dead Zone, TDZ ) 特性,在 let、const 宣告變數前方的區域就是 TDZ,如果試圖在這個區域寫入程式碼取得變數值,就會出現 ReferenceError


小補充:undefined 與 is not defined 的差異

undefined 與 is not defined 這兩者字面看起來似乎沒有差異,但經過上面的範例示範應該多少可以感覺到兩者的不同

相信看到這兩者應該還可以再聯想到:null,那這三者又有什麼差異呢?

這邊簡單的說明三者間的差異:

  • undefined:是原始型別的一種,在宣告變數時系統預設的空值,開發者應避免手動為變數賦予 undefined 的值
  • null:是原始型別的一種,開發者手動賦予的空值,需要抹除變數已被賦予的值時可以使用 null
  • is not defined:是沒有宣告變數卻試圖取得該變數的值時,會跳出的錯誤訊息,這會讓後面的程式碼無法繼續執行,所以要及時修正才不會影響程式碼運作

結語

本篇文章介紹了有關 JavaScript 變數宣告、作用域及提升相關的內容,希望能記錄下我學習到的相關內容,同時也希望這篇分享能幫助正在學習 JavaScript 的朋友

那我們就下篇文章見囉 ~