JavaScript: Execution Context & Closure


Posted by Nicolakacha on 2020-10-03

上篇文章有稍微提過過執行環境(Execution Context)的模型來解釋 Hoisting 原理,這次我們要把模型建構地更詳細完整一點,並用執行環境的模型來進一步了解什麼是閉包(Closure)。

真正在執行 JavaScript 時,可分為編譯與執行兩個階段,在編譯時會把需要的資源準備好,在執行階段時再執行程式碼。

Global EC & VO

JavaScript 在編譯階段時,首先會建立全域執行(global EC),在 global EC 內會有一個變數物件(VO),宣告過的變數、函式都會被加到這個 VO 內,並採下列這種方始初始化:

函式宣告:

  • 把 function 宣告放進 VO
  • 如果 VO 已經有個同名的 property,則取代之

變數宣告:

  • 把變數宣告放進 VO,並初始化成 undefined
  • 如果 VO 已經有重複的同名 property,該 property 所指向的值和屬性都不會改變
  • 如果變數宣告和 function 宣告或函式同名,則不改變先前的 property

scopeChain & [[Scope]]

上面是我們已經知道的部分,但除了把變數和函式放進 VO 之外,global EC 還會有一個作用域鏈(scopeChain),初始化的時候會把 VO 放進 scopeChain 內:

scopeChain = [globalEC.VO]

global EC 內的每個函式都有一個 [[Scope]] 屬性,global EC 遇到函式時,會將它初始化為 global EC 的 scopeChain:

function.[[Scope]] = globalEC.scopeChain

函式的 local EC & AO

當一個函式被宣告、呼叫或執行,也都會建立 EC,被稱為 local EC,並放進 JavaScript 的執行堆疊(Call Stack)裡,最新被宣告的函式就會在執行堆疊最上面。JavaScript 程式碼稍後在執行階段執行程式式,就會由上往下來逐一完成 Call Stack 裡的 EC,這種執行方式可以理解成 Last In, First Out。

不只 Global EC 有 VO,函式的 local EC 也會有 VO,在這裡被稱為 Activation Object(AO),在函式內除了宣告的變數和函式會被加入 AO 之外,外面傳進來的參數也會被加到這個 AO 裡。

參數:

  • 把參數放進 VO
  • 沒有傳的參數會被初始化成 undefined

這裡的重點是,函式的 local EC 也會有自己的 scopeChain,它的 scopeChain 會參考「自己的 AO 加上自己的 [[Scope]]」,這個 [[Scope]] 是我們在進入 Global EC 時,這個函式就有的:
function.[[Scope]] = globalEC.scopeChain。而 globalEC.scopeChain 就是 [globalEC.VO]

所以函式的 scopeChain 會像這樣:
function.scopeChain = [function.AO, function.[[Scope]]]

除了建立自己的 scopeChain 之外,也像在 global EC 一樣,所有在裡面宣告的函式, [[Scope]] 都會被初始化為 local EC 的 scopeChain:
inner.[[Scope]] = function.scopeChain

實際範例跑一遍

以下列程式碼為範例:

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

test();

globalEC 有 VO,並建立一個 scopeChain,這個 scopeChain 是 globalEC 的 VO:

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

並把 VO 裡的 test 函式的 [[Scope]] 初始化:

test.[[Scope]] = globalEC.scopeChain

testEC 也有自己的 AO,進入 testEC 後,把宣告的 b 變數放進 AO,初始化為 undefined,inner 函式也會被放進來:

testEC {
    AO: {
    b: undefined,
    inner: function
    }
}

testEC 也會有 scopeChain,它的 scopeChain 是 AO 加上 test 的 [[Scope]],test.[[Scope]] 就是 globalEC.scopeChain,即是 globalEC.VO,所以 testEC 的 scopeChain 是 [testEC.AO, globalEC.VO]

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

此外,testEC 也會把 AO 裡宣告的 inner 函式的 [[Scope]] 給初始化:

inner.[[Scope]] = testEC.scopeChain

inner 也有自己的 innerEC 和 AO,並把宣告的變數 c 放進來,初始化成 undefined:

innerEC {
    AO: {
    c: undefined
    },
}

innerEC 的 scopeChain 是 AO 加上 inner 的 [[Scope]],而我們知道 inner.[[Scope]] = testEC.scopeChain,testEC.scopeChain 又等於 [testEC.AO, globalEC.VO],所以可以得到 innerEC 的 scopeChain:

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

這樣就可以得到程式編譯完成時的 EC 模型了:

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

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

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

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

test.[[Scope]] = globalEC.scopeChain

執行 JavaScript

剛才有提到,越新 的 EC 會被放進 Call Stack 的越上面,當開始執行時,從最上面往下執行,當一個 EC 被執行完,就會從 Call Stack 中抽離,AO 和 scopeChain 也一起消失。

程式開始執行後的 EC 模型

innerEC: { // 第四步,進入 inner 函式
  AO: {
    c: 3  // 第五步,將 c 賦值為 3
  },
  scopeChain : [innerEC.AO, testEC.AO, globalEC.VO]
}

testEC: {   // 第二步,進入 test 函式
  AO: {
    b: undefined,  // 第三步,將 b 賦值為 2
    inner: function
  }
  scopeChain: [testEC.AO, globalEC.VO]
}

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

