JS Advanced --Closure 閉包


Posted by s103071049 on 2021-07-29

Closure 是什麼?

closure:在一個 function 裡面再 return 一個 function。當我們 return 內層的 function 時,他可以將外層 function 的值給記起來。透過 closure 可以避免重複運算。

範例一、

直接 call inner function

當 function 執行結束,資源都會被釋放。test EC、test VO 理論上在 test() 結束時就會被 pop out,但不知道為何 inner 還是可以鎖住 test 的 a 值。

function test() {
  var a = 10
  function inner() {
    a ++
    console.log(a)
  }
  inner() // 直接 call inner function
}

test() // 11

從外面 call inner(),可以在外部改變回傳的 function 值。

function test() {
  var a = 10
  function inner() {
    a ++
    console.log(a)
  }
  return inner
}

var func = test()
console.log(func) // [Function: inner]
func() // inner() => 11
func() // inner() => 12
func() // inner() => 13

範例二、

每 call 一次就要做一次複雜的運算。只要輸入一樣輸出就會一樣,因為公式都是一樣的。

function complex(num) {
  // 複雜計算
  return num * num * num
}

console.log(complex(20)) // 8000
console.log(complex(20)) // 8000
console.log(complex(20)) // 8000
console.log(complex(20)) // 8000

製造一個 function 叫 cache,想要達到的效果是將 complex 這個複雜運算的 function 傳進 cache 裡面,用一個 cachedComplex 去接他。回傳的 cachedComplex 會幫我把輸入的值給記起來。

  • 解釋:對 cache 來說,我將 complex 傳入,又 return 了一個新的 function,這個新的 function 就是 cachedComplex,所以當我執行cachedComplex(20) 就是在執行這個新的 function。第一次執行:ans[20] = complex(20),接著再 call 一次 function(num) {略 },他判斷 ans[20] 是否存在,因為第一次已經計算過,所以就不會 call complex(num) 而是直接回傳。因為他們共用同一個 ans 所以才可以把值記住。
function complex(num) {
  // 複雜計算
  console.log('caculate')
  return num * num * num
}
function cache(funct) {
  var ans = {}
  return function(num) {
    if (ans[num]) {
      return ans[num]
    }
    ans[num] = funct(num)
    return ans[num]
  }
}

console.log(complex(20)) // caculate 8000
console.log(complex(20)) // caculate 8000
console.log(complex(20)) // caculate 8000

const cachedComplex = cache(complex)
console.log(cachedComplex(20)) // 呼叫第一次會進行計算 // caculate 8000
console.log(cachedComplex(20)) // 因為前面計算過,所以之後呼叫會直接輸出結果 //8000
console.log(cachedComplex(20)) // 8000

從 ECMAScript 看作用域

closure 和 scope、scopechain 緊密相關。今天要再更新之前學的 EC、VO 的概念。

每一個 ec 都有一個 scope chain,當進入一個新的 ec,scope chain 就會被建立。

  • Every execution context has associated with it a scope chain. A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects, depending on the type of code. During execution within an execution context, the scope
    chain of the execution context is affected only by with statements (section 12.10) and catch clauses (section 12.14)

當進入 ec,scope chain 被建立也被初始化,變數也被初始化

  • When control enters an execution context, the scope chain is created and initialised, variable instantiation is performed, and the this value is determined.

進入 function code,

enter function EC => 
scope chain: [AO, [[Scope]]]
  • The scope chain is initialised to contain the activation object followed by the objects in the scope chain stored in the [[Scope]] property of the Function object.

甚麼是 AO ? 當進入 function code AO 會被新增,他會有一個預設的屬性叫 argument,接著 AO 就可以被當作 VO 來用。

其實,AO VO 超級細微不同,可以想成在 global EC 裡面才有 VO,當現在是 function EC,他的這個東西叫 AO,他做的事情還是一樣,一樣會被初始化變數放在這邊。

global EC: {
  VO: {

  }
}

function EC: {
  AO: {
    a: undefined,
    func: func,
  }
}

今天進入某個 function,就會多加一個 scope chain屬性,自己的 function EC 自己的 AO、function 內部使用的 [[Scope]]。[[Scope]] 是在 function 宣告時他來決定的。

