主題: 學習筆記
JavaScript 函式執行時發生了什麼?從執行脈絡到區域記憶體
透過自編範例追蹤 JavaScript 函式呼叫、參數、回傳值與區域變數,並釐清重複呼叫和閉包的差異。
同一個函式呼叫兩次,函式裡的變數會接著上一次的值繼續算嗎?我在練習時,一開始以為會。把每次呼叫拆開追蹤後,才發現一般區域變數會在每次呼叫時重新建立。
這篇是我學習 JavaScript: The Hard Parts, v3 時整理的筆記:從函式呼叫開始,看看參數、執行脈絡和回傳值如何接在一起。本文聚焦一般同步函式;後面也會用一個小例子說明閉包的差異。
定義、參照與呼叫
先看這段程式:
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
- 外部程式呼叫
outer(3),進入outer。 outer執行到inner(value),再進入inner,這次value是3。inner回傳6,結束這次呼叫;程式回到outer,將6指派給doubled。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 這個名稱取得它。
例外:閉包可以保留外層變數
「函式結束後,所有區域變數一定消失」並不是完整的說法。如果回傳的內部函式仍引用外層變數,它可以繼續使用那個變數:
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。
外層函式的執行脈絡已經離開呼叫堆疊,但仍被閉包引用的變數可以繼續存在。這也不代表我們能從程式結果推定垃圾回收發生的確切時間。閉包值得另外深入;在這裡,先記住它是「一般區域變數每次呼叫重新建立」之外需要考慮的情況。
小結
現在讀到函式呼叫時,我會先問自己:這是函式的參照,還是實際呼叫?引數傳給了哪個參數?這次呼叫有哪些區域變數?回傳後,執行流程回到哪裡?
把一次呼叫當成一次獨立的追蹤,再留意是否有閉包,就比較不容易把不同次呼叫的狀態混在一起。