從 Closure 更進一步理解 JS 運作


Posted by saffran on 2021-02-25

Closure 是什麼?

console.log(a) 會是 11

function test() {
  var a = 10;
  function inner() {
    a++;
    console.log(a); // 11
  }
  inner(); // 呼叫 inner function
}

test();

閉包就是:在一個 function 裡面,return 另一個 function

範例:
function test 裡面,return function inner

用一個變數 func 去接收「test() 所回傳的 inner function」
console.log(a) 一樣會是 11

function test() {
  var a = 10;
  function inner() {
    a++;
    console.log(a); // 11
  }
  return inner; // 回傳 inner function
}

var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」

如果再次呼叫 func()console.log(a) 就依序會是 11, 12, 13

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

var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」
func();
func();

output:

11
12
13

為什麼會叫做「閉包」呢?

以上面範例為例:
當進入到 function test 時,會產生 test EC 並建立 test VO

test EC
test VO = {
  a: 10,
  inner: func
}

之前有說過,當 function 執行結束後,所有資源都會被釋放掉
照理來說,當執行到第七行 return inner 之後,function test 就執行完畢了,在 test VO 裡面的資源都會被清空

但是卻發現在 return function 時,return 的 function inner 不知道為什麼,可以把「在 test VO 裡面的 a 給記起來」(a 就像是一直被鎖在 function inner 裡面)

只有在 function inner 裡面可以存取到 a 的值,其他地方都存取不到。也可以透過 function inner 去改變 a 的值

Closure 應用的範例:避免一直重複計算

例如,有一個 function complex 每次都要做很複雜的計算
如果是按照以前的作法,我每 call 一次 function complex,都要重新做一次複雜的計算才能得到結果

但是,我可以利用閉包的特性,來避免一直重複計算:

我把 function complex 傳進 function cache 後,cache(complex) 會 return 一個新的 function -> 所以,變數 cachedComplex 就是這個 return 的 function,所以就可以接收一個 num 的參數

利用 var ans = {} 這個物件,把值記起來
function cache 裡面 return 的 function,會把 ans 的值給記幾來,我就可以一直重複運用它

  • 第一次 call cachedComplex(20) 時:因為是第一次執行,所以會進行計算
  • 第二次 call cachedComplex(20) 時:因為前面已經計算過了,所以不用重新計算,可以直接輸出結果 -> cachedComplex 利用「閉包」的特性,把值給記起來
function complex(num) {
  // 複雜的計算
  console.log("calculate"); // 有出現 "calculate" 代表「真的有執行 function complex」
  return num * num * num;
}

// 會回傳一個新的 function
function cache(func) {
  var ans = {}; // 用這個物件,把值記起來
  return function (num) {
    if (ans[num]) {
      // 如果 ans[num] 有東西的話,就直接回傳 ans[num] 的 value
      return ans[num];
    }

    // 如果 ans[num] 沒東西的話
    ans[num] = func(num); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
    return ans[num];
  };
}

console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算

const cachedComplex = cache(complex);

console.log(cachedComplex(20)); // 因為是第一次,所以會需要執行 complex(20)
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳 ans[20] 的值
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳結果 ans[20] 的值

每一次執行 cachedComplex(20),其實就是在執行下面這段:
會記住變數 ans 的值

  return function (20) {
    if (ans[20]) {
      // 如果 ans[20] 有東西的話,就直接回傳 ans[20] 的 value
      return ans[20];
    }

    // 如果 ans[20] 沒東西的話
    ans[20] = func(20); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
    return ans[20];
  };

從 ECMAScript 看作用域

每一個 EC 都有一個 scope chain。當進入一個 EC 時,scope chain 就會被建立

什麼是 AO (Activation Object)?

Activation Object 和 Variable Object 做的事情是一樣的,都是拿來存放相關的資訊,只是在 global EC 裡面稱作 VO,在 function EC 裡面稱作 AO

當進入到 global EC 後,VO 會被建立,裡面存放所有相關的資訊