globalEC: {
  VO: {
    a: 1  // 第一步先讓 a 賦值為 1
    test: function
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

透過上面這個模型,我們可以知道程式在執行時,是怎麼在 AO 或 VO 裡面找到宣告過的變數,若該作用域沒有該變數,就會沿著 scopeChain 不斷會往上一層找。

程式碼:

var a = 1;
function test() {
  var b = 2;
  function inner() {
    var c = 3;
        console.log(c); 
        // 在 innerEC.AO 裡面可以找到 C
    console.log(b); 
        // 在 innerEC.AO 裡找不到,沿著 scopeChain 往上找到 testEC.AO 裡面有 b 
    console.log(a);
        // 在 innerEC.AO 裡找不到,沿著 scopeChain 往上找到 globalEC.AO 裡面有 c 
  }
  inner();
}

test();
  1. 當 inner() 被呼叫且被執行完之後, innerEC 就被從 Call Stack 中抽離,AO 和 innerEC.scopeChain 也會消失。
  2. 當 test() 被呼叫執行完之後,testEC 也從 Call Stack 中抽離,AO 和 testEC.scopeChain 也會消失。
  3. 當全部程式碼被執行完,global EC 也從 Call Stack 中抽離,VO 和 scopeChain 消失,至此程式碼全部跑完。

Closure 初探

Closure is when a function "remembers" the variables outside of it, even if you pass that function elsewhere." -- Kyle Simpson

Closure 可以理解成,當一個 function 記住某個外部的變數,而且就算把這個 function 移到別的地方,它還能繼續記住這個變數。

在下面的例子中,呼叫 ask() 執行完畢之後,照理來說就會消失,但在 ask 裡,setTimeout() 的 callback function 竟然還是可以存取到 question,這就是一種 closure:

function ask(question) {
    setTimeout(function wait() {
        console.log(question);
    }, 1000);
}
ask("What is closure?"); // What is closure?

一般看到實現 Closure 的方法是透過在一個 outter function 裡面 return 一個 inner function,讓 inner function 存取 outter function 作用域的變數。

而在下面這個簡單的範例中,其實只有第一次呼叫 cachedComplex function 時有運算,其他則都是直接取用存在 cache function 李的計算結果:

function complex(num) {
  console.log('calculate');
  return num * num * num;
}

function cache(func) {
  var ans = {};
  return function (num) {
    if (ans[num]) {
      return ans[num];
    }
    ans[num] = func(num);
    return ans[num];
  }
}

const cachedComplex = cache(complex);
console.log(cachedComplex(20));
console.log(cachedComplex(20));
console.log(cachedComplex(20));

從 EC 模型來看 Closure 在幹嘛

為什麼可以存取一個已經消失的 function 的變數?要搞懂 Closure 背後發生的事,就要回頭看看我們的 EC 模型和作用域鏈。

程式碼範例:

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

EC 模型(忘記怎麼畫可以重看前面):

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

testEC: {
    AO: {
        vTest: undefined, // 執行時變成 20
        inner: func
    },
    scopeChain: [testEC.AO, globalEC.VO];
}

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

globalEC: {
    VO: {
        v1: undefined, // 執行時變成 10
        inner: undefined, // 執行時變成 func
        test: func
    },
    scopeChain: [globalEC.VO];
}

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

雖然我們在 var inner = test(); 時已經執行完 test() 了,照理說 testEC 應該會從 Call Stack 中抽離,但當我們執行 inner 的時候,還會需要參考 testEC.AO 來拿到 vTest 和 globalEC.AO 來拿到 v1,所以 testEC 的 AO 會先被保留起來。這就是閉包的原理。

從日常生活中的作用域陷阱看 closure

下列程式碼, 在執行 arr0 的時候,i 已經變成 5 了:

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

arr[0]();
// 5

解決方法

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

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

arr[0]();
// 0

IIFE 立即函式:

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

arr[0]();
// 0

使用 let 限定變數的作用域:

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

arr[0]();
// 0

Closure 實際應用

通常 closure 的使用時機是想要隱藏住某些資訊,

例如下面的例子中,就算不是經過 deduct 函式,還是可以隨便在外部改到 my_balance:

var my_balance = 999
function deduct(n) {
  my_balance -= (n > 10 ? 10 : n) // 超過 10 塊只扣 10 塊
}

deduct(13) // 只被扣 10 塊
my_balance -= 999 // 還是被扣了 999 塊

為了保證 my_balance 不會隨便被 function 外面的程式碼重新賦值,所以可以用閉包的方式,這樣就只有透過 getWallet 裡面的 deduct 才能更改到 getWallet 的值:

function getWallet() {
  var my_balance = 999
  return {
    deduct: function(n) {
      my_balance -= (n > 10 ? 10 : n) // 超過 10 塊只扣 10 塊
    }
  }
}

var wallet = getWallet()
wallet.deduct(13) // 只被扣 10 塊
my_balance -= 999 // Uncaught ReferenceError: my_balance is not defined

參考資料

所有的函式都是閉包:談 JS 中的作用域與 Closure


#javascript #closure #程式導師計畫







Related Posts

如何做出一個好的 NodeJS 模組?

如何做出一個好的 NodeJS 模組?

Day05 - Web API

Day05 - Web API

物聯網初體驗:樹莓派與 Golang

物聯網初體驗:樹莓派與 Golang


Comments