Lesson 1.5.1 — Stack Frames
0. Metadata (Thông tin bài học)
| Thuộc tính | Giá trị |
|---|---|
| Stage | 1 — JavaScript Execution Model |
| Module | 1.5 — Call Stack & Execution Tracing |
| Lesson | 1.5.1 — Stack Frames |
| Competency | C02 — JavaScript Runtime (C02.6 Call Stack) |
| Depth Target | L3–L4 (Use / Debug) |
| Prerequisites | Execution Context (Module 1.1), Scope & Lexical Environment (Module 1.2), Hoisting & TDZ (Module 1.3), Closure (Module 1.4) |
| Cognitive Load | Medium |
1. Why This Exists (Vì sao cần học)
Bạn mở DevTools, thấy một lỗi và panel Call Stack hiển thị hàng loạt tên function xếp chồng lên nhau. Bạn đọc trace nhưng không chắc:
- Frame trên cùng là function đang chạy hay function đã gọi nó?
- Tại sao một biến local vẫn "sống" trong frame dù function chưa return?
- Dòng
Maximum call stack size exceededxuất hiện từ đâu?
Nếu chỉ biết "Execution Context là môi trường để code chạy", bạn chưa đủ khả năng trace chương trình. Bạn cần một mental model về cấu trúc vật lý của các context đang active — đó chính là Stack Frame.
INFO
Module này không dạy cách viết code mới. Module này dạy cách nhìn code đang chạy.
2. Prerequisites (Yêu cầu đầu vào)
Trước khi học bài này, bạn cần:
- [ ] Giải thích được Global vs Function Execution Context.
- [ ] Vẽ được Execution Context Stack cơ bản (từ Lesson 1.1.5).
- [ ] Phân biệt được Scope và Execution Context.
- [ ] Hiểu Closure ở mức: inner function giữ reference tới outer environment.
Nếu thiếu, quay lại Module 1.1 và 1.4 trước.
3. Learning Objectives (Mục tiêu học tập)
Sau bài này, bạn có thể:
- Phân biệt Execution Context, Stack Frame và Call Stack.
- Vẽ trạng thái call stack tại một dòng code bất kỳ trong nested function calls.
- Dự đoán thứ tự push/pop frame khi một đoạn code chạy.
- Đọc một stack trace đơn giản và xác định function nào đang thực thi.
- Giải thích nguyên nhân của
RangeError: Maximum call stack size exceeded.
4. Mental Model (Mô hình tư duy)
Mỗi lần gọi function = đẩy một khung (frame) lên đỉnh ngăn xếp.
Khung chứa:
- execution context của function đó
- local variables
- điểm trở về (return address)
Khi function return = lấy khung trên cùng ra khỏi ngăn xếp.
Ngăn xếp luôn là LIFO: Last In, First Out.Mental Model
Trong curriculum này, ta hình dung Stack Frame như một "snapshot" của Execution Context tại thời điểm function đang active. Khi function return, snapshot bị xóa khỏi stack, nhưng nếu có closure giữ reference, dữ liệu trong heap vẫn tồn tại (sẽ học sâu ở Stage 11).
5. Core Concepts (Các khái niệm cốt lõi)
Essential (Bắt buộc)
| Khái niệm | Định nghĩa |
|---|---|
| Stack Frame | Đơn vị cấu trúc trên Call Stack đại diện cho một lần gọi function đang active. |
| Call Stack | Cấu trúc dữ liệu LIFO quản lý các Stack Frame. |
| Push | Thêm frame mới khi function được gọi. |
| Pop | Xóa frame trên cùng khi function return. |
| Stack Trace | Danh sách các frame từ đỉnh xuống đáy, thường xuất hiện khi có lỗi. |
Supporting (Hỗ trợ)
- Frame contents: local bindings, arguments,
thisbinding, outer environment reference. - Stack depth: số lượng frame hiện tại trên stack.
Awareness (Biết tồn tại)
- Tail Call Optimization (TCO) được định nghĩa trong ECMAScript nhưng không được triển khai rộng rãi trong V8/SpiderMonkey hiện tại.
Out of Scope (Không học trong bài này)
- V8 stack frame implementation internals.
- Memory layout của frame trong bộ nhớ (stack pointer, base pointer).
- Garbage Collection liên quan đến stack (Stage 11).
async/ Promise stack behavior (Stage 3).
6. Worked Example (Ví dụ phân tích từng bước)
Code:
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(x) {
const result = square(x);
console.log(result);
}
printSquare(4);Trace từng bước:
Step 1 — Global Execution bắt đầu
Call Stack:
[ Global Frame ]Step 2 — printSquare(4) được gọi
Call Stack:
[ Global Frame ]
[ printSquare(4) — x=4 ]Step 3 — Bên trong printSquare, square(x) được gọi
Call Stack:
[ Global Frame ]
[ printSquare(4) — x=4, result=? ]
[ square(4) — n=4 ]Step 4 — Bên trong square, multiply(n, n) được gọi
Call Stack:
[ Global Frame ]
[ printSquare(4) — x=4, result=? ]
[ square(4) — n=4 ]
[ multiply(4, 4) — a=4, b=4 ]Step 5 — multiply return 16 Frame multiply bị pop.
Call Stack:
[ Global Frame ]
[ printSquare(4) — x=4, result=? ]
[ square(4) — n=4 ]Step 6 — square return 16 Frame square bị pop.
Call Stack:
[ Global Frame ]
[ printSquare(4) — x=4, result=16 ]Step 7 — console.log(16) Một frame mới cho console.log được push (internal implementation), sau đó pop ngay.
Step 8 — printSquare return undefined Frame printSquare bị pop.
Call Stack:
[ Global Frame ]Quan sát quan trọng
Frame trên cùng luôn là function đang thực thi. Frame dưới là function đã gọi nó. Khi một frame pop, điều khiển quay lại frame ngay bên dưới.
7. Prediction Exercise (Bài tập dự đoán)
Bài 1
Đừng chạy code. Hãy vẽ trạng thái Call Stack ngay trước khi dòng return a + b + c; thực thi.
function inner(c) {
return a + b + c; // ← Vẽ stack tại đây
}
function middle(b) {
return inner(b + 1);
}
function outer(a) {
return middle(a + 1);
}
outer(1);Đáp án & Giải thích
[ Global Frame ]
[ outer(1) — a=1 ]
[ middle(2) — b=2 ]
[ inner(3) — c=3 ]Giải thích:
outer(1)push frameouter,a = 1.middle(a + 1)=middle(2), push framemiddle,b = 2.inner(b + 1)=inner(3), push frameinner,c = 3.- Tại dòng
return, stack có đúng 4 frame.
Bài 2
Đừng chạy code. Đoán điều gì xảy ra với Call Stack.
function a() {
b();
}
function b() {
a();
}
a();Đáp án & Giải thích
Stack sẽ grow vô hạn:
[ Global ]
[ a() ]
[ b() ]
[ a() ]
[ b() ]
...Đến một giới hạn, engine throw RangeError: Maximum call stack size exceeded.
Tại sao? Không có base case hay điều kiện dừng. Mỗi lần gọi push một frame mới mà không bao giờ pop. Đây là stack overflow.
8. Implementation Lab (Bài lab thực hành)
Lab 1 — Trace by Hand (Guided)
Cho đoạn code sau. Điền vào bảng trạng thái stack sau mỗi bước.
function greet(name) {
const message = `Hello, ${name}`;
return format(message);
}
function format(text) {
return text.toUpperCase();
}
const output = greet("world");| Bước | Hành động | Call Stack (từ đáy → đỉnh) | Giải thích |
|---|---|---|---|
| 1 | Global bắt đầu | Global | Script bắt đầu chạy. Global Execution Context được tạo và đẩy lên stack. |
| 2 | greet("world") | Global → greet | Engine gặp lời gọi greet("world"). Một frame mới cho greet được push. Trong frame này: name = "world". |
| 3 | format(message) | Global → greet → format | Bên trong greet, lời gọi format(message) xảy ra. Frame format được push lên đỉnh. text = "Hello, world". |
| 4 | format return | Global → greet | format thực thi text.toUpperCase() và return "HELLO, WORLD". Frame format bị pop. Điều khiển quay về greet. |
| 5 | greet return | Global | greet nhận kết quả từ format, return về Global. Frame greet bị pop. |
| 6 | Gán output | Global | Global code tiếp tục: output = "HELLO, WORLD". Chỉ còn Global Frame. |
Đáp án Lab 1 — Trace by Hand
Giải thích: Mấu chốt là nhận ra frame chỉ tồn tại trong thời gian function đang active (chưa return). Khi
formatchạy,greetvẫn nằm bên dưới trong stack vì nó đang chờformattrả kết quả. Đây chính là tính chất LIFO: function nào được gọi sau cùng thì hoàn thành trước.Điểm dễ nhầm: Nhiều người nghĩ
const messagetồn tại sau khigreetreturn. Thực tế,messagelà local binding trong framegreet. Khi frame pop, binding đó không còn trên stack. Nếu có closure giữ lạimessage, dữ liệu sẽ nằm trên heap — không liên quan đến stack frame nữa.
Lab 2 — Partial Scaffold
Hoàn thành hàm traceStack để mô phỏng stack depth. Hàm nhận vào depth và in ra số lần đệ quy còn lại.
function traceStack(depth) {
console.log("Frame pushed. Current depth:", depth);
// TODO: Điều kiện dừng để tránh stack overflow
if (depth <= 0) {
console.log("Base case reached. Returning...");
return;
}
// TODO: Gọi đệ quy với depth giảm đi 1
traceStack(depth - 1);
console.log("Frame popped. Depth was:", depth);
}
traceStack(3);Expected output:
Frame pushed. Current depth: 3
Frame pushed. Current depth: 2
Frame pushed. Current depth: 1
Base case reached. Returning...
Frame popped. Depth was: 1
Frame popped. Depth was: 2
Frame popped. Depth was: 3Gợi ý
Thứ tự pop ngược với thứ tự push. Đây chính là tính chất LIFO.
Đáp án Lab 2 — Partial Scaffold
Giải thích điều kiện dừng (
depth <= 0): Đệ quy phải có một điểm mà nó không gọi chính mình nữa. Nếudepthbắt đầu từ 3, các lần gọi sẽ là 3 → 2 → 1 → 0. Tạidepth = 0, ta không gọi tiếp mà return ngay. Nếu thiếu điều kiện này,traceStacksẽ gọitraceStack(-1), rồitraceStack(-2)... vô hạn → stack overflow.Giải thích
traceStack(depth - 1): Mỗi lần gọi đệ quy phải tiến gần hơn đến base case. Giảmdepthđi 1 đảm bảo sau một số hữu hạn bước, ta đạtdepth <= 0.Giải thích thứ tự output: Dòng
console.log("Frame popped...")nằm sau lời gọi đệ quy. Điều này có nghĩa:traceStack(3)in "pushed 3", rồi gọitraceStack(2).traceStack(2)in "pushed 2", rồi gọitraceStack(1).traceStack(1)in "pushed 1", rồi gọitraceStack(0).traceStack(0)in "pushed 0", chạm base case, return.- Quay lại
traceStack(1), thực thi dòng sau đệ quy → in "popped 1". - Quay lại
traceStack(2), in "popped 2". - Quay lại
traceStack(3), in "popped 3".
Đây chính là minh họa trực quan nhất cho tính chất LIFO: frame nào push sau cùng (depth 0) thì pop trước tiên.
Mental model: Hãy hình dung mỗi lần gọi
traceStacknhư đặt một cuốn sách lên chồng sách. Bạn chỉ có thể lấy cuốn trên cùng ra (pop) sau khi đã xử lý xong mọi cuốn được đặt lên sau nó.
Sau khi hoàn thành 2 lab trên, tự kiểm tra:
- [ ] Tôi có thể vẽ call stack của
traceStack(3)tại thời điểm ngay trước khi base case chạy không? (Đáp án: 4 frame — Global + 3 + 2 + 1 + 0) - [ ] Tôi có hiểu tại sao
console.log("Frame popped")in ra theo thứ tự 1, 2, 3 không? (Đáp án: vì đó là thứ tự pop — LIFO) - [ ] Nếu đổi điều kiện thành
depth === 0thay vìdepth <= 0, điều gì xảy ra nếu ai đó gọitraceStack(-1)? (Đáp án: stack overflow vì-1không bao giờ bằng0)
9. Edge Cases (Các trường hợp ngoại lệ)
Edge Case 1 — Anonymous Functions trong Stack Trace
const fn = function() {
throw new Error("boom");
};
fn();WARNING
Stack trace có thể hiển thị fn hoặc (anonymous) tùy engine. Nếu function expression không có tên, việc debug stack trace trở nên khó khăn. Đây là lý do nhiều style guide khuyến khích đặt tên cho function expression khi có thể.
Edge Case 2 — Stack Overflow không phải lúc nào cũng do đệ quy trực tiếp
function a() { b(); }
function b() { c(); }
function c() { a(); }
a();DANGER
Đây là mutual recursion — đệ quy gián tiếp. Stack vẫn overflow nhưng pattern khó nhận ra hơn đệ quy trực tiếp function f() { f(); }.
Edge Case 3 — Frame vẫn tồn tại trong closure context
function outer() {
const x = 10;
return function inner() {
return x;
};
}
const fn = outer();INFO
Sau khi outer return, frame outer bị pop khỏi call stack. Tuy nhiên, nếu inner được trả về và giữ lại, lexical environment của outer vẫn tồn tại trong heap (do closure). Đừng nhầm lẫn: frame biến mất khỏi stack, nhưng environment có thể vẫn được giữ lại ở nơi khác.
10. Debug Lab (Bài lab gở lỗi)
Symptom (Triệu chứng):
Console hiển thị RangeError: Maximum call stack size exceeded.
Reproduction (Tái hiện lỗi):
function factorial(n) {
return n * factorial(n - 1);
}
factorial(5);Evidence (Bằng chứng):
Lỗi xảy ra ngay lập tức, không phụ thuộc vào giá trị input lớn.
Hypothesis (Giả thuyết):
Function gọi chính nó vô hạn vì không có điều kiện dừng.
Verification (Xác minh):
Trace mental model:
factorial(5)push frame 1factorial(4)push frame 2- ...không bao giờ dừng
- Stack đầy → throw
Root Cause (Nguyên nhân gốc rễ):
Thiếu base case. Đệ quy phải có điểm dừng để bắt đầu pop frame.
Fix:
function factorial(n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1);
}Prevention (Phòng ngừa):
Trước khi viết đệ quy, luôn xác định:
- Base case là gì?
- Mỗi bước đệ quy có tiến gần base case không?
11. Design Exercise (Bài tập thiết kế giải pháp)
Ngữ cảnh: Bạn cần tính tổng của một mảng số.
Constraint: Không dùng vòng lặpfor/while.
Câu hỏi: Bạn có nên dùng đệ quy cho mảng có 100,000 phần tử không? Tại sao?
Đáp án tham khảo
Không nên.
Dù đệ quy đúng logic, JavaScript engine (V8, SpiderMonkey) không tối ưu Tail Call ở thời điểm hiện tại. 100,000 frame đồng thời trên call stack sẽ gây stack overflow.
Quyết định: Dùng Array.prototype.reduce hoặc iteration. Nếu bắt buộc đệ quy, cần chia đôi (divide-and-conquer) để giảm độ sâu stack, hoặc chấp nhận giới hạn đầu vào.
Trade-off: Đệ quy = code ngắn gọn nhưng rủi ro stack overflow. Iteration = an toàn về stack nhưng ít "functional".
12. Production Scenario (Tình huống thực tế)
Bạn nhận được một error log từ Sentry:
RangeError: Maximum call stack size exceeded
at normalizePath (/app/utils/path.js:42:15)
at resolveAlias (/app/utils/path.js:88:10)
at normalizePath (/app/utils/path.js:45:18)
at resolveAlias (/app/utils/path.js:88:10)
...Câu hỏi:
- Từ stack trace, bạn nhận ra pattern gì?
- Đây là lỗi ở data hay logic?
- Bạn sẽ bắt đầu fix từ đâu?
Đáp án tham khảo
- Pattern:
normalizePathvàresolveAliasgọi lẫn nhau tạo thành vòng lặp vô hạn (mutual recursion). - Loại lỗi: Logic — không phải do input quá lớn, mà do điều kiện dừng bị miss hoặc alias trỏ ngược lại chính path gốc.
- Hướng fix: Kiểm tra điều kiện dừng trong
normalizePath. Có thể cần mộtvisitedSet để phát hiện circular alias trước khi stack overflow xảy ra.
13. AI-Assisted Exercise (Bài tập với AI)
Level B — Challenge (Thách thức)
- Tự trả lời trước: Viết ra giấy sự khác biệt giữa "Execution Context" và "Stack Frame" bằng chính lời của bạn.
- Hỏi AI: "Explain the difference between Execution Context and Stack Frame in JavaScript."
- So sánh: Câu trả lời AI có nhắc đến heap, closure, hay outer environment reference không?
- Verify: Kiểm tra lại bằng MDN hoặc ECMAScript spec nếu AI nói điều gì mơ hồ.
Gợi ý
Để ý xem AI có gộp "frame bị pop" với "environment bị xóa" hay không. Đây là điểm mù phổ biến: AI thường nói "when function returns, its variables are destroyed" mà không nhắc đến trường hợp closure giữ lại environment.
Đáp án tham khảo
Bạn nghĩ: Execution Context là môi trường chạy với bindings và scope. Stack Frame là cách engine tổ chức context đó trên Call Stack. Khi function return, frame pop khỏi stack, nhưng nếu có closure, environment vẫn tồn tại trên heap.
AI trả lời: AI thường nói đúng rằng Execution Context chứa scope và variables, Stack Frame là đơn vị trên Call Stack. Tuy nhiên, AI thường thiếu depth khi nói: "khi function kết thúc, mọi thứ trong nó bị xóa" — điều này sai với closure.
So sánh: AI đúng ở bề mặt nhưng thiếu phân biệt giữa stack (LIFO, tự động quản lý) và heap ( Garbage Collection, có thể giữ lại qua closure).
Điểm AI nói sai hoặc quá mơ hồ: Cụm "variables are destroyed when function returns" — quá mơ hồ. Nó không phân biệt local primitive (thực sự biến mất khỏi frame) với object được closure giữ reference (vẫn reachable trên heap).
Kết luận: Nếu bạn chỉ ra được điểm này, bạn đã hiểu sâu hơn AI về relationship giữa stack lifetime và closure lifetime. Concept này sẽ quay lại ở Stage 11 (Memory & GC).
14. Teach Back (Dạy lại)
Câu hỏi: Giải thích cho một đồng nghiệp junior — trong 2 phút — tại sao đoạn code sau lại throw Maximum call stack size exceeded, và stack trace sẽ trông như thế nào.
function process(data) {
if (data.length === 0) return 0;
return data[0] + process(data.slice(1));
}
process([1, 2, 3]); // giả sử đây là mảng 100,000 phần tửMô phỏng
- Bạn nói: Đoạn này dùng đệ quy để cộng dồn mảng. Mỗi lần gọi
process, một frame mới được push lên call stack. Với mảng 100,000 phần tử, bạn có 100,000 frame đồng thời. Call Stack của JavaScript có giới hạn (thường vài nghìn đến chục nghìn tùy engine). Khi vượt giới hạn, engine throwRangeError: Maximum call stack size exceeded. Stack trace sẽ hiển thịprocesslặp đi lặp lại hàng trăm lần. Cách sửa là dùng vòng lặp hoặcreduceđể chỉ dùng một frame duy nhất.
💡 Hình dung call stack như một chồng đĩa: bạn chỉ có thể chồng được nhiều đĩa đến một độ cao nhất định. Đệ quy sâu = chồng quá nhiều đĩa.
Gợi ý đánh giá bản thân
- Đồng nghiệp có hiểu tại sao lỗi xảy ra không, hay họ chỉ nghĩ "do mảng quá lớn"?
- Bạn có dự đoán được stack trace sẽ trông như thế nào không?
- Bạn có giải thích được tại sao
data.slice(1)tạo ra đệ quy sâu mà không chia nhánh không?
15. Assessment (Đánh giá)
| Objective (Mục tiêu) | Hình thức đánh giá | Depth |
|---|---|---|
| Phân biệt Execution Context, Stack Frame, Call Stack | Explain (Giải thích) | L2 |
| Vẽ stack state tại một điểm trong code | Prediction (Dự đoán) | L3 |
| Đọc và interpret stack trace đơn giản | Debug (Gỡ lỗi) | L4 |
| Giải thích stack overflow | Explain (Giải thích) | L3 |
16. Exit Criteria (Tiêu chí qua bài)
- [ ] Có thể vẽ Call Stack của một chuỗi nested function calls (3–4 tầng) tại bất kỳ thời điểm nào.
- [ ] Có thể dự đoán thứ tự push/pop của các frame khi đọc code.
- [ ] Có thể đọc một stack trace đơn giản và xác định function nào đang thực thi, function nào là caller.
- [ ] Có thể giải thích tại sao
RangeError: Maximum call stack size exceededxảy ra và đề xuất cách phòng ngừa. - [ ] Có thể phân biệt "frame bị pop khỏi stack" với "environment bị xóa hoàn toàn" (trong ngữ cảnh closure).
17. Spiral Connection (Liên kết xoắn ốc)
Previous (Trước): Execution Context (Module 1.1) → Scope & Lexical Environment (Module 1.2) → Hoisting & TDZ (Module 1.3) → Closure (Module 1.4). Bạn đã biết code chạy trong context, biến được resolve theo scope chain, và closure giữ environment. Nhưng bạn chưa hình dung cách các context này được tổ chức trong thời gian thực thi.
Current (Hiện tại): Stack Frames — mỗi Execution Context active được đặt lên Call Stack dưới dạng frame. Bạn giờ có thể trace chương trình bằng tay, không chỉ hiểu lý thuyết.
Next (Tiếp theo): Nested Calls (1.5.2) → Recursion (1.5.3) → Exception & Stack Unwinding (1.5.4) → Full Execution Trace (1.5.5). Sau đó, concept này sẽ quay lại ở Stage 3 (Async / Event Loop), Stage 8 (React render stack), và Stage 11 (Memory profiling & stack vs heap).