function EC: {
  AO: {
    a: undefined,
    func: func,
  },
  scopeChain: [function EC.AO, [[Scope]]]
}
  • When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below.

  • The activation object is then used as the variable object for the purposes of variable instantiation.

範例一、

宣告 function,不要忘了他的隱藏屬性 scope

var a = 1
function test() {
  var b = 2
  function inner() {
    var c = 3
    console.log(b) // 2
    console.log(a) // 1
  }
  inner()
}
test()

進入 globalEC,初始化 VO,因為宣告 function,所以還有 test 這個 function 的 scope 屬性 test.[[Scope]],他會是 globalEC 的 scopeChain,也就是 globalEC.VO

globalEC : {
  VO : {
    a : undefined,
    test: function
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain // globalEC.VO

執行code

進入 test 這個 function,test 這邊的 scopeChain 會是他自己的 AO 再加上 scope 的 property。[testEC.AO, test.[[Scope]]],又因為 test.[[Scope]] = globalEC.scopeChain,且 globalEC.scopeChain = globalEC.VO,所以她其實就是這個 [testEC.AO, globalEC.VO]

inner 的 scope 也要跟著初始化

testEC: {
  AO: {
    b: undefined,
    inner: function
  }
  scopeChain: [testEC.AO, test.[[Scope]]] // [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, test.[[Scope]]]

globalEC : {
  VO : {
    a : 1,
    test: function
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain // globalEC.VO

初始化完繼續執行代碼

testEC: {
  AO: {
    b: 2,
    inner: function
  }
  scopeChain: [testEC.AO, test.[[Scope]]] // [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, test.[[Scope]]]

scope 往上找變數的過程,其實就是一層一層 AO VO 找變數的值。

innerEC: {
  AO: {
    c: undefined,
  }
  scopeChain: [innerEC.AO, inner.[[Scope]]]
    = [innerEC.AO, testEC.scopeChain ]
    = [innerEC.AO,  testEC.AO, test.[[Scope]]]
}

去 scopeChain 依序找。舉例:console.log(b),先從 innerEC.AO 開始找,找不到 b 再去 testEC.AO 找,找到 b 所以 log 出 b。

innerEC: {
  AO: {
    c: 3,
  }
  scopeChain: [innerEC.AO, inner.[[Scope]]]
    = [innerEC.AO, testEC.scopeChain ]
    = [innerEC.AO,  testEC.AO, test.[[Scope]]]
}

scope 往上找變數的過程,其實就是從一層一層 AO, VO 找變數。

每一個 EC 上面 AO/VO 的 objects 構成了整個 scope chain,透過 scope chain 找變數在哪裡,變數又存在 variable objects 裡。

現在這個模型可以解釋 hoisting 與 closure。

再次 cosplay JS 引擎

var v1 = 10
function test() {
  var vTest = 20
  function inner() {
    console.log(v1, vTest) // 10 20
  }
  return inner
}
var inner = test()
inner()

首先進入 globalEC 進行 VO 的初始化、scopeChain。

對於宣告的 function,都要設定他的 scope 屬性。

globalEC: {
  VO: {
    v1: undefined,
    test: function
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

執行代碼,v1 = 10,然後呼叫 test function,所以接著進入 testEC

先初始化 AO 、記得他的 scopeChain

有宣告 function,就要設定 function 的 scope 屬性,function 的 scope 屬性就是 EC 裡面這個 function 的 scopeChain

testEC: {
  AO: {
    vTest = undefined,
    inner: function
  }
  scopeChain: [testEC.AO, test.[[Scope]]] // [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    v1: 10,
    test: function
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

return inner,照理來說 test EC 就要結束,但因為 inner 的 scope inner.[[Scope]] = [testEC.AO, globalEC.VO] 有用到,所以這兩個的 active object、variable object 就還會存在。所以她不會被 js 底層機制給回收。

這就是閉包的原理,對 testEC 而言他執行完成就被 pop,但如上述所說,我們會先將 testEC 的 AO 保留起來 (因為後面要用)。

當 test( ) 執行完到 var inner = test(),globalEC inner 的值就會被改成某個 function

globalEC: {
  VO: {
    v1: 10,
    test: function
    inner: function
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

將 test pop out,但將 testEC 的 AO 保留起來

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    v1: 10,
    test: function
    inner: function
  },
  scopeChain: [globalEC.VO]
}


testEC.AO: {
  vTest: undefined,
  inner: func
}

call inner( ),更新 vTest 為 20

進入 innerEC、初始化他的 AO、scopeChain

innerEC: {
  AO: {

  },
  scopeChain: [innerEC.AO, inner.[[Scope]]] // [innerEC.AO, testEC.AO, globalEC.VO]
}

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    v1: 10,
    test: function
    inner: function
  },
  scopeChain: [globalEC.VO]
}


testEC.AO: {
  vTest: 20,
  inner: func
}

console.log(v1, vTest),去 innerEC.AO 找,兩個都找不到再去 testEC.AO 找找,找到 vTest = 20,最後再去 globalEC.VO 找,找到 v1 = 10

所以最後結果輸出 10 20

小結

closure 是因為他的 scopeChain 有 reference 到其他 execution context 的 AO 或 VO,因為她會需要用到這些 execution context 的東西,所以 js 底層的回收機制就不能將這些東西給回收掉,因為還是有東西連結到他。因為沒有回收掉,所以就一直存在那裡。

因為這樣的特性,有些時候會發生一些問題
舉例、我在 test 裡面有一個超大的物件

var v1 = 10
function test() {
  var vTest = 20
  var obj = { huge object }
  function inner() {
    console.log(v1, vTest) // 10 20
  }
  return inner
}
var inner = test()
inner()

當我離開 function 以後,到全部代碼執行完以前,這個 huge object 都會存在 testEC.AO

testEC.AO: {
  vTest: 20,
  inner: func,
  obj: huge object
}

原本將 test 執行完,這個 huge object 就會被回收掉,但因為今天這邊是 return inner,我把內部的 inner function 給帶到外面來,所以他連帶把 scopeChain 相對應的東西給 keep 住,造成 scopeChain 一旦有存取到的 AO、VO 就會無法回收。

這個超大物件,儘管 inner function 沒有用到,但他還是沒有辦法被回收,因為她就是屬於 testEC.AO 的一部分。

所以使用 closure 要小心一點。有可能會保留到不想保留的值。

closure 背後原理就是他保留了 scopeChain,所以才造成明明離開了 function 但還是可以存到這個值。function 的這個 [[Scope]] 屬性可以記住他要存取的 scopeChain 長甚麼樣子,當我在進入這個 function EC 時,他才初始化這個 scopeChain,一旦這個 scopeChain 裡面有其他人的 AO、VO,那我就可以存取他的東西。這就是 scopeChain、scope 和 closure 的關係。

所以從這個角度看,無論他有無 return function,這個機制都是長一樣的,如果將閉包定義為會記住周邊資訊的 function,那在 js 裡面所有 function 都是閉包。所有的 function 都是按照這個機制走,他都會把他周邊、上層的這些 scope 給記起來。

但一般來說,對於閉包的定義是:function 裡面 return function。

日常生活中的作用域陷阱

沒有想到是閉包,會發生的常見錯誤

錯誤一

我希望每一個元素都是一個可以 log 出 i 的 function

應該要 log 出 0,但卻 log 出 5

var arr = []
for (var i=0; i<5; i++) {
  arr[i] = function() {
    console.log(i)
  }
}

arr[0]()

錯誤二

點按鈕發現跟我想的不一樣

for(var i=0; i<5; i++) {
  $('.num'+i).click(function() {
    console.log(i)
  })
}

以錯誤一進行說明
var 的生存範圍是 function,今天宣告在 global 就是 global variable,所以錯誤一的代碼實際上跟下面是一樣的。

var arr = []
var i
for (i=0; i<5; i++) {
  arr[i] = function() {
    console.log(i)
  }
}

arr[0]()

當我執行 arr[0] 他就會執行 console.log(i)

這個 i 會往 scope 的上面找,所以他會找 global 的 scope,存取 var i 的 i,當他在跑 arr[i] = function() {略},這個 i 已經是 5。

迴圈每一圈產生一個 function,只是還沒執行,跑完五圈,i = 5,因為不符合 i < 5 所以跳出,因此在執行 arr[0]() 這個 function,i 就會是 5

arr[0] = function() {
  console.log(i)
}

arr[1] = function() {
  console.log(i)
}

...以此類推

解法一、用一個新的 function 代替

運用 closure 的概念:function 裡面 return function。

function logN(n) {
  return function() {
    console.log(n)
  }
}
const log3 = logN(3)
log3()

所以可以這樣寫

透過新增一個新的 function,他會回傳一個新的 function,所以就會有一個新的作用域去記住 i 的值

var arr = []
var i
for (i=0; i<5; i++) {
  arr[i] = logN(i)
}

function logN(n) {
  return function() {
    console.log(n)
  }
}

arr[0]() // logN(0)
arr[1]() // logN(1)
arr[2]() // logN(2)
arr[3]() // logN(3)
arr[4]() // logN(4)

解法二、解法一搭配 IIFS

匿名 function,透過 IIFE (Immediately Invoked Function Expression) 進行呼叫。IIFE 會立刻執行這個 function。

function test() {
  console.log('test')
}
test()

====
IIFE
(function () {
  console.log('test')
})()

將 function 名稱拿掉 => 用小括號包起來 => 後面的小括號傳需要的參數進去
功能是會立刻執行這個 function

function test(num) {
  console.log(num)
}
test(123)
====
IIFE => 通常給匿名函式用

(function (num) {
  console.log(num)
})(123)

將 logN 改成執行 function 的形式。 IIFE 的好處是不用額外宣告 function,但可讀性差。

var arr = []
var i
for (i=0; i<5; i++) {
  arr[i] = (function(num) {
    return function() {
      console.log(num)
    }
  })(i)
}

arr[1]()

解法三、let

在 for 迴圈,let 的表現不太一樣。let 的範圍是 block,迴圈跑五圈可以想成產生五個 block。

var arr = []
var i
for (let i=0; i<5; i++) {
  arr[i] = function() {
    console.log(i)
  }
}

arr[2]()

以此類推。迴圈用 let 比較像下面的形式,每一圈都會有自己的 scope,所以這個 i就可以確定是自己想要的 i

let 在迴圈裡面特別不一樣。

{ // 第一圈
  let i = 0
  arr[0] = function() {
    console.log(i)
  }
}

{ // 第二圈
  let i = 1
  arr[1] = function() {
    console.log(i)
  }
}
var arr =  []
{ // 第一圈
  let i = 0
  arr[0] = function() {
    console.log(i)
  }
}

{ // 第二圈
  let i = 1
  arr[1] = function() {
    console.log(i)
  }
}

{ // 第三圈
  let i = 2
  arr[2] = function() {
    console.log(i)
  }
}

arr[1]() // 1

Closure 可以應用在哪裡?

  • 大量的運算:前面的 cache function,只要參數一樣她就可以直接回傳結果而不是重新再計算一次,在計算量很大時是很有幫助的。

  • 隱藏資訊:可以將資訊完好的封裝在裡面,從外面只能用他給你用的東西。雖然自由度變少,但東西變得更安全。你沒辦法改到不屬於你的東西。

var money = 99

function add(num) {
  money += num
}

function deduct(num) {
  if(num >= 10) {
    money -= 10
  } else {
    money -= num
  }
}

add(1)
deduct(100)
console.log(money) // 90

雖然我寫了兩個 function 去操控 money,但其他人可以直接調整 money,我沒辦法防止這種事情發生。

add(1)
deduct(100)
money = -100
console.log(money) // -100

不要直接改他傳進來的參數,再宣告一個新的變數會是比較好的做法

用 closure 的好處是我無法從外部直接操控 money 的值, myWallet.money = 10 是沒有用的。只能透過回傳的 function 去操控當初傳進的 money 值。

function createWallet(initMoney) {
  var money = initMoney
  return {
    add: function(num) {
      money += num
    },
    deduct: function(num) {
      if (num >= 10) {
        money -= 10
      } else {
        money -= num
      }
    },
    getMoney() {
      return money
    }
  }
}
var myWallet = createWallet(99)
myWallet.add(1)
myWallet.deduct(100)
console.log(myWallet.getMoney()) // 90

#closure







Related Posts

不可不知的小工具-cURL

不可不知的小工具-cURL

JS 展開  (Spread Operator) 以及反向展開 (Rest Parameters)

JS 展開 (Spread Operator) 以及反向展開 (Rest Parameters)

來寫測試吧!

來寫測試吧!


Comments