當進入到一個 function EC 後,AO 會被新增,裡面也是存放所有相關的資訊(有一個預設的屬性是 arguments

再次 cosplay JS 引擎

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

var inner = test();
inner();

用上面這段程式碼為例,我們假裝自己是 JS 的引擎,看看背後是怎麼實際執行的:

1. 首先,會進入 globalEC

在 globalEC 裡面:

  • 初始化 VO:
    • 變數 v1 被初始化成 undefined
    • 變數 inner 被初始化成 undefined
    • test 是一個 function
  • 設定 globalEC 的 scopeChain 會是 [globalEC.VO]

設定 function test[[Scope]] 屬性,會把「上一層的 scopeChain 給複製進來」,也就是 [globalEC.VO]

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

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

2. 接著,開始執行 globalEC 的程式碼

第 1 行 var v1 = 10;,會把 globalEC.VO 裡面的 v1 變成 10
第 10 行 var inner = test();,會執行 function test,就進入了 testEC

3. 進入 testEC

testEC 裡面:

  • 初始化 AO:
    • 變數 vTest 被初始化成 undefined
    • inner 是一個 function
  • 設定 testECscopeChain 會是「自己的 AO」+「自己的 [[Scope]] 屬性」,也就是 [testEC.AO, globalEC.VO]

設定 inner[[Scope]] 屬性,會把「上一層的 scopeChain 給複製進來」,也就是 [testEC.AO, globalEC.VO]

testEC: {
  AO: {
    vTest: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, globalEC.VO]
}

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

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

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

4. 開始執行 testEC 的程式碼

執行第 3 行 var vTest = 20;vTest 的值更新成 20

當執行到第 7 行 return inner; 時,照理來說,testEC 執行完畢了,testECtestAO 就要被清空

可是,因為 function inner 被 return 回去,在 inner.[[Scope]] 會需要引用到 testEC.AOglobalEC.VO,所以 JS 底層的垃圾回收機制就不能把 testEC.AOglobalEC.VO 回收掉,testEC.AOglobalEC.VO 都會被存在 function inner 裡面

scopeChain[[Scope]] 和 Closure 之間的關係,就是 Closure 的原理:因為保留了 scopeChain,所以在離開了前一個 function 後,還是可以存取的到前一個 function 的 AO 的值

因為 function 的 [[Scope]] 屬性會把「要存取的 scopeChain」給記起來,當我進入 function 的 EC 時,再去初始化 scopeChainscopeChain 會需要用到前面幾層的 AOVO,所以那些 AOVO 必須被保留起來

testEC 執行完畢後,testEC 就被 pop 掉了(testEC 可以回收),但 testEC.AO 要保留起來

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

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

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

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

5. 進入 innerEC

innerEC 裡面:

  • 初始化 AO:
    • AO 裡面沒有任何東西
  • 設定 innerECscopeChain 會是「自己的 AO」+「自己的 [[Scope]] 屬性」,也就是 [innerEC.AO, testEC.AO, globalEC.VO]
inner.[[Scope]] = [testEC.AO, globalEC.VO]

innerEC: {
  AO: {

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

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

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

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

6. 開始執行 innerEC 的程式碼

執行這行 console.log(v1, vTest); 時,會在自己的 scopeChain 裡面一層一層往上找:

  • 先找 v1

    • innerEC.AO 裡面,找不到 v1
    • testEC.AO 裡面,找不到 v1
    • globalEC.VO 裡面,找到 v1: 10,所以就會輸出 10
  • 再找 vTest

    • innerEC.AO 裡面,找不到 vTest
    • testEC.AO 裡面,找到 vTest: 20,所以就會輸出 20

Closure 要注意的地方:有可能會保留到「我不會用到的值」

假設,我在 function test 裡面宣告了一個很巨大的物件 var obj = { huge object };

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();

testEC 執行完畢後,這個很巨大的物件還是會隨著 testEC.AO 被帶到 innerECscopeChain 裡面

雖然我在 function inner 裡面完全不會用到這個巨大的物件,但它還是沒辦法被回收掉,因為 obj: { huge object } 就是屬於 testEC.AO 的一部分

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

innerEC: {
  AO: {

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

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

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

testEC.AO: {
  vTest: 20,
  inner: func,
  obj: { huge object } // 這個很巨大的物件還是會存在
}

日常生活中的作用域陷阱

常見陷阱一

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

arr[0](); // 呼叫 arr[0] 這個 function,會印出 5

當我呼叫 arr[0]() 時,我以為會輸出的是 0,但結果是輸出 5

原因為:
因為是在 global 宣告 var i = 0,所以變數 i 就是一個全域變數(等同於是下面這樣寫)

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = function () {
    console.log(i);
  };
}

arr[0](); // 呼叫 arr[0] 這個 function,會印出 5

迴圈的每一圈都會建立一個 function,只是還沒執行:

// 當迴圈跑第一圈時
arr[0] = function () {
    console.log(i);
  };

// 當迴圈跑第二圈時
arr[1] = function () {
    console.log(i);
  };

... 以此類推

當執行到第 9 行 arr[0]() 時,就是執行 arr[0] 這個 function,那在 function 裡面的 console.log(i),要怎麼找到這個 i 呢?

因為在自己的 function scope 沒有 i,所以會往上層的 scope(也就是 global scope)去找到這個 i

在執行第 9 行 arr[0]() 時,for 迴圈已經跑完了,這時候的全域變數 i 的值已經是 5 了,這就是為什麼最後 console.log(i) 的結果會是 5

解決方法一

利用閉包的原理,寫一個這樣的 function:
logN() 裡面傳入什麼值,就會印出什麼值

// 利用閉包的原理
function logN(n) {
  return function () {
    console.log(n);
  };
}

const log2 = logN(2);
log2(); // 2

就可以把 for 迴圈裡面的程式碼改成 arr[i] = logN(i);

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = logN(i);
}

// 利用閉包的原理
function logN(n) {
  return function () {
    console.log(n);
  };
}

arr[0](); // 呼叫 arr[0] 這個 function,會印出 0

因為 logN(i) 會回傳一個新的 function,所以就會有一個新的作用域可以去記住當時的 i 的值

因為 ilogN(i) 所傳進去的參數,所以這個 i 會被記在 function logN 的 Activation Object 裡面

改成這樣之後,就會是我想要的結果了:
執行 arr[0]() 就輸出 0,執行 arr[1]() 就輸出 1

用 IIFE 立即執行函式(通常是給匿名函式用的)

一般我要呼叫一個 function,就是用 test() 這種方式

function test() {
  console.log("hello");
}

test(); // 呼叫 function test

那要怎麼呼叫一個匿名的 function 呢?
在 JS 裡面,如果要呼叫一個「匿名的 function」,可以用 IIFE (immediately-invoked function expression)這個方法,中文翻作「立即呼叫函式表達式」,範例如下:
把 function 用一個小括號包起來,後面再加上一個小括號(裡面可以傳參數進去)

(function (num) {
  console.log(num);
})(123); // 立即呼叫函式,會輸出 123

用 IIFE 也是可以呼叫一個有名字的函式:

(function test() {
  console.log("hello");
})(); // 立即呼叫函式,會輸出 hello

解決方法二

所以上面的 for 迴圈範例,就可以改成這樣:
改成用 IIFE 的好處是「不用再額外宣告一個 logN 函式」

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = (function (number) {
    return function () {
      console.log(number);
    };
  })(i);
}

arr[1](); // 呼叫 arr[0] 這個 function,會印出 1

解決方法三

在 for 迴圈裡面,改為用 let 宣告 i,就可以了

var arr = [];
// 改為用 let 宣告 i
for (let i = 0; i < 5; i++) {
  arr[i] = function () {
    console.log(i);
  };
}

arr[1](); // 呼叫 arr[0] 這個 function,會印出 1
arr[3](); // 呼叫 arr[0] 這個 function,會印出 3

在 for 迴圈裡面,let 的表現不太一樣

let 宣告的變數,作用域會是一個 block

for 迴圈跑 5 圈,可以想成是「會產生 5 個 block」,每一圈都會有一個「自己的作用域」,像是這樣:

所以,每一圈的 i,就會跟 arr[i]i 有相同的值

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

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

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

... 以此類推

Closure 可以應用在哪裡?

用 Closure 來隱藏某些資訊

舉一個例子:
雖然我寫了兩個 function 去操控 money
但是,我會碰到的問題是:如果有其他同事跟我協作,他完全可以不透過這兩個 function,就任意把 money 的值改掉,這是我完全沒辦法預防的

var money = 99;

function add(num) {
  money += num;
}

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

add(1); // money = 100
deduct(100); // money = 90

money = -1; // 被同事亂改成 -1
console.log(money); // -1

這時,就可以用閉包來解決這個問題:

利用閉包的特性,把這些 function 給封裝起來,會比較安全

function createWallet(initMoney) {
  var money = initMoney;
  return {
    add: function (num) {
      money += num;
    },
    deduct: function (num) {
      if (num >= 10) {
        money -= 10;
      } else {
        money -= num;
      }
    },
    getMoney: function () {
      return money;
    },
  };
}

let myWallet = createWallet(99);
myWallet.add(1);
myWallet.deduct(100);
console.log(myWallet.getMoney()); // 90

改成用這樣寫之後,其他人就沒辦法用什麼 myWallet.money = -1; 來把我的 money 亂改值了

其他人沒辦法從「外部」去操控 money 的值,只能使用 createWallet 回傳回來的這些 function 來操控我傳進去的 money 的值


#javascript







Related Posts

CH4. 老手看函式:理解函式呼叫

CH4. 老手看函式:理解函式呼叫

再戰原型鏈

再戰原型鏈

Github page

Github page


Comments