JavaScript - Scope 作用域 & Closure 閉包

JavaScript - Scope 作用域 & Closure 閉包

·

6 min read

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。

  1. init() 函式在自己的作用域建立了局部變數 name 以及 displayName() 函式

  2. 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 中引入了 letconst 變數,這兩個變數的宣告方式提供了「區塊作用域」。

  • 區塊作用域的範圍只存在於大括號 {}(例如 if 或 for)

  • 區塊作用域與函式作用域相同,內部宣告的變數不能從外部取用

  • var 宣告的變數不會有區塊作用域,透過 letconst 宣告才能讓變數具有區塊作用域

// 在 {} 中用 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 函式中:

  1. 宣告了一個變數 greet

  2. 宣告一個 inner 函式,inner 函式會印出 greet

  3. 回傳 inner 函式

接著把 outer 函式指派給 newFunc 變數,此時:

  • newFuncinnerouter 運行時所建立的實例 (instance)

  • newFunc 存取了 outer 回傳的值(也就是 inner 函式)

  • 由於 inner 的 instance 中保有了原本作用域的環境參照,而作用域裡含有 greet 變數,因此調用 newFunc()greet 也能被取用

Closure 的特性

  • 在 JavaScript 中每當函式被建立時,一個閉包就會被產生

  • 當一個 function 內 return 了另一個 function,通常就是有用到閉包的概念

  • 閉包是資料結構的一種,當一個函式被宣告時,函式對其語法環境 (lexical environment) 的引用在一起的組合就是閉包(可以想像成函式記住了宣告時的作用域環境)

  • 閉包就是可以回傳一個 function,並且使用父層 function 的變數

  • 閉包可以讓函式內部的變數不受外部環境影響

  • 閉包內部可以透過作用鏈獲取外部資料,內層作用域(child scope)永遠可以取得外層作用域(parent scope)的資料,反過來則不行

Closure 的好處

節省寫入記憶體的次數

可以把程式中需要重複執行的部分透過閉包封裝起來,進一步簡化程式,或是讓變數的值給保存下來。

  1. 把想要回傳的東西包到 function 裡再回傳

  2. 再把 function assign 給變數

  3. 讓變數去存取 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)
  1. 把想要回傳的東西包到 function 裡再回傳

  2. 把函式 heavyDutyClosure assign 給變數 getHeavyDuty,讓 getHeavyDuty 去存取 heavyDutyClosure 所回傳的值(也就是函式 function(index)...

  3. 執行 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 variable

  • count 只存在於 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)
  })
}
...

Ref