JS 觀念贖罪之旅:作用域 scope

2024/02/24閱讀時間約 15 分鐘
首次聽到作用域這三個字,我馬上想到火鳳燎原的張遼...

首次聽到作用域這三個字,我馬上想到火鳳燎原的張遼...


什麼是作用域 (scope)?

顧名思義,作用域即為某事物涉及或與之相關的範圍。

而在程式語言中,作用域有著更加嚴謹的定義:

The scope is the current context of execution in which values and expressions are "visible" or can be referenced. If a variable or expression is not in the current scope, it will not be available for use. Scopes can also be layered in a hierarchy, so that child scopes have access to parent scopes, but not vice versa. - MDN

作用域是指程式執行上下文當中,值與表達式能被看見或引用的範圍。換句話說,若某個值或表達式不處於當前作用域的話,我們便無法使用它。

作用域可採取階層式架構,也就是我們熟悉的巢狀、父子層關係。子層作用域可以存取父層作用域,但父層作用域無法存取子層作用域,就像是慈父與不肖子的關係。以程式碼說明非常好懂:

function inner() {
let cat = "mimiball";
console.log("My cat", cat);
}

inner(); //​ My cat mimiball

console.log(cat) //❌ Uncaught ReferenceError: cat is not defined


靜態作用域 (static scope)

我們能把程式語言分類成靜態作用域 (static scope) 以及動態作用域 (dynamic scope) 兩大宗,而 JavaScript 屬於靜態作用域

靜態作用域也被稱作詞彙作用域 (lexical scope),其變數的值會由變數被宣告的區域為準。相對來說,動態作用域變數的值,則要看函式被呼叫的區域而定。

這些概念乍看之下有點干我何事的感覺,對,我以前就是這樣的白目鬼。但若要更深入認識 JS 作用域,請務必將它們埋進深深的腦海裡。



Var 與作用域

var 雖然已經像學生時代的朋友,漸漸離開我們的生命了。但早年 JavaScript 只有這種變數宣告,所以要談到作用域,還是要與它重溫舊夢。再者,了解 var 衍伸出的作用域問題,能幫助我們理解為何要推出 letconst 兩個變數宣告。

var 有以下兩種作用域:

  • 全域作用域 (global scope)
  • 函式作用域 (function scope)

若把瀏覽器當作執行環境,全域作用域的變數會被存入 window 全域物件,該份 JavaScript 檔案中的程式碼可以存取它。

var cat = "mimiball";

function myCat() {
console.log(cat);
}

console.log(cat) // "mimiball"​
myCat() // "mimiball"​
console.log(window.cat) // "mimiball"​


反過來說,函式作用域內的 var 變數,僅供該作用域之內存取,因為這個變數是在函式作用域之內被宣告的。

function myCat() {
var cat = "mimiball";
return cat;
}

console.log(cat) //​ ❌ Uncaught ReferenceError: cat is not defined


嗯,目前為止都很好理解,看來這趟贖罪之旅意外地順暢呢...... 如果我們改成這樣:

for (var i = 0; i < 10; i++) {
console.log(i);
}

console.log(i) // 😮 10


什麼?!在 {...} 之外竟然還能存取到變數!但 i 明明不是在全域作用域被宣告的啊!我們看仔細點, i 並沒有在函式裡面被宣告,所以算是全域變數。透過 window 物件來檢查就更明確了:

console.log(window.i); // 10​


嗯......也就是說,var 會忽略到區塊 {...},把區塊內宣告的變數看待成全域變數囉?多做幾個實驗,似乎是這樣沒錯:

if (true) {
let test = true;
}

console.log(window.test); // true

var x = 1;
{
var x = 2;
}
console.log(x); // 2​


而如果 var 變數被宣告在函示的區塊內,還是會被當作函式作用域喔。

function sayHi() {
if (true) {
var phrase = "Hello";
}

alert(phrase); // 在函式作用域內能被存取
}

console.log(phrase) // ReferenceError: phrase is not defined​


只要不是在函式內宣告,var 都會自動變成全域變數,而誠如前言,子層作用域可以存取到父層作用域,造成變數容易汙染,也就是意外被更動


var 所帶來的隱憂

比方說,在 window 全域物件中,有個叫做 origin 的屬性,會回傳我們目前所在的網址:

console.log(window.origin); // 'https://developer.mozilla.org'


由於 origin 隸屬於 window 全域物件,我們其實可以隨意去更動它。前面提過,var 的變數宣告,會存入到全域物件中:

var origin = "mimiball";
console.log(window.origin) // mimiball


就算把範圍限縮在 JS 檔案裡面,var 的特性也容易造成命名衝突、變數汙染,所以 ES6 後來新增了 letconst 另外兩種變數宣告,它們除了全域作用域以及函式作用域之外,還多了區塊作用域 (block scope)



let、const 與作用域

我們沿用 window.origin 的例子,觀察 varletconst 之間的差別:

let origin = "mimiball";

console.log(origin) // mimiball
console.log(window.origin) // 'https://developer.mozilla.org'


就算使用相同的名稱進行變數宣告,也不影響 window 全域物件的屬性值。用 letconst 進行變數宣告,該值便不會存入到 window 全域物件,當然我們現在都是以瀏覽器這個執行環境來討論。

const cat = "mimiball";

console.log(window.cat) // undefined​


其他像是函式作用域中的變數無法於全域作用域存取,和 var 的特性一致。

function myCat() {
let cat = "mimiball";
console.log("In the function scope", cat);
}

myCat(); // In the function scope mimiball

console.log("Out of function scope", cat) //​ ❌ Uncaught ReferenceError: cat is not defined


而由於 letconst 多了區塊作用域的加持,因此在 if...elsefor... 等等的區塊當中宣告變數,無法於區塊之外存取,這點和 var 非常不同。

for (let i = 0; i < 10; i++) {
console.log(i);
}

console.log(i) // ❌ Uncaught ReferenceError: i is not defined

for (var j = 0; j < 10; j++) {
console.log(j);
}

console.log(j) // 10


以上例子結構都滿單純的,但實際環境中的程式碼往往一層疊一層,這時候就需要明確的規定來判斷作用域範圍



作用域鍊 scope chain

前面提到作用域可採取階層化的結構。面對趨漸複雜的程式碼,JavaScript 會遵循以下的順序來判斷變數值:

  1. 從局部作用域 (local scope) 判斷
  2. 從其他外層函式作用域 (outer function scope) 判斷
  3. 從全域作用域 (global scope) 判斷

簡單來說,就是由內而外一層一層找下去,直到真相大白為止。來看些簡單的例子:

let age = 10;

function outer() {
let age = "eternal";

function inner() {
console.log(age);
}

inner();
}

outer();// eternal


JavaScript 會這樣來判斷變數 age 的值:

  1. outer 函式呼叫 inner 函式
  2. inner 函式內找不到 age 的值 (local scope),所以往外層函式作用域找
  3. 在外層函式作用域 outer 發現 age 的值為 eternal

假如我們把第四行程式碼註解掉:

let age = 10;

function outer() {
//let age = "eternal";

function inner() {
console.log(age);
}

inner();
}

outer();// age


JavaScript 因為無法在任何相較於 inner 的外層函式作用域找到 age,所以一路探詢到程式碼第一行的全域變數,循規蹈矩。

當然如果我們在局部作用域進行賦值,答案昭然若揭,永生至今仍是個夢想:

let age = 10;

function outer() {
let age = "eternal";

function inner() {
age = "mortal";
console.log(age);
}

inner();
}

outer();// mortal;


Hoisting

關於 var 還有一個奇怪的地方,被稱作提升 (hoisting)。所以說是要提升到哪裡?讓我們用簡單的程式碼觀察一下:

function observe() {
console.log(cat);
var cat = "mimiball";
}

observe(); //undefined​


唉靠,怎麼會是 undefined?明明在宣告變數前就 console.log 了啊。用視覺化的方式呈現背後所發生的事情,我們就能理解了:

function observe() {
var cat;
console.log(cat);
cat = "mimiball";
}

observe();


換句話說,var 變數宣告被「提升」到作用域頂端了,但是變數初始化 (initialization) 並沒有被提升,才造成結果為 undefined 的奇怪現象。

看起來好像和日常生活沒什麼關係,但在某些時候,如果沒有意識到 hoisting,我們可能被預期之外的結果殺得措手不及:

function myCat() {
if (false) {
var cat = "I don't have any cat";
}

console.log(cat); // undefined
}


var 沒有區塊作用域,所以變數宣告被提升到了外層的函式作用域:

function myCat() {
var cat;
//if (false) {
//var cat = "I don't have any cat";
//}

console.log(cat); // undefined
}


但這樣其實和我們預期的結果不同,因為 cat 變數應該不能存在才對。而這也是為什麼 letconst 問世的原因之一, letconst 的能夠避免 hoisting 造成的混亂:

function observe() {
console.log(cat);
let cat = "mimiball";
}

observe(); //​ ❌ Uncaught ReferenceError: Cannot access 'cat' before initialization


所以說使用 letconst 來宣告變數,JavaScript 便不會產生 hoisting 的現象囉?其實不然,它還是默默提升了變數,只是把變數提升到了名為 TDZ (temporal dead zone) 的地方,個人認為有點像假死狀態。


TDZ

我們先來看看 MDN 對於 TDZ 的描述:

A variable declared with let, const, or class is said to be in a "temporal dead zone" (TDZ) from the start of the block until code execution reaches the place where the variable is declared and initialized.

透過這段說明,我們了解到 TDZ 的疆界從區塊起點一路延展至 letconst 宣告變數以及初始化的地方。

國界需要用地圖來詳加說明,這邊我們也用程式碼來劃出 TDZ 的疆界:

function myCat() {
// TDZ 起始點
console.log(cat);
// ReferenceError,我們無法在 cat 被宣告和初始化之前存取它
let cat = "mimiball";
// cat 經過宣告和初始化了,TDZ 結束
}


所以 JavaScript 還是提升了變數,但和 var 不同的點在於 letconst 無法在 TDZ 裡面被存取,所以上述例子並未出現 undefined



參考資料:


16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!