不確定現在才開始講已經被用了無數遍的關鍵字會不會太晚,但總是得提一嘴,還有順便帶到關於作用域的觀念。
變數我們定義過了,但作用域還沒。
作用域指的是變數,函數,物件在程式碼中,可以被**訪問(存取)**的範圍。
當在目前的作用域找不到想要訪問的對象的時候,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。