不確定現在才開始講已經被用了無數遍的關鍵字會不會太晚,但總是得提一嘴,還有順便帶到關於作用域的觀念。
變數我們定義過了,但作用域還沒。
作用域指的是變數,函數,物件在程式碼中,可以被**訪問(存取)**的範圍。
當在目前的作用域找不到想要訪問的對象的時候,JS 的行為與作用域的如何區分作用域範圍就會是這篇討論的內容。
在 ES 6 以前,JS 只有 var
一個關鍵字用於宣告變數,也沒有 {} 的塊級作用域。
在 ES 5 的時候,JS 只有兩種作用域:
直到 ES 6 以後,隨著 let
和 const
被介紹,也引入了 {}
的塊級作用域。
全域作用域就是所有作用域的頂點,可以把作用域想成一種樹狀結構,其他的作用域是這棵樹中的某個節點。無論在樹的哪裡,你都能找到樹的根節點 - 全域作用域。
全域作用域代表的關鍵詞可能因為運行環境有不同的指稱對象:
關於 this 的討論,我們會放在下一篇的內容來討論,今天只討論作用域。
在 ES 5 時,定義變數的方式只有關鍵字 var
,透過 var
進行定義的變數(即使是在 ES 6 之後)都會是使用函示作用域。
function foo() {
var x = 1;
{
var y = 1;
}
function bar(){
var z = 1;
}
console.log(x); // 1
console.log(y); // 1
console.log(typeof z); // undefined
}
foo();
console.log(typeof x); // undefined
console.log(typeof y); // undefined
如 foo
這個函數內定義的 x
,和進一步用 {}
包圍的都算在 foo
的作用域之下。
所以可以正常印出 x
和 y
,但離開了 foo()
以後,在最後印出的都是 undefined
。
但 foo
裡面如果定義另一個函式 bar
,則 bar
中定義的 z
就只存於 bar
的函示作用域之下,當嘗試於 foo
(bar
的外層作用域)進行對 z
的存取,就會拿到 undefined
。
乍看之下上面的方法運行良好,但如果是這樣呢?
function foo(){
var x = [1];
var y = x;
console.log(x, x===y); // [1], true
if(true){
x[0] = 2;
console.log(x, x===y); // [2], true
var x = [3];
console.log(x, x===y); // [3], false
}
console.log(x, x===y); // [3], false
x[0] = 4;
console.log(x, x===y); // [4], false
console.log(y);// [2]
}
foo();
這邊特別使用陣列這種複合型別來展示,因為可以明確知道第一次聲明的變數位址被覆蓋掉的是什麼時候。
一開始複製了一個 y
來驗證這件事,可以看到直到 if
的範圍內,第一次印出的 x 都還是和 y 為同一物件,但在下一行做了 var
進行宣告後,foo
這個函數作用域中的 x
就被覆寫了,變成了 [3]
,即使離開 if
的括號範圍之外也一樣,他已經不再是當初和 y
指向同一位址的 x
了。
而看到我們能在同一作用域中,多次使用 var
來進行同一變數的宣告,造成意料外的變數覆蓋,這也是 var
關鍵字本身的特性(算是某種缺點,畢竟預期使用宣告的情況應該不會是刻意想要覆蓋,這樣用的意圖很混淆)。
除此之外,JS 有另一個特性:提升(Hoisting)。
這個特性主要對 var
和 function
作用,會把 宣告 拉到該作用域的起始位置。
foo();
function foo() {
console.log(x); // undefined
var x = 1;
console.log(x); // 1
}
如上面的例子,即使文本上看起來我們先呼叫 foo()
,再進行 foo 的宣告和實行,但 foo 依舊能正常被使用。
以 x
來說在第一次 console
的時候沒有報錯,是因為實際上 var x
這個宣告被拉到 foo()
作用域內的頂端,所以執行該行時,實際上 x
已被宣告。
但賦值沒有被提升,直到 var x = 1
該行執行後 x
才是有值的。
x = 1;
var x;
console.log(x);
那這個例子呢?語句順序賦值發生在宣告之前?
實際最後還是會印出 1。
因為如同上面說的,宣告被提升了,以這個例子就是提升到全域的頂端。x = 1
的當全域的 x
已經存在了,
那麼,函式跟變數,誰會先被提升?
console.log(a);//function a(){}
function a(){}
a = 10;
console.log(a);//10
console.log(b);//function b(){}
b = 10;
function b(){}
console.log(b);//10
答案是函式!可以看到不管是哪種順序,都是 function
拿到了優先權。
如同上面說到的問題,ES 6 推出了新的變數宣告關鍵字來解決這個問題:let
。
相對於 var
的 函式作用域
,let
是使用塊級作用域
的。
函式作用域作用於函式,那塊級作用域自然是作用於塊級,這個塊指的是被 {}
包住的範圍。
讓我們借用 var
的例子,把它全部改為 let
試試。
function foo() {
let x = 1;
{
let y = 1;
}
function bar(){
let z = 1;
}
console.log(x); // 1
console.log(y); // 報錯:Uncaught ReferenceError: y is not defined
console.log(typeof z); // undefined
}
foo();
console.log(typeof x); // undefined
console.log(typeof y); // undefined
可以看到,foo()
內部的對 y
的存取已經存不到了,因為 {let y = 1;}
的 y
的作用域僅存於這個大括弧裡。因為函式的定義上也有 {}
(function(){}
),可以認為塊級作用域規則也適用於函式,是一個更為限縮的規則。
透過 let
,的宣告,我們可以更簡單的避免變數覆蓋與全域污染。
同一作用域內重複對一個變數名稱使用 let
宣告也會遇到錯誤。
function foo(){
let x = 1;
let x = 2;
//Uncaught SyntaxError: Identifier 'x' has already been declared"
}
上面說提升的時候有說到提升是僅針對 var
和 function
的,那對 let
呢?
function foo(){
console.log(typeof x);//undefined
console.log(typeof y);//Uncaught ReferenceError: Cannot access 'y' before initialization"
var x;
let y;
}
let
其實也會被識別到同一個作用域中的先使用後宣告的這個行為,但與 var
不同,他會直接報錯告訴你這樣使用是不行的,又稱作 TDZ(Temporal Dead Zone,暫時性死區),讓我們能避免未宣告先使用的行為。
const
同樣在 ES 6 被引入,他一樣也是塊級作用域,也有如同 let
一樣防止為宣告先使用的 TDZ。
不同的在於透過 const
宣告的變數,他會保護該變數的值,任何嘗試修改該變數的值的行為,都會得到錯誤,僅有在宣告時能進行賦值。
const a;
// Uncaught SyntaxError: Missing initializer in const declaration"
程式也會提醒你,只宣告不賦值是會報錯的。
const a = 10;
a = 15;
//Uncaught TypeError: Assignment to constant variable."
報錯,宣告後不能針對值進行修改。
const a = {k1:123}
a.k1 = 234;
沒事?對,沒事。
因為 a 的值是指向一個儲存 {k1:123}
物件的位址,修改的是物件裡的值,並不是修改 a 本身,所以沒有問題。
那麼,什麼時候該使用 const
,什麼時候該使用 let
呢?
有些人會說,能用 const
的時候,就全部用 const
,不能用 const
才用 let
。
但因為像上面這種例子,用 const
宣告物件後仍能對其中改值,我個人的建議是,把 const
用於標示不可更動的變數值,一旦宣告後就真的不去對他,也不去對他的屬性做修改,儘管可能更類似於一種程式碼風格而非強制性,但這樣能夠讓語意更清楚。(對 const
就是不要修改的這件事有共識的話)
每個作用域都有上下層級關聯,最上方的就是全域作用域,往下會有各種不同範圍的作用域。
如果我們嘗試訪問某個不存在於現有作用域的變數,他會發生什麼事?
function bar(){
x = 10;
}
console.log(typeof x);//undefined
bar();
console.log(x);//10
在我們尚未執行 bar()
,嘗試訪問全域下的 x
,我們會得到一個 Reference Error
,告訴你 x
為 undefined
。但我們一旦執行了 bar()
可以看到現在全域的作用域下,多了一個 x 的變數。
這個是一種全域污染,我們本來預期 x
應該只存於 bar()
內部。但是,因為我們並沒有用變數聲明的關鍵字來聲明 x,執行 bar()
的當下,執行 x=10
賦值語句,他找不到 bar()
這個函式作用域裡的宣告,便會往外層的作用域去尋找,直至全域(這個例子中,bar()
的外側作用域就是全域)。
在嚴格模式(Strict Mode,尚未用文章介紹,可先透過其他地方的文章稍微了解)下,會報出 Reference Error
,非嚴格模式情況下,就要看是哪種查找機制了。
這邊提到的是 LHS 和 RHS 機制,全名是 Left Hand Side 和 Right Hand Side。
這個機制是套用在變數賦值上,Left 和 Right 的相對就是對於等號的左右兩邊,儘管有時候不同的賦值方式下可能不是,也可以認為是賦值的目標(LHS)和賦值的資料來源(RHS)可能是更萬用的解釋。
LHS 是左側查找,行為是尋找需要賦值的位址(存值)。當一個變數出現在左邊的時候,如果查找不到,他會在全域直接創建一個對應的變數,再進行賦值行為。
RHS 是右側查找,行為是訪問一個值來直接使用(取值)。當一個變數出現在右邊的時候,如果查找不到,他會直接報出 Reference Error
的錯誤。
我們來嘗試把上方的範例中的 LHS 和 RHS 標示出來。
function bar(){
x = 10; //對 x 是 LHS,對 10 是 RHS
}
console.log(typeof x);//undefined,這是一個 RHS(訪問,取值,查值),找不到時是 undefined,直接使用則報錯
bar();//對 bar() 進行 RHS 查找(訪問),找到了所以執行
//執行時發生了對 x 的 LHS,找不到所以在全域建立
console.log(x);//執行 x 的 RHS,全域下已有 x 被建立,得到 10
看一個我們在函數作用域的例子來檢視一下是不是懂了這個概念:
var y = x;
x = 1;
var x;
console.log(x);
console.log(y === x, y);
答案是:
console.log(x)
會印出 1,因為對 var x
的宣告提升console.log(y === x, y)
會印出 false
和 undefined
,因為在 y = x
的當下對 x 執行的是 RHS
,全域此時不存在 x
,得到的是 undefined
,下面如同提升的例子,全域的 x
被 x = 1
建立,undefined === 1
自然會得到 false
。