Skip to content

Lesson 1.4.5 — Closure trong Loop ​

Bài 1.4.5 — Closure in Loop
Loop Bindings, Captured Variables & Common Closure Bugs • 27 phút
0:00 / 0:00

0. Metadata (Thông tin bài học) ​

FieldValue
Stage1
Module1.4 — Closures
Lesson1.4.5 — Closure trong Loop
CompetencyC02 — JavaScript Runtime (C02.5 Closure)
Depth TargetL4 (Debug)
PrerequisitesClosure 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 LoadHigh

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:

js
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

Nhiều người mới dự đoán:

text
0
1
2

nhưng kết quả lại là:

text
3
3
3

Cách giải thích yếu thường là:

"var bị 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 var function scope với let block 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ể:

  1. Giải thích tại sao loop dùng var có thể tạo nhiều closures cùng tham chiếu một binding.
  2. Dự đoán output của các loop closure scenarios cơ bản.
  3. Phân biệt shared binding với per-iteration binding.
  4. Debug bug 3 3 3 bằng environment model.
  5. Sửa bug bằng let, IIFE/explicit capture hoặc factory function.
  6. Chọn cách sửa phù hợp với mục tiêu code thay vì thay syntax theo mẹo.
  7. 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:

js
for (var i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

Mental model:

text
Function / Global Environment
└── i
    ↑
    ├── callback 1
    ├── callback 2
    └── callback 3

Ba callbacks khác nhau, nhưng cùng resolve một binding i.

Sau loop:

text
i = 3

nên cả ba callbacks đều đọc 3.

Với:

js
for (let i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

mental model cần dùng ở lesson này:

text
Iteration 1 Environment
└── i = 0
    ↑
    └── callback 1

Iteration 2 Environment
└── i = 1
    ↑
    └── callback 2

Iteration 3 Environment
└── i = 2
    ↑
    └── callback 3

Trong 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:

"let copy giá trị i và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ợ) ​

  • var không tạo block-scoped binding cho mỗi iteration.
  • let có lexical scoping và trong for loop 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) ​

  • setTimeout chỉ 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:

js
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:

text
i

được dùng xuyên suốt loop.

Step 2 — Iteration đầu tiên:

text
i = 0

Một function được tạo:

js
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ó:

text
callback 1 ─┐
callback 2 ─┼──→ same binding i
callback 3 ─┘

Step 4 — Loop kết thúc:

Sau lần update cuối:

text
i = 3

Condition:

js
i < 3;

không còn đúng, loop dừng.

Step 5 — Callbacks được gọi:

Mỗi callback chạy:

js
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ả:

text
3
3
3

Step 6 — Kết luận:

Bug không phải vì callbacks bị "ghi sai giá trị".

Bug đến từ:

text
3 functions
+
1 shared binding
+
binding changed before functions read it

7. 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

js
const callbacks = [];

for (var i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

console.log(callbacks[0]());
console.log(callbacks[2]());

Prediction 2

js
const callbacks = [];

for (let i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

console.log(callbacks[0]());
console.log(callbacks[2]());

Prediction 3

js
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: 3 rồi 3.
    • Cả hai callbacks cùng resolve một var i. Khi loop kết thúc, binding đó có value 3.
  • Prediction 2:

    • Output: 0 rồi 2.
    • Với for (let i = ...), mỗi iteration có binding i phù hợp riêng. Callback đầu giữ iteration binding có value 0; callback cuối giữ binding có value 2.
  • Prediction 3:

    • Output: 0, 1, 2.
    • Dù i là var, const value = i nằm trong block của loop. Mỗi iteration tạo lexical binding value mới. Mỗi callback capture value của iteration tương ứng.

Transfer Check — Function Array:

js
const readers = [];

for (let index = 0; index < 3; index++) {
  readers.push(function () {
    return index * 10;
  });
}

console.log(readers[1]());

Hãy trả lời:

  1. Callback thứ hai resolve binding nào?
  2. Binding đó thuộc iteration nào?
  3. Output là gì?
  4. Có cần setTimeout thì Closure-in-loop bug/behavior mới tồn tại không?
Gợi ý trả lời
  1. Callback thứ hai resolve index của iteration thứ hai.
  2. Binding đó có value 1.
  3. Output là 10.
  4. 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:

js
const callbacks = [];

for (var i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

console.log(callbacks.map((fn) => fn()));

Expected:

js
[0, 1, 2];

Bước 0 — Trước khi sửa, hãy vẽ:

text
callback 1 ─┐
callback 2 ─┼──→ i
callback 3 ─┘

Sau đó sửa code để mỗi callback giữ iteration binding riêng.

[Đáp án tham khảo]
js
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 i trong for loop cung cấp per-iteration bindings phù hợp. Mỗi closure không còn cùng resolve một shared var 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:

js
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]
js
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 capturedI mới được tạo. Callback của invocation đó giữ binding riêng.

Lab 3 — Independent: Sửa bằng Factory Function ​

Implement:

js
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]
js
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. Parameter value là local binding riêng của invocation. Returned function giữ binding value tươ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 ​

js
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:

"let luô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 ​

js
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 ​

js
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:

text
0
1
2

nhưng nhận:

text
3
3
3

Reproduction (Tái hiện lỗi):

js
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 i có value 3.
  • Callback không có local binding i riê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:

js
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]()); // 3

