Lesson 1.4.5 — Closure trong Loop
0. Metadata (Thông tin bài học)
| Field | Value |
|---|---|
| Stage | 1 |
| Module | 1.4 — Closures |
| Lesson | 1.4.5 — Closure trong Loop |
| Competency | C02 — JavaScript Runtime (C02.5 Closure) |
| Depth Target | L4 (Debug) |
| Prerequisites | Closure Formation (1.4.1), Closure Lifetime (1.4.2), Private State (1.4.3), Block Scope (1.2.4), Variable Resolution (1.2.6) |
| Estimated Cognitive Load | High |
Out of Scope (Ngoài phạm vi)
- Event Loop, task queue, microtask queue và scheduling internals (Stage 3)
- Timer timing guarantees và browser scheduling details (Stage 3/4)
- React stale closure (Stage 8)
- Memory profiling và Garbage Collection internals (Stage 11)
- Performance comparison giữa các cách capture
1. Why This Exists (Vì sao cần học)
Đây là một trong những bug Closure kinh điển nhất:
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}Nhiều người mới dự đoán:
0
1
2nhưng kết quả lại là:
3
3
3Cách giải thích yếu thường là:
"
varbị hoist."
Hoisting không giải thích được tại sao ba callback cùng đọc một giá trị cuối cùng.
Câu hỏi đúng phải là:
Mỗi callback đang capture binding nào?
Nếu không hiểu điểm này, bạn sẽ dễ gặp cùng một lớp bug khi:
- Tạo callback trong loop.
- Gắn event listener cho nhiều element.
- Tạo nhiều function trong một vòng lặp.
- Lưu handlers vào array.
- Xây các factory nhỏ bằng loop.
- Debug code mà nhiều callback đều trả về cùng một value ngoài dự kiến.
Lesson này dùng Closure + Scope để giải thích bug theo cơ chế, không theo mẹo.
2. Prerequisites (Yêu cầu đầu vào)
Trước khi học bài này, bạn phải:
- [ ] Giải thích được Closure Formation.
- [ ] Biết function giữ reference cần thiết đến lexical environment nơi nó được tạo.
- [ ] Phân biệt
varfunction scope vớiletblock scope. - [ ] Biết Variable Resolution tìm binding qua lexical environment chain.
- [ ] Biết mỗi invocation của function tạo local bindings riêng.
- [ ] Phân biệt value với binding.
Nếu thiếu, quay lại Lesson 1.2.4, 1.2.6 và 1.4.1 trước.
3. Learning Objectives (Mục tiêu học tập)
Sau bài này, bạn có thể:
- Giải thích tại sao loop dùng
varcó thể tạo nhiều closures cùng tham chiếu một binding. - Dự đoán output của các loop closure scenarios cơ bản.
- Phân biệt shared binding với per-iteration binding.
- Debug bug
3 3 3bằng environment model. - Sửa bug bằng
let, IIFE/explicit capture hoặc factory function. - Chọn cách sửa phù hợp với mục tiêu code thay vì thay syntax theo mẹo.
- Transfer mental model sang event listeners và function arrays.
4. Mental Model (Mô hình tư duy)
Mental Model — Loop Closure
Khi callback được tạo trong loop, đừng hỏi:
"Callback nhớ giá trị nào?"
Hãy hỏi:
Callback giữ reference đến binding nào?
Nếu nhiều callbacks cùng resolve một binding, khi callback chạy chúng sẽ đọc giá trị hiện tại của cùng binding đó.
Nếu mỗi iteration có binding riêng, mỗi callback có thể giữ một binding khác nhau.
Với:
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}Mental model:
Function / Global Environment
└── i
↑
├── callback 1
├── callback 2
└── callback 3Ba callbacks khác nhau, nhưng cùng resolve một binding i.
Sau loop:
i = 3nên cả ba callbacks đều đọc 3.
Với:
for (let i = 0; i < 3; i++) {
callbacks.push(() => i);
}mental model cần dùng ở lesson này:
Iteration 1 Environment
└── i = 0
↑
└── callback 1
Iteration 2 Environment
└── i = 1
↑
└── callback 2
Iteration 3 Environment
└── i = 2
↑
└── callback 3Trong for loop dùng lexical declaration như let i, JavaScript tạo binding iteration phù hợp để callback của từng iteration có thể giữ value độc lập.
Common Misconception
Không nên nói:
"
letcopy giá trịivào callback."
Mental model tốt hơn:
Với
for (let i = ...), callbacks của các iteration có thể tham chiếu đến các iteration bindings khác nhau.
Closure vẫn hoạt động theo reference tới binding/environment, không chuyển thành cơ chế copy value.
5. Core Concepts (Các khái niệm cốt lõi)
Essential (Bắt buộc)
- Shared Binding: Nhiều closures cùng resolve một binding.
- Per-iteration Binding: Mỗi iteration có binding riêng trong loop phù hợp với lexical declaration.
- Captured Binding: Binding mà callback/function cần resolve từ outer environment.
- Late Read: Callback có thể đọc binding sau khi loop đã thay đổi binding đó.
- Explicit Capture: Tạo một invocation mới để chuyển value hiện tại thành parameter/local binding riêng.
Supporting (Hỗ trợ)
varkhông tạo block-scoped binding cho mỗi iteration.letcó lexical scoping và trongforloop có per-iteration binding semantics.- IIFE tạo một function invocation mới.
- Factory function tạo một invocation mới và local parameter binding riêng.
- Function array là cách quan sát closure behavior mà không cần timer.
Awareness (Biết tồn tại)
setTimeoutchỉ làm bug dễ quan sát vì callback chạy sau khi loop hoàn tất.- Cùng mental model xuất hiện trong event handlers.
- Async scheduling sẽ được học đầy đủ ở Stage 3.
6. Worked Example (Ví dụ phân tích từng bước)
Worked Example — Tại sao var cho ra 3 3 3:
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(function () {
return i;
});
}
console.log(callbacks[0]());
console.log(callbacks[1]());
console.log(callbacks[2]());Step 1 — var i được tạo:
Trong mental model của đoạn code này, loop không tạo một binding i mới cho mỗi iteration.
Ta có một binding:
iđược dùng xuyên suốt loop.
Step 2 — Iteration đầu tiên:
i = 0Một function được tạo:
function () {
return i;
}Function không lưu riêng số 0. Nó cần resolve binding i.
Step 3 — Iteration thứ hai và thứ ba:
Hai function khác tiếp tục được tạo.
Ta có:
callback 1 ─┐
callback 2 ─┼──→ same binding i
callback 3 ─┘Step 4 — Loop kết thúc:
Sau lần update cuối:
i = 3Condition:
i < 3;không còn đúng, loop dừng.
Step 5 — Callbacks được gọi:
Mỗi callback chạy:
return i;Variable Resolution của cả ba đều tìm đến cùng binding i, hiện đang có value 3.
Kết quả:
3
3
3Step 6 — Kết luận:
Bug không phải vì callbacks bị "ghi sai giá trị".
Bug đến từ:
3 functions
+
1 shared binding
+
binding changed before functions read it7. Prediction Exercise (Bài tập dự đoán)
Đừng chạy code. Dự đoán output và giải thích bằng binding model.
Prediction 1
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(callbacks[0]());
console.log(callbacks[2]());Prediction 2
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(callbacks[0]());
console.log(callbacks[2]());Prediction 3
const callbacks = [];
for (var i = 0; i < 3; i++) {
const value = i;
callbacks.push(() => value);
}
console.log(callbacks[0]());
console.log(callbacks[1]());
console.log(callbacks[2]());[Đáp án & Giải thích]
Prediction 1:
- Output:
3rồi3. - Cả hai callbacks cùng resolve một
var i. Khi loop kết thúc, binding đó có value3.
- Output:
Prediction 2:
- Output:
0rồi2. - Với
for (let i = ...), mỗi iteration có bindingiphù hợp riêng. Callback đầu giữ iteration binding có value0; callback cuối giữ binding có value2.
- Output:
Prediction 3:
- Output:
0,1,2. - Dù
ilàvar,const value = inằm trong block của loop. Mỗi iteration tạo lexical bindingvaluemới. Mỗi callback capturevaluecủa iteration tương ứng.
- Output:
Transfer Check — Function Array:
const readers = [];
for (let index = 0; index < 3; index++) {
readers.push(function () {
return index * 10;
});
}
console.log(readers[1]());Hãy trả lời:
- Callback thứ hai resolve binding nào?
- Binding đó thuộc iteration nào?
- Output là gì?
- Có cần
setTimeoutthì Closure-in-loop bug/behavior mới tồn tại không?
Gợi ý trả lời
- Callback thứ hai resolve
indexcủa iteration thứ hai. - Binding đó có value
1. - Output là
10. - Không. Timer chỉ là một cách làm delayed execution dễ thấy. Function array cũng đủ chứng minh closures đang giữ bindings nào.
8. Implementation Lab (Bài lab thực hành)
Lab 1 — Guided: Sửa bằng let
Code đang lỗi:
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(callbacks.map((fn) => fn()));Expected:
[0, 1, 2];Bước 0 — Trước khi sửa, hãy vẽ:
callback 1 ─┐
callback 2 ─┼──→ i
callback 3 ─┘Sau đó sửa code để mỗi callback giữ iteration binding riêng.
[Đáp án tham khảo]
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(callbacks.map((fn) => fn()));
// [0, 1, 2]- Giải thích:
let itrongforloop cung cấp per-iteration bindings phù hợp. Mỗi closure không còn cùng resolve một sharedvar i.
Lab 2 — Partial Scaffold: Explicit Capture bằng Function Invocation
Giữ var i, nhưng tạo binding riêng bằng một function invocation:
const callbacks = [];
for (var i = 0; i < 3; i++) {
// TODO:
// tạo một invocation nhận current i
// rồi push callback dùng parameter riêng
}
console.log(callbacks.map((fn) => fn()));
// [0, 1, 2][Đáp án tham khảo]
const callbacks = [];
for (var i = 0; i < 3; i++) {
(function (capturedI) {
callbacks.push(() => capturedI);
})(i);
}
console.log(callbacks.map((fn) => fn()));
// [0, 1, 2]- Giải thích: Mỗi lần IIFE được gọi, một parameter binding
capturedImới được tạo. Callback của invocation đó giữ binding riêng.
Lab 3 — Independent: Sửa bằng Factory Function
Implement:
function createReader(value) {
// TODO
}
const readers = [];
for (var i = 0; i < 3; i++) {
readers.push(createReader(i));
}
console.log(readers.map((fn) => fn()));
// [0, 1, 2]Constraints:
- Giữ
var i. - Không dùng IIFE.
createReader(i)phải tạo binding riêng cho từng call.- Giải thích vì sao đây là cùng mental model với IIFE.
[Đáp án tham khảo]
function createReader(value) {
return function () {
return value;
};
}
const readers = [];
for (var i = 0; i < 3; i++) {
readers.push(createReader(i));
}
console.log(readers.map((fn) => fn()));
// [0, 1, 2]- Giải thích: Mỗi call
createReader(i)là một invocation mới. Parametervaluelà local binding riêng của invocation. Returned function giữ bindingvaluetương ứng.
9. Edge Cases (Các trường hợp ngoại lệ)
Edge Case 1 — let bên ngoài loop vẫn có thể là shared binding
let i = 0;
const callbacks = [];
for (; i < 3; i++) {
callbacks.push(() => i);
}Phân tích
Chỉ nhìn thấy từ khóa let là chưa đủ.
Ở đây i được khai báo bên ngoài for, nên callbacks cùng resolve binding i đó. Sau loop, i === 3, vì vậy các callbacks sẽ đọc 3.
Rule không phải:
"
letluôn sửa closure loop bug."
Rule tốt hơn:
Hãy xác định binding được tạo ở đâu và mỗi closure đang giữ binding nào.
Edge Case 2 — Block-local binding trong loop
const callbacks = [];
for (var i = 0; i < 3; i++) {
let current = i;
callbacks.push(() => current);
}Phân tích
i vẫn là shared var binding, nhưng current là block-scoped binding được tạo cho mỗi lần block được thực thi. Các callbacks dùng current, không dùng i, nên có thể trả 0, 1, 2.
Edge Case 3 — Capture object reference
const callbacks = [];
const state = { value: 0 };
for (let i = 0; i < 3; i++) {
callbacks.push(() => state.value);
state.value = state.value + 1;
}Phân tích
Mỗi iteration có i riêng, nhưng callbacks không dùng i. Chúng cùng đọc state.value từ cùng object reference.
Khi callbacks chạy sau loop, chúng đều có thể đọc value cuối cùng của cùng object.
Per-iteration binding chỉ giúp với binding mà callback thực sự capture; nó không tự động clone mọi object trong scope.
10. Debug Lab (Bài lab gỡ lỗi)
Debug Lab — Callback trong Loop
Symptom (Triệu chứng): Developer mong đợi in ra:
0
1
2nhưng nhận:
3
3
3Reproduction (Tái hiện lỗi):
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}Evidence (Bằng chứng):
- Loop có một declaration
var i. - Ba callback functions được tạo.
- Mỗi callback dùng identifier
i. - Sau loop, binding
icó value3. - Callback không có local binding
iriêng.
Hypothesis (Giả thuyết):
Cả ba callbacks cùng capture một binding i, không phải ba value 0, 1, 2.
Verification (Xác minh):
Loại bỏ timer để tránh nhầm rằng timer là root cause:
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(i); // 3
console.log(callbacks[0]()); // 3
console.log(callbacks[1]()); // 3
console.log(callbacks[2]()); // 3Bug vẫn tồn tại.
Vậy root cause nằm ở binding model, không phải timer.
Root Cause (Nguyên nhân gốc rễ):
var loop
→ one shared binding
→ multiple closures reference same binding
→ loop changes binding
→ callbacks later read final valueFix (Sửa):
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}for (var i = 0; i < 3; i++) {
(function (capturedI) {
setTimeout(() => {
console.log(capturedI);
}, 0);
})(i);
}function createLogger(value) {
return function () {
console.log(value);
};
}
for (var i = 0; i < 3; i++) {
setTimeout(createLogger(i), 0);
}Prevention (Phòng ngừa):
- Khi tạo function trong loop, luôn hỏi: "Function này capture binding nào?"
- Không dùng rule thuộc lòng "
varxấu,lettốt" thay cho reasoning. - Nếu cần snapshot-like behavior, đảm bảo có binding riêng cho mỗi callback.
- Khi debug, thử bỏ async/timer ra để kiểm tra binding model trước.
11. Design Exercise (Bài tập thiết kế giải pháp)
Không áp dụng Design Exercise đầy đủ ở depth L4.
Thay vào đó, hãy làm implementation decision:
Bạn có code:
for (var i = 0; i < items.length; i++) {
handlers.push(() => items[i]);
}Bạn có ba options:
A. Đổi var → let
B. Giữ var + IIFE / explicit capture
C. Giữ var + factory functionHãy chọn dựa trên context:
- Nếu chỉ cần lexical iteration binding rõ ràng →
let. - Nếu đang đọc/maintain legacy code và cần hiểu explicit capture → IIFE.
- Nếu muốn tách logic tạo callback thành abstraction có tên → factory function.
Engineering Decision
Ở modern JavaScript, let thường là cách đơn giản và trực tiếp nhất cho loop index.
Nhưng lesson này vẫn học IIFE và factory vì chúng làm lộ rõ cơ chế tạo binding riêng, giúp mental model không phụ thuộc vào một mẹo syntax.
12. Production Scenario (Tình huống thực tế)
Production Scenario — Event Listener cho nhiều button
Bạn có ba button:
const buttons = document.querySelectorAll("button");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", () => {
console.log("Clicked button:", i);
});
}Context: Mỗi button phải log index của chính nó.
Symptom: Click button nào cũng log cùng một index cuối cùng.
Reasoning:
- Mỗi listener là một closure.
- Tất cả listeners dùng identifier
i. var ilà shared binding của loop.- Event xảy ra sau khi setup loop đã hoàn tất.
- Khi listener chạy, nó đọc value hiện tại của shared binding.
Fix:
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", () => {
console.log("Clicked button:", i);
});
}Bây giờ mỗi listener được tạo trong iteration tương ứng và resolve iteration binding riêng.
Production lesson: Đây không phải bug riêng của timer. Bất kỳ hệ thống nào lưu callback để gọi sau đều có thể làm shared-binding mistake lộ ra.
13. AI-assisted Exercise (Bài tập với AI)
Level B — Challenge (Thách thức)
- Tự giải thích trước: Vì sao đoạn
for (var i...)tạo3 3 3? - Hỏi AI: "Explain why closures created in a JavaScript for-loop with var often read the same final value."
- Inspect: AI có nói "
varhoisted nên thành 3" mà không nói shared binding không? - Challenge: Đưa case này cho AI:
let i = 0;
for (; i < 3; i++) {
callbacks.push(() => i);
}Hỏi tại sao let vẫn có thể cho shared final value ở đây.
- Verify: Dùng binding location để kiểm tra câu trả lời.
Gợi ý
Một câu trả lời đủ depth phải phân biệt:
keyword
≠
binding placement
≠
per-iteration bindingNếu AI chỉ dùng rule "let fixes closures", hãy yêu cầu nó giải thích binding nào được tạo ở đâu.
Đáp án tham khảo
- Với
var itrong loop, callbacks cùng resolve một shared bindingi. - Loop mutate binding đó cho tới
3. - Khi callbacks đọc sau đó, chúng đều đọc current value
3. letchỉ giải quyết case canonical khi declaration nằm trongforheader và tạo per-iteration bindings phù hợp.- Nếu
let iđược khai báo bên ngoài loop, closures vẫn có thể cùng resolve một binding. - Vì vậy root mental model là binding identity, không phải keyword memorization.
14. Teach Back (Dạy lại)
Giải thích cho một đồng nghiệp junior trong 2 phút:
"Tại sao
for (var i = 0; i < 3; i++)tạo ba callback nhưng cả ba đều có thể in3, cònfor (let i = 0; ...)lại cho0 1 2?"
Yêu cầu:
- Dùng đúng terminology: binding, lexical environment, closure, per-iteration binding.
- Không kết thúc bằng "
varhoisted". - Giải thích được vì sao ba functions khác nhau vẫn có thể cùng đọc một state.
- Giải thích được IIFE/factory sửa bug bằng cách nào.
Mô phỏng
- Bạn nói:
- Closure không capture một "ảnh chụp value"; nó giữ reference cần thiết tới binding trong lexical environment.
- Với
var itrong loop, ta có một shared bindingidùng xuyên suốt các iterations. - Mỗi callback được tạo đều cần resolve identifier
i, nên cả ba cùng trỏ tới binding đó. - Loop update cùng binding từ
0lên1,2, rồi3. Khi callbacks được gọi sau loop, chúng đều đọc current value là3. - Với
for (let i = ...), JavaScript cung cấp per-iteration bindings. Callback của iteration0dùng binding khác callback của iteration1. - IIFE và factory cũng sửa được vì mỗi function call tạo parameter/local binding riêng cho value của iteration hiện tại.
💡 Hình dung
varcase là ba người cùng cầm chìa khóa một tủ số;letper-iteration case là mỗi người có một tủ riêng.
Gợi ý đánh giá bản thân
- Bạn có vẽ được một binding shared cho
varcase không? - Bạn có vẽ được ba iteration bindings cho
letcase không? - Bạn có giải thích được case
let ikhai báo ngoài loop không? - Bạn có sửa bug bằng cả
let, IIFE và factory mà vẫn giải thích được cùng một mental model không?
15. Assessment (Đánh giá)
| Objective (Mục tiêu) | Hình thức đánh giá |
|---|---|
| Giải thích shared binding trong loop | Teach Back — đúng binding model |
| Phân biệt shared vs per-iteration binding | Prediction 1–3 |
| Dự đoán Closure trong loop | Prediction — 3/3 đúng |
| Sửa Closure loop bug | Lab 1–3 — let, IIFE, factory |
Debug 3 3 3 | Debug Lab — xác định đúng root cause |
| Transfer sang event listener | Production Scenario |
Nhận biết giới hạn của rule "let fixes" | Edge Case 1 + AI Challenge |
16. Exit Criteria (Tiêu chí qua bài)
- [ ] Có thể giải thích tại sao
varloop tạo nhiều closures cùng đọc một binding. - [ ] Có thể vẽ shared-binding diagram cho
varcase. - [ ] Có thể vẽ per-iteration binding diagram cho
for (let i = ...). - [ ] Dự đoán đúng 3/3 prediction scenarios.
- [ ] Sửa được bug bằng
let. - [ ] Sửa được bug bằng IIFE / explicit capture.
- [ ] Sửa được bug bằng factory function.
- [ ] Có thể giải thích vì sao timer không phải root cause của bug.
- [ ] Có thể giải thích case
letkhai báo ngoài loop vẫn là shared binding. - [ ] Debug được event listener loop bug bằng environment model thay vì trial-and-error.
17. Spiral Connection (Liên kết xoắn ốc)
Previous (Trước): Closure Formation → Closure Lifetime → Private State → Factory Functions. Bạn đã biết function giữ environment nào và mỗi invocation có thể tạo bindings riêng.
Current (Hiện tại): Closure trong Loop. Bạn dùng binding identity để giải thích tại sao nhiều callbacks có thể cùng giữ một binding hoặc giữ các iteration bindings khác nhau.
Next (Tiếp theo):
- Lesson 1.4.6 — Closure + Callback (callback được truyền sang nơi khác nhưng vẫn giữ lexical environment)
- Lesson 1.4.7 — Closure + Async (callback chạy sau nhưng vẫn giữ captured bindings)
- Lesson 1.4.8 — Stale Closure (captured environment không đồng nghĩa latest state trong system)
- Stage 3 — Event Loop & Async Concurrency
- Stage 8 — React Hooks và stale closures