Scope 作用域 留言
什麼是作用域
「作用域就是一個變數的生存範圍,一旦出了這個範圍,就無法存取到這個變數」。
當我們把變數 a
宣告在 function 中,function 之外的地方都無法取用這個變數:
// 把變數宣告在 function 中
function test1() {
var a = "hello"
}
console.log(a) // Uncaught ReferenceError: a is not defined
但當我們把變數 b
宣告在全域,function 裡面也可以取用外面的變數:
// 把變數宣告在全域
var b = 123
function test2() {
console.log(b) // 123
}
test2()
再看看這個例子:
// 宣告函式
function outer() {
var x = 5
// 在函式裡面宣告另一個函式
function inner() {
var y = 4
console.log(x+y) // 9
}
inner()
console.log(y) // Uncaught ReferenceError: y is not defined
}
// 呼叫函式
outer()
y
不能被outer
取用:y
不在outer
function 的作用域中因此無法被outer
取用但
x
可以被inner
取用:inner
在自己的作用域中找不到x
,便開始向外尋找,最後在outer
的作用域中取用x
結論:
外面存取不到裡面的,但內層可以存取到外層的
倘若一直向外查找到全域都找不到變數,就會出現的
ReferenceError: variable is not defined
錯誤訊息
靜態作用域 / 語法作用域
Javascript 的作用域是採用「靜態作用域(static scope)」,也稱作「語法作用域 (lexical scope)」(也有人翻成「詞法作用域」、「語彙作用域」)。「靜態作用域」是相對於「動態作用域(dynamic scope)」而來。
靜態作用域
靜態作用域決定作用域的方式是根據函式在哪裡被宣告,與函式在哪裡被呼叫無關,查找變數的流程不會因為函式實際執行的位置不同而改變。
動態作用域
動態作用域決定作用域的方式是以呼叫堆疊 (call stack) 為準,查找變數的流程是執行時才決定的。
來看看這個例子:
var name = "Peter"; // 全域變數
function init() {
var name = "Amy"; // 局部變數
function displayName() {
console.log(name); // "Peter" or "Amy"?
}
displayName();
}
init();
答案是 Amy。
init()
函式在自己的作用域建立了局部變數name
以及displayName()
函式displayName()
函式的作用域中沒有局部變數name
,因此開始向外層尋找,先找到了init
作用域的name
變數 --> 因此答案是 Amy
再看看另一個例子:
var name = "Peter"; // 全域變數
function displayName() {
console.log(name); // "Peter" or "Amy"?
}
function init() {
var name = "Amy"; // 局部變數
displayName();
}
init();
答案是 Peter。
displayName
裡的name
其實就是 global 的name
,跟init
裡的name
沒有關係。這是因為 Javascript 是採用「靜態作用域」的關係,
displayName()
是在全域中被宣告,即使是在init()
中被呼叫,它的作用域仍與init()
無關。(在某些採用「動態作用域」的程式語言中,name
確實有可能會是"Amy"
)。
這邊可以知道:
作用域跟這個 function 在哪裡被「呼叫」一點關係都沒有
靜態作用域是在 function 被「宣告」的時候就決定了,而不是 function 被「執行」的時候
如何產生作用域
作用域有三種:
全域 Global Scope
函式作用域 Function Scope
塊級作用域 Block Scope (ES6)
全域 Global Scope
Javascript 執行的一開始就會創建一個全域環境,被定義在 function-scope 或是 block-scope 以外的變數就叫做全域變數 (global variable)。
不在函式或區塊內宣告的變數就是全域變數
可以在任何地方取用的變數
如果在定義變數時沒有加上宣告變數,即使在函式內也會成為全域變數(應避免這種情形)
function hello() { name = 'Jack'; } hello(); // 先執行 hello() 才宣告了 name 這個變數 console.log(name); // Jack,即使變數是在函式中被定義,還是變成了全域變數
函式作用域 Function Scope
每建立一個函式就會創建一個新的函式作用域。
在函式中宣告的變數只能在函式(該作用域中)使用
不論是透過
var
,let
,const
宣告的變數,當他們在函式中宣告時都屬於函式作用域
function foo(){
var num = 10; // function scope
}
console.log(num); // Uncaught ReferenceError: num is not defined
塊級作用域 Block Scope
在 ES6 中引入了 let
與 const
變數,這兩個變數的宣告方式提供了「區塊作用域」。
區塊作用域的範圍只存在於大括號
{}
(例如 if 或 for)區塊作用域與函式作用域相同,內部宣告的變數不能從外部取用
var
宣告的變數不會有區塊作用域,透過let
與const
宣告才能讓變數具有區塊作用域
// 在 {} 中用 let 或 const 宣告的變數具有區塊作用域
function foo1() {
if (true) {
const user = "花爸"; // block scope
}
console.log(user); // Uncaught ReferenceError: user is not defined
}
foo1();
// 在 {} 中用 var 宣告的變數不會有區塊作用域
function foo2() {
if (true) {
var user = "花媽"; // function scope
}
console.log(user); //花媽
}
foo2();
在上述的例子中,
foo1()
使用了const
宣告變數,所以 if 的{}
內為區塊作用域,const user = "花爸"
只存在於該{}
區塊中。既便是同函式中的console.log(user)
也無法取用 if{}
內宣告的變數。foo2()
使用了var
宣告變數,不會有區塊作用域,var user = "花媽"
存在於foo2
函式中。因此同函式中的console.log(user)
可以取用該變數。
作用域鏈 Scope Chain
Javascript 在使用一個變數的時候,會先在當層的作用域尋找該變數。若當前的作用域找不到該變數,會再往父層作用域尋找,就這樣一層一層往上找,一直到全局作用域如果還是沒找到就會報錯。這種由內而外搜尋的行為就是「作用域鏈」。
事實上,每個函式在執行時都會建立一個對應的作用域鏈。(延伸閱讀:前端中階:JS令人搞不懂的地方-Closure(閉包))
function outer() {
var a = 10
function inner() {
console.log(a) // 10
}
inner()
}
outer()
上述例子中,inner
函式在自己的作用域中找不到 a
變數,就會往上一層的 outer
作用域找,如果還是找不到就會再往上一層找直到最上層的 global
作用域為止,如果最後還是找不到就會報錯。
查找方向:【 inner function scope 】 -> 【 outer function scope 】 -> 【 global scope 】,這個過程就構成了一個作用域鏈。
對 inner
來說,a
變數並不存在於它的作用域中,但它仍能存取這個變數,這樣的變數也叫做「自由變數 (free variable)」。
Closure 閉包
什麼是 closure
根據 MDN 的敘述:
閉包(Closure)是函式以及該函式被宣告時所在的作用域環境(lexical environment)的組合。
const outer = () => {
const greet = 'Hi'
const inner = () => {
console.log(greet) // 'Hi'
}
inner()
}
outer()
當我們呼叫 outer
時,就只是執行內部的一個 function inner
而已。
但如果我們不直接執行 inner
,而是直接把這個函式回傳:
const outer = () => {
const greet = 'Hi'
const inner = () => {
console.log(greet) // 'Hi'
}
return inner
}
const newFunc = outer() // 建立了 newFunc 實例(instance)
newFunc()
原本當函式執行完成以後,裡面宣告的變數也應該要被釋放,因此當 outer()
執行結束時,照理來說變數 greet
的記憶體空間會被釋放,但緊接著在呼叫 newFunc
時卻仍能存取到 greet
。
換句話說只要 newFunc
還在, greet
就依然存在。這種在 function 裡回傳了一個 function 導致明明函式執行完畢卻還能存取到資源的現象就是「閉包」。
我們首先在 outer
函式中:
宣告了一個變數
greet
宣告一個
inner
函式,inner
函式會印出greet
回傳
inner
函式
接著把 outer
函式指派給 newFunc
變數,此時:
newFunc
是inner
在outer
運行時所建立的實例 (instance)newFunc
存取了outer
回傳的值(也就是inner
函式)由於
inner
的 instance 中保有了原本作用域的環境參照,而作用域裡含有greet
變數,因此調用newFunc()
時greet
也能被取用
Closure 的特性
在 JavaScript 中每當函式被建立時,一個閉包就會被產生
當一個 function 內 return 了另一個 function,通常就是有用到閉包的概念
閉包是資料結構的一種,當一個函式被宣告時,函式對其語法環境 (lexical environment) 的引用在一起的組合就是閉包(可以想像成函式記住了宣告時的作用域環境)
閉包就是可以回傳一個 function,並且使用父層 function 的變數
閉包可以讓函式內部的變數不受外部環境影響
閉包內部可以透過作用鏈獲取外部資料,內層作用域(child scope)永遠可以取得外層作用域(parent scope)的資料,反過來則不行
Closure 的好處
節省寫入記憶體的次數
可以把程式中需要重複執行的部分透過閉包封裝起來,進一步簡化程式,或是讓變數的值給保存下來。
把想要回傳的東西包到 function 裡再回傳
再把 function assign 給變數
讓變數去存取 function 回傳的值
假設今天這個函式需要創造一個很大的 array,並且這個函式會被呼叫很多次:
function heavyDuty(index) {
const bigArray = new Array(7000).fill('something')
console.log('created!')
return bigArray[index]
}
heavyDuty(688) // created!
heavyDuty(688) // created!
heavyDuty(688) // created!
每執行一次函式都需要重複產生一個 7000 個 index 的 array,太浪費資源了,這時就很適合使用 closure:
function heavyDutyClosure() {
const bigArray = new Array(7000).fill(':)')
console.log('created!')
return function(index) { // 簡化寫法,直接 return function
return bigArray[index]
}
}
const getHeavyDuty = heavyDutyClosure()
getHeavyDuty(688) // created!
getHeavyDuty(688)
getHeavyDuty(688)
把想要回傳的東西包到 function 裡再回傳
把函式
heavyDutyClosure
assign 給變數getHeavyDuty
,讓getHeavyDuty
去存取heavyDutyClosure
所回傳的值(也就是函式function(index)...
)執行
getHeavyDuty
時,就是在呼叫函式function(index)...
值得注意的是,雖然呼叫了三次 getHeavyDuty
但用 closure 寫法的版本只會在第一次印出 created!
執行第一次 getHeavyDuty
時的運作流程:
呼叫 getHeavyDuty
-> 呼叫 heavyDutyClosure
-> 創造 bigArray 並印出 created!
-> 回傳函數
-> 執行回傳的函數
-> 回傳 bigArray[688]
執行第一次後 function(index)...
就已經被存取 getHeavyDuty
這個變數了。後續在執行 getHeavyDuty
就是在執行 function(index)...
了。而 heavyDutyClosure
中沒有被 return 的部分(產生 7000 個 array、印出 created!)就只會執行一次。
接下來幾次執行 getHeavyDuty
的運作流程其實只剩:
呼叫 getHeavyDuty
-> 呼叫函數 function(index)…
-> 回傳 bigArray[688]
封裝(Encapsulation) 變數 (private variable)
封裝是物件導向程式設計(Object Oreinted Programming,簡稱 OOP) 一個非常重要的概念。
有些資料如果不想讓外部函式/方法改動它,就可以使用閉包的方式來確保只有內部函式可以改動內部資料。
let count = 0
function increment(num) {
return count += num
}
function decrement(num) {
return count -= num
}
function getCount() {
return count
}
increment(3)
increment(3)
increment(1)
getCount() // 7
這裡直接在全域宣告 count
變數是錯誤的,可能會導致程式碼出現不可預期的錯誤。比如要是有人在其他地方寫了 count = 5
資料就亂掉了。
這種情況就可以使用閉包,寫一個 function 把 count
這個變數和能夠改變這個變數的 function 封裝在一起:
function createCounter (initCount) {
let count = initCount
return {
increment: (num) => count += num,
decrement: (num) => count -= num,
getCount: () => count
}
}
const counter = createCounter(0)
counter.increment(3) // 3
counter.increment(3) // 6
counter.increment(1) // 7
counter.getCount() // 7
count = 5 // ReferenceError: count is not defined
這樣一來除了 .increment()
、.decrement()
、.getCount()
之外的方法都無法改變 count
這個變數。
count
是一個 private variablecount
只存在於createCounter
作用域中count
只能被內部的函式改動資料,不會被外部環境所影響能夠存取 private variable 的方法被稱作 privileged method
記憶體回收 (Garbage Collection)
作用域陷阱&閉包應用
var btns = document.querySelectorAll('button')
for(var i=0; i<btns.length; i++){
btns[i].addEventListener('click', function(){
alert(i+1)
})
}
假設頁面上有 3 個按鈕,預期的行為是點擊第一個按鈕跳出 1 、點擊第二個按鈕跳出 2 ⋯以此類推。但實際操作後會發現,無論點擊哪一個按鈕都會印出 3。
原本想像中的迴圈應該是要這樣跑的:
btn[0].addEventListener('click', function(){
alert(1)
})
btn[1].addEventListener('click', function(){
alert(2)
})
btn[2].addEventListener('click', function(){
alert(3)
})
但實際上 JS 引擎在運作時是這樣跑的:
btn[0].addEventListener('click', function(){
alert(i+1)
})
btn[1].addEventListener('click', function(){
alert(i+1)
})
btn[2].addEventListener('click', function(){
alert(i+1)
})
在跑迴圈的時候只是加上它的 callback function 而已還沒有執行,是要等到使用者按按鈕的時候才會去尋找 i
變數。也就是說,事件發生時(使用者點擊按鈕)所引發的函式 (callback function) 是在迴圈跑完之後才執行。
加上這幾個 callback function 本身並沒有 i
這個變數,因此會向作用域的外層開始尋找,一直找到在迴圈中定義的 i
(定義在全域中)作為其值。
而此時的 i
早就已經變成了 2 了,因此無論點擊哪一個按鈕,取用的都是同一個全域變數中的 i
,所以都只會輸出 3。
這個問題可以透過閉包來解決:
建立一個新的函式並傳入參數:
function getAlert(num) {
return function() {
alert(num)
}
}
for(var i=0; i<btn.length ; i++) {
btn[i].addEventListener('click', getAlert(i))
}
透過高階函式 (Higher Order Function) 產生三個新的 function,並且因為傳入了一個參數 i
,利用閉包的特性將 i
個別鎖進 getAlert
中。
或者也可以利用 IIFE(Immediately Invoked Function Expression):
for(var i=0; i<btn.length ; i++) {
(function(num) {
btn[i].addEventListener('click', function(){
alert(num)
})
})(i)
}
透過 IIFE 把 function 包起來並傳入參數 i
立即執行。
迴圈每跑一次就會產生一個新的 function 並且立刻呼叫它,因此會就地產生新的作用域,並且利用了閉包的特性將參數 i
鎖住。
也可以直接把 var
改成 let
(ES6):
for(let i=0; i<btn.length ; i++){
btn[i].addEventListener('click', function() {
alert(i)
})
}
ES6 裡有了塊級作用域 (block scope) 之後就可以直接利用 let
的特性,等於每跑一次迴圈都會產生一個新的作用域,因此 i
就會存在在該作用域裡。可以看成這樣:
{ // 塊級作用域
let i=0
btn[i].addEventListener('click', function() {
alert(i)
})
}
{ // 塊級作用域
let i=1
btn[i].addEventListener('click', function() {
alert(i)
})
}
...