Bug 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ễ):

text
var loop
→ one shared binding
→ multiple closures reference same binding
→ loop changes binding
→ callbacks later read final value

Fix (Sửa):

js
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}
js
for (var i = 0; i < 3; i++) {
  (function (capturedI) {
    setTimeout(() => {
      console.log(capturedI);
    }, 0);
  })(i);
}
js
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 "var xấu, let tố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:

js
for (var i = 0; i < items.length; i++) {
  handlers.push(() => items[i]);
}

Bạn có ba options:

text
A. Đổi var → let
B. Giữ var + IIFE / explicit capture
C. Giữ var + factory function

Hã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:

js
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 i là 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:

js
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)

  1. Tự giải thích trước: Vì sao đoạn for (var i...) tạo 3 3 3?
  2. Hỏi AI: "Explain why closures created in a JavaScript for-loop with var often read the same final value."
  3. Inspect: AI có nói "var hoisted nên thành 3" mà không nói shared binding không?
  4. Challenge: Đưa case này cho AI:
js
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.

  1. 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:

text
keyword
≠
binding placement
≠
per-iteration binding

Nế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 i trong loop, callbacks cùng resolve một shared binding i.
  • Loop mutate binding đó cho tới 3.
  • Khi callbacks đọc sau đó, chúng đều đọc current value 3.
  • let chỉ giải quyết case canonical khi declaration nằm trong for header 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ể in 3, còn for (let i = 0; ...) lại cho 0 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 "var hoisted".
  • 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 i trong loop, ta có một shared binding i dù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ừ 0 lên 1, 2, rồi 3. 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 iteration 0 dùng binding khác callback của iteration 1.
    • 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 var case là ba người cùng cầm chìa khóa một tủ số; let per-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 var case không?
  • Bạn có vẽ được ba iteration bindings cho let case không?
  • Bạn có giải thích được case let i khai 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 loopTeach Back — đúng binding model
Phân biệt shared vs per-iteration bindingPrediction 1–3
Dự đoán Closure trong loopPrediction — 3/3 đúng
Sửa Closure loop bugLab 1–3 — let, IIFE, factory
Debug 3 3 3Debug Lab — xác định đúng root cause
Transfer sang event listenerProduction 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 var loop tạo nhiều closures cùng đọc một binding.
  • [ ] Có thể vẽ shared-binding diagram cho var case.
  • [ ] 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 let khai 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
📴 Offline Mode — Content served from cache