iT邦幫忙

2024 iThome 鐵人賽

DAY 9
0
JavaScript

Don't make JavaScript Just Surpise系列 第 9

作用域(Scope),let,var 與 const

  • 分享至 

  • xImage
  •  

不確定現在才開始講已經被用了無數遍的關鍵字會不會太晚,但總是得提一嘴,還有順便帶到關於作用域的觀念。

變數我們定義過了,但作用域還沒。

作用域指的是變數,函數,物件在程式碼中,可以被**訪問(存取)**的範圍。
當在目前的作用域找不到想要訪問的對象的時候,JS 的行為與作用域的如何區分作用域範圍就會是這篇討論的內容。

在 ES 6 以前,JS 只有 var 一個關鍵字用於宣告變數,也沒有 {} 的塊級作用域。
在 ES 5 的時候,JS 只有兩種作用域:

  1. 全域作用域
  2. 函式作用域

直到 ES 6 以後,隨著 letconst 被介紹,也引入了 {} 的塊級作用域。

全域作用域(Global Scope)

全域作用域就是所有作用域的頂點,可以把作用域想成一種樹狀結構,其他的作用域是這棵樹中的某個節點。無論在樹的哪裡,你都能找到樹的根節點 - 全域作用域。
全域作用域代表的關鍵詞可能因為運行環境有不同的指稱對象:

  • globalThis:ES2020引入,如果版本有到,是一個通用的全域作用域關鍵字
  • window:瀏覽器環境下的全域物件
  • global:Node.js 環境下的全域物件

關於 this 的討論,我們會放在下一篇的內容來討論,今天只討論作用域。

函式作用域(Function Scope)和 var

在 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 的作用域之下。
所以可以正常印出 xy,但離開了 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 關鍵字本身的特性(算是某種缺點,畢竟預期使用宣告的情況應該不會是刻意想要覆蓋,這樣用的意圖很混淆)。

提升(Hoisting)

除此之外,JS 有另一個特性:提升(Hoisting)。
這個特性主要對 varfunction 作用,會把 宣告 拉到該作用域的起始位置。

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 拿到了優先權。

塊級作用域(Block Scope)和 let

如同上面說到的問題,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"
}

let 的提升

上面說提升的時候有說到提升是僅針對 varfunction 的,那對 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 關鍵字

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,告訴你 xundefined。但我們一旦執行了 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);

答案是:

  1. console.log(x) 會印出 1,因為對 var x 的宣告提升
  2. console.log(y === x, y) 會印出 falseundefined,因為在 y = x 的當下對 x 執行的是 RHS,全域此時不存在 x,得到的是 undefined,下面如同提升的例子,全域的 xx = 1 建立,undefined === 1 自然會得到 false

上一篇
原型與相關關鍵字([[Prototype]],__proto__,.prototype)
下一篇
this 關鍵字
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言