主題: 學習筆記

JavaScript 函式執行時發生了什麼?從執行脈絡到區域記憶體

透過自編範例追蹤 JavaScript 函式呼叫、參數、回傳值與區域變數,並釐清重複呼叫和閉包的差異。

同一個函式呼叫兩次,函式裡的變數會接著上一次的值繼續算嗎?我在練習時,一開始以為會。把每次呼叫拆開追蹤後,才發現一般區域變數會在每次呼叫時重新建立。

這篇是我學習 JavaScript: The Hard Parts, v3 時整理的筆記:從函式呼叫開始,看看參數、執行脈絡和回傳值如何接在一起。本文聚焦一般同步函式;後面也會用一個小例子說明閉包的差異。

從一行呼叫開始,追蹤程式接下來進到哪裡。 · 來源:GIPHY

定義、參照與呼叫

先看這段程式:

function double(value) {
  const result = value * 2;
  return result;
}

const action = double;
const first = action(3);
const second = double(5);

console.log(first, second); // 6 10

function double(value) 定義了函式。const action = double 則把函式的參照交給 action;執行這一行時,函式內容還沒有執行。

接著,action(3) 和 double(5) 才是兩次函式呼叫。括號裡的 3、5 是呼叫時傳入的引數(arguments);定義裡的 value 是接收值的參數(parameter)。兩個變數都指向同一個函式,但各次呼叫有各自的執行狀態。

什麼是執行脈絡?

呼叫一般函式時,JavaScript 會為這次呼叫建立一個執行脈絡(execution context)。它追蹤目前的程式執行狀態、變數綁定,以及其他執行所需的資訊。

課程用兩個概念幫助理解:

  • 執行流程:目前程式執行到哪裡,以及何時進入函式、回到呼叫處。
  • 區域記憶體:這次呼叫中的參數與區域變數,例如 value 和 result。

這是閱讀程式時的簡化模型,不是在宣稱引擎一定用兩個實體盒子存放資料。執行流程也不永遠只是往下一行:遇到函式呼叫、條件或迴圈時,會依程式的控制流程改變。呼叫函式不會因此建立一條新的作業系統執行緒。

追蹤兩次呼叫

回到 double 的例子,每次呼叫都建立自己的函式執行脈絡:

呼叫 參數 value 區域變數 result 回傳值
action(3) 3 6 6
double(5) 5 10 10

第一次呼叫結束後,action(3) 的結果是 6,因此 first 得到 6。第二次呼叫會以 5 作為新的 value,計算並回傳 10,所以 second 得到 10。

return 把值交還給呼叫處,但不一定要存入變數。例如:

function double(value) {
  return value * 2;
}

const result = double(4) + 1;
console.log(result); // 9

這裡 double(4) 先得到 8,程式接著加上 1,最後才將 9 指派給 result。相對地,console.log() 的工作是顯示值;它不會代替函式的 return。

函式裡再呼叫函式:執行流程怎麼走?

function inner(value) {
  return value * 2;
}

function outer(value) {
  const doubled = inner(value);
  return doubled + 1;
}

console.log(outer(3)); // 7
  1. 外部程式呼叫 outer(3),進入 outer。
  2. outer 執行到 inner(value),再進入 inner,這次 value 是 3。
  3. inner 回傳 6,結束這次呼叫;程式回到 outer,將 6 指派給 doubled。
  4. outer 回傳 7,外部的 console.log() 印出 7。

可以把這個過程想成呼叫堆疊:進入 outer,再進入 inner;inner 結束後回到 outer,最後回到外部程式。函式完成時,它的執行脈絡會離開堆疊,控制權交還呼叫處。

為什麼重複呼叫不會自動累積?

function step() {
  let count = 0;
  count += 1;
  return count;
}

console.log(step(), step()); // 1 1

第一次呼叫建立 count,從 0 加到 1,然後回傳。第二次呼叫會重新執行 let count = 0,所以也是回傳 1。

我原本把兩次呼叫想成共用同一個 count,因此猜成 1 2。真正的差別是:這段程式的 count 宣告在函式內,每次呼叫都有自己的變數。函式外也不能直接用 count 這個名稱取得它。

再呼叫一次,也會重新建立該次呼叫的區域變數。 · 來源:GIPHY

例外:閉包可以保留外層變數

「函式結束後,所有區域變數一定消失」並不是完整的說法。如果回傳的內部函式仍引用外層變數,它可以繼續使用那個變數:

function makeCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const firstCounter = makeCounter();
console.log(firstCounter(), firstCounter()); // 1 2

const secondCounter = makeCounter();
console.log(secondCounter()); // 1

makeCounter() 回傳的 increment 函式會透過閉包(closure)保留對外層 count 的存取。呼叫 firstCounter 兩次,會更新同一個 count;再次呼叫 makeCounter() 則會建立另一個獨立的 count。

外層函式的執行脈絡已經離開呼叫堆疊,但仍被閉包引用的變數可以繼續存在。這也不代表我們能從程式結果推定垃圾回收發生的確切時間。閉包值得另外深入;在這裡,先記住它是「一般區域變數每次呼叫重新建立」之外需要考慮的情況。

小結

現在讀到函式呼叫時,我會先問自己:這是函式的參照,還是實際呼叫?引數傳給了哪個參數?這次呼叫有哪些區域變數?回傳後,執行流程回到哪裡?

把一次呼叫當成一次獨立的追蹤,再留意是否有閉包,就比較不容易把不同次呼叫的狀態混在一起。

參考資料