技術成長 · Web 開發 · JavaScript
JavaScript 的作用域到底是什麼?變數到底能活在哪裡?用「領域展開」理解 Scope。
上一篇談到 Hoisting 時,我們不斷看到「目前作用域」、「區塊作用域」、「外層變數」等名詞。
那麼,作用域詳細來說到底是什麼?
可以先用一句話理解:
作用域決定了一個變數可以在哪些地方被看見、存取和使用。
如果用《咒術迴戰》的概念來比喻,我們可以暫時把作用域想成一種「領域展開」。
當領域形成後,領域裡會有自己的規則和事物。如果使用 let 或 const 在領域內建立變數,通常就只能在這個範圍內直接使用。
一旦離開領域,外面的程式就無法直接取得裡面的東西。
{
const technique = "領域內的術式";
console.log(technique); // 領域內可以使用
}
console.log(technique); // ReferenceError白話來說:
technique是在{}這個領域裡建立的術式,所以只在領域裡有效。走出領域後,外面的程式就找不到它了。
但這裡要注意,以上只是一個幫助理解的比喻。
JavaScript 不是真的在執行期間展開一道結界,它的作用域主要是由程式碼的宣告位置與巢狀結構決定的。
---
最基本的作用域範例
先看一個函式:
function sayHello() {
const message = "Hello";
console.log(message);
}
sayHello();
console.log(message); // ReferenceError在 sayHello() 裡面,我們可以正常使用 message。
但離開函式後,外面的程式就找不到它了。
可以把 sayHello() 想成一個獨立領域:
message 是在這個領域裡建立的東西,只有領域內的程式能直接使用。這就是作用域最基本的概念。
---
最外層作用域
宣告在函式與區塊之外的變數,位於目前程式最外層的作用域。
const siteName = "夜行手記";
function showSiteName() {
console.log(siteName);
}
showSiteName(); // 夜行手記showSiteName() 裡面沒有自己的 siteName,所以 JavaScript 會往外尋找,最後找到外層的:
const siteName = "夜行手記";可以把它理解成:
作用域內沒有需要的東西,所以開始往外層尋找。
就像領地裡面沒了某種東西,你就得向外尋找取得的機會。
Tips:全域變數是不是很方便?
在一般的 Script 環境中,宣告在最外層的變數,通常會被稱為全域變數。
全域變數可以在很大的範圍內被存取,看起來非常方便。
但如果太多程式都能修改同一個變數,也容易產生問題:
let count = 0;
function addCount() {
count++;
}
function resetCount() {
count = 0;
}addCount() 和 resetCount() 都能修改外面的 count。
當操作它的地方越來越多,發生 Bug 時就會很難確認:
到底是誰動了我的 count!所以全域變數可以使用,只是要避免開放太多管道任意指向去修改同一份狀態。
即使單元測試完整,過多共享的可變狀態仍然會增加程式之間的耦合。所以說測試可以是防護手段,但不是讓全域變數變成零風險的免死金牌。
Tips:最外層變數不一定都是全域變數
如果程式使用 ES Module:
<script type="module" src="app.js"></script>檔案最外層的變數屬於模組作用域,不會直接變成所有程式都能任意取得的真正全域變數。
因此,「最外層作用域」和「全域作用域」在某些執行環境中不能完全畫上等號。
---
函式作用域
使用 var 在函式裡宣告的變數,會屬於整個函式作用域。
function showAge() {
var age = 20;
console.log(age); // 20
}
showAge();
console.log(age); // ReferenceError這裡的 age 位於 showAge() 的函式作用域中。
外面的程式無法直接取得它。
這裡也可以順便釐清 var 的一個常見誤解:
var 並不是完全沒有作用域,而是沒有一般的區塊作用域,但具有函式作用域。例如:
function test() {
if (true) {
var message = "晚安";
}
console.log(message); // 晚安
}
test();
console.log(message); // ReferenceError雖然 message 宣告在 if 的 {} 裡面,但因為使用的是 var,所以它的有效範圍是整個 test() 函式。
不過離開函式後,一樣不能使用。
---
區塊作用域
let 和 const 具有區塊作用域。
所謂的區塊,通常是由 {} 包起來的一段程式。
if (true) {
let userName = "Dennis";
const siteName = "夜行手記";
console.log(userName); // Dennis
console.log(siteName); // 夜行手記
}
console.log(userName); // ReferenceError
console.log(siteName); // ReferenceErroruserName 和 siteName 都是在 if 的作用域內建立的。
一旦離開這個 {},外面的程式就無法直接使用它們。
Tips:哪些地方會形成區塊?
常見的區塊包括:
if (true) {
const message = "if 區塊";
}for (let i = 0; i < 3; i++) {
const message = "for 區塊";
}也可以直接建立一個獨立區塊:
{
const message = "獨立區塊";
}但不是所有 {} 都代表區塊作用域。
例如:
const user = {
name: "Dennis",
age: 20
};這裡的 {} 是物件語法,用來描述物件內容,不是在建立一個新的區塊作用域。
---
var、let、const 進入同一個作用域後會怎樣?
直接看例子:
if (true) {
var meow = "var的meow";
let dennis = 20;
const siteName = "夜行手記";
}
console.log(meow); // var的meow
console.log(dennis); // ReferenceError
console.log(siteName); // ReferenceErrormeow 使用 var 宣告,所以沒有被限制在 if 區塊裡。
但 dennis 和 siteName 使用 let、const 宣告,因此只能留在自己的作用域內。
白話來說:
var:作用域的門關起來了,它還是跑得出去。let、const:乖乖待在作用域裡,離開就找不到了。
這也是現代 JavaScript 通常優先使用 let 和 const 的原因之一。
變數的活動範圍越明確,就越不容易在其他地方被意外使用或修改。
---
內層可以往外找,外層不能直接往內找
JavaScript 查找變數時,內層作用域可以往外層尋找。
const outside = "外面的變數";
function test() {
const inside = "裡面的變數";
console.log(outside); // 外面的變數
console.log(inside); // 裡面的變數
}
test();test() 裡面可以找到自己的 inside,也可以往外找到 outside。
但外層作用域不能直接取得函式內部的變數:
console.log(inside); // ReferenceError用領域的概念來想:
身處內層領域時,可以往外接觸外層環境;但站在領域外面的人,不能直接伸手取得領域內的術式。
這裡特別使用「不能直接取得」,是因為之後會提到的 Closure,可以讓函式間接保留並使用內層資料。
---
JavaScript 是怎麼往外找變數的?
來看一個巢狀函式:
const levelOne = "第一層";
function outer() {
const levelTwo = "第二層";
function inner() {
const levelThree = "第三層";
console.log(levelThree);
console.log(levelTwo);
console.log(levelOne);
}
inner();
}
outer();當 inner() 使用一個變數時,JavaScript 會按照順序尋找:
- 先在
inner()裡找。 - 找不到就往外到
outer()。 - 還是找不到,就繼續往最外層尋找。
- 如果一路找到最外面都沒有,才發生
ReferenceError。
這條由內往外尋找的路線,稱為 Scope Chain。
Tips:什麼是 Scope Chain?
Scope Chain 通常翻成「作用域鏈」。
白話來說就是:
JavaScript 尋找變數時,會依序查找的一連串作用域。
如果用領域來比喻:
先找目前的領域,找不到就往外層領域找,再找不到就繼續向外。
但它只會由內往外找,不會從外層跑進其他函式裡東翻西找。
---
同名變數會發生什麼?
不同作用域中,可以存在同名變數:
const name = "外面的 Dennis";
function showName() {
const name = "裡面的 Dennis";
console.log(name);
}
showName(); // 裡面的 Dennis
console.log(name); // 外面的 Dennis當 showName() 執行 console.log(name) 時,JavaScript 會先從目前作用域尋找。
它已經在內層找到 name,所以不會繼續往外使用最外層的 name。
這個現象叫做 Shadowing。
Tips:什麼是 Shadowing?
Shadowing 通常翻譯成「變數遮蔽」。
可以把它理解成:
內層作用域出現同名變數後,在目前範圍裡,外層的同名變數會暫時被遮住。
如果用領域的概念來想:
在內層領域時,這個領域擁有自己的 name 規則,所以領域內會優先使用自己的版本。外層變數並沒有消失或被修改。
離開內層作用域後,外面的 name 仍然存在:
console.log(name); // 外面的 Dennis---
Shadowing 和 TDZ 一起出現會怎樣?
上一篇 Hoisting 裡,我們看過這個例子:
const meow = "外面的 meow";
{
console.log(meow);
let meow = "裡面的 meow";
}有人可能會想:
內層的meow還沒有宣告完成,那 JavaScript 應該會往外找到"外面的 meow"吧?
實際上不會。
程式會直接發生 ReferenceError。
原因是內層的 let meow,從區塊開始時就已經遮蔽了外層的 meow。
但是內層的 meow 在執行到宣告以前,又仍然處於 TDZ,不能使用。
所以當下的狀況變成:
- 外層的
meow已經被內層遮住。 - 內層的
meow又還沒完成初始化。 - 最後只能得到
ReferenceError。
簡單來說:
外面的 meow 被領域隔開了,裡面的 meow 又還在吟唱、沒辦法施展,所以現主時兩隻 meow 都叫不出來。
---
什麼是 Lexical Scope?
JavaScript 使用的是 Lexical Scope,中文通常翻成「詞法作用域」。
啊哈...這又是什麼抽象的名詞...
沒事,其實核心概念只有一句話:
函式能存取哪些外部變數,主要由函式寫在哪裡決定,而不是最後從哪裡被呼叫。
例如:
const name = "全域的 Dennis";
function outer() {
const name = "outer 裡面的 Dennis";
function inner() {
console.log(name);
}
return inner;
}
const showName = outer();
showName(); // outer 裡面的 Dennisinner() 是寫在 outer() 裡面的,所以它往外尋找 name 時,會先找到:
const name = "outer 裡面的 Dennis";即使最後是在外面呼叫:
showName();它仍然不會改成使用最外層的 name。
這段程式其實已經稍微碰到了 Closure 的概念,下一篇會再完整拆解。
Tips:Lexical 是什麼意思?
Lexical 可以理解成:
依照程式碼的書寫位置。
函式在建立時,就已經決定往外尋找變數時會經過哪些作用域。
不是看誰呼叫它,也不是看它最後在哪裡執行。
---
宣告位置和呼叫位置有什麼差別?
來看一個例子:
const message = "全域訊息";
function showMessage() {
console.log(message);
}
function run() {
const message = "run 裡面的訊息";
showMessage();
}
run();最後輸出的是:
全域訊息有人可能會覺得:
showMessage()是在run()裡面被呼叫的,應該要取得run()裡面的message吧?
但 JavaScript 不會這樣判斷。
showMessage() 宣告在最外層,所以它往外尋找變數時,會依照自己宣告位置的作用域路線尋找。
它不會因為被 run() 呼叫,就突然獲得存取 run() 區域變數的能力。
如果繼續用領域來比喻的話:
showMessage() 屬於哪個領域,是在它被創造時就決定的,不會因為跑到別人的領域裡被呼叫,就突然改換陣營。---
領域展開這個比喻有哪些限制?
把作用域比喻成領域展開,可以幫助理解:
- 變數有自己的有效範圍。
- 內層與外層作用域。
- 變數遮蔽。
- 作用域鏈。
- 離開作用域後,外部不能直接取得內部變數。
但這個比喻並不完全等於 JavaScript 的實際機制。
領域展開比較像角色在丟招時,主動建立一個影響範圍;JavaScript 的詞法作用域,則主要由程式碼寫在哪裡決定。
所以比較精確的說法是:
領域展開可以幫助我們理解「變數的有效範圍」,而 JavaScript 真正判斷作用域時,看的仍然是程式碼的宣告位置與巢狀關係。
---
為什麼理解作用域很重要?
因為它會直接影響我們平常寫程式的方式。
避免不同變數互相污染
function calculatePrice() {
const total = 100;
}
function calculateScore() {
const total = 80;
}兩個函式都能使用 total,但因為存在於不同作用域中,所以不會互相影響。
限制資料能被修改的地方
function createCounter() {
let count = 0;
count++;
return count;
}count 被限制在函式裡,外面的程式無法直接修改它。
理解 Hoisting 和 TDZ
Hoisting、TDZ 和 Shadowing,都建立在作用域之上。
如果不知道一個變數屬於哪個作用域,就很難理解:
- 為什麼變數會被遮蔽?
- 為什麼 JavaScript 不去外層尋找?
- 為什麼宣告前會發生錯誤?
為 Closure 做準備
函式可以依照詞法作用域往外取得變數。
而且在某些情況下,即使外層函式已經執行結束,內層函式仍然可以保留並使用外層的資料。
這就是下一篇會提到的 Closure。
---
簡單整理
作用域決定了一個變數可以在哪些地方被看見和使用。
JavaScript 常見的作用域包括:
- 全域或模組的最外層作用域
- 函式作用域
- 區塊作用域
其中:
var具有函式作用域,但沒有一般的區塊作用域。let和const具有區塊作用域。- 內層作用域可以往外尋找變數。
- 外層作用域不能直接取得內層變數。
- JavaScript 會沿著 Scope Chain,由內往外尋找變數。
- 內層同名變數會遮蔽外層變數,這個現象稱為 Shadowing。
- JavaScript 使用 Lexical Scope,函式能取得哪些外部變數,主要由宣告位置決定。
- Shadowing 和 TDZ 同時出現時,可能造成外層變數被遮住,但內層變數又還不能使用。
短短懶人包:
作用域就像變數的領域展開,決定它能在哪裡發揮效果;而詞法作用域,則決定函式會沿著哪一條路往外尋找資料。
下一篇我們就可以繼續看看:
為什麼一個函式離開原本的作用域後,居然還能記得外面的資料?
這就是 JavaScript 的 Closure。