Lesson 1.4.7 — Closure + Async
0. Metadata (Thông tin bài học)
| Field | Value |
|---|---|
| Stage | 1 |
| Module | 1.4 — Closures |
| Lesson | 1.4.7 — Closure + Async |
| Competency | C02 — JavaScript Runtime (C02.5 Closure) |
| Depth Target | L4 (Debug) |
| Prerequisites | Closure Formation (1.4.1), Closure Lifetime (1.4.2), Closure + Callback (1.4.6), Variable Resolution (1.2.6), Call Stack (1.5) |
| Estimated Cognitive Load | High |
Out of Scope (Ngoài phạm vi)
- Event Loop algorithm, task queue và microtask queue (Stage 3)
- Promise /
async/awaitsemantics (Stage 3) - Timer timing guarantees và browser scheduling details (Stage 3/4)
- Race condition, cancellation, retry và concurrency control (Stage 3)
- React stale closure (Stage 8)
- Garbage Collection internals và memory profiling (Stage 11)
1. Why This Exists (Vì sao cần học)
Ở Lesson 1.4.6, bạn đã thấy callback có thể được truyền sang nơi khác nhưng vẫn giữ lexical environment nơi nó được tạo.
Bây giờ thêm một yếu tố mới:
Callback không chạy ngay.
Ví dụ:
function search(query) {
setTimeout(() => {
console.log(query);
}, 1000);
}
search("javascript");search("javascript") kết thúc trước khi callback chạy. Nhưng sau đó callback vẫn đọc được query.
Câu hỏi trung tâm của lesson này là:
Tại sao một callback chạy sau vẫn truy cập được binding của function đã return?
Nếu chỉ nghĩ:
"Function return thì local variable biến mất."
thì async callback sẽ trông như một phép màu.
Mental model đúng phải nối được:
function call
→ environment created
→ callback created
→ callback captures binding
→ outer function returns
→ callback remains reachable
→ callback runs later
→ captured binding still resolvesNếu không hiểu chuỗi này, bạn sẽ khó:
- Trace async callback giữ state nào.
- Debug callback dùng shared mutable state ngoài dự kiến.
- Phân biệt dữ liệu tại call time với captured binding.
- Hiểu vì sao request/timer handler vẫn dùng parameter cũ.
- Chuẩn bị cho stale closure ở lesson tiếp theo.
- Học Event Loop ở Stage 3 mà không bị lẫn giữa scheduling và Closure.
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 outer function return nhưng environment vẫn có thể reachable.
- [ ] Phân biệt creation site và call site của callback.
- [ ] Trace được callback capture parameter/local binding nào.
- [ ] Biết Function Execution Context rời Call Stack khi function kết thúc.
- [ ] Hiểu rằng
setTimeoutnhận một callback để host gọi lại sau; chưa cần biết queue internals.
Nếu thiếu, quay lại Lesson 1.4.1, 1.4.2 và 1.4.6 trước.
3. Learning Objectives (Mục tiêu học tập)
Sau bài này, bạn có thể:
- Trace lifecycle của một async callback từ lúc outer function được gọi đến lúc callback chạy.
- Giải thích tại sao callback vẫn resolve được captured bindings sau khi outer function return.
- Phân biệt Closure mechanism với async scheduling mechanism.
- Dự đoán output của các callback chạy sau với captured bindings.
- Debug một async callback dùng nhầm shared outer state.
- Implement delayed callback giữ parameter/local state đúng ownership.
- Chuẩn bị mental model cho Stale Closure ở Lesson 1.4.8.
4. Mental Model (Mô hình tư duy)
Mental Model — Closure sống qua thời gian
Async không tạo ra Closure.
Closure đã hình thành khi callback function được tạo.
Async chỉ làm cho callback được gọi ở một thời điểm sau.
Outer Function Called
↓
Outer Environment Created
↓
Async Callback Created
↓
Callback keeps needed outer bindings
↓
Callback registered / handed to host
↓
Outer Function Returns
↓
Outer Execution Context leaves stack
↓
Callback still reachable
↓
Callback Called Later
↓
Captured bindings are resolvedVí dụ:
function search(query) {
setTimeout(() => {
console.log(query);
}, 1000);
}Mental model:
search("javascript")
↓
Environment
└── query = "javascript"
↑
└── callback
↓
registered
↓
search() returns
↓
callback still reachable
↓
callback runs later
↓
reads query = "javascript"Điểm quan trọng:
Closure trả lời "callback vẫn truy cập được dữ liệu nào?"
Còn:
Async scheduling trả lời "callback được gọi khi nào?"
Đây là hai vấn đề khác nhau.
Common Misconception
Không nên nói:
"
setTimeoutgiữ local variable sống."
Mental model tốt hơn:
- Callback được tạo trong lexical environment của
search. - Callback cần binding
query. - Callback còn được hệ thống giữ để gọi sau.
- Vì callback còn reachable và cần outer binding, environment cần thiết vẫn reachable.
setTimeout chỉ là nơi nhận callback; Closure semantics không phụ thuộc riêng vào timer API.
5. Core Concepts (Các khái niệm cốt lõi)
Essential (Bắt buộc)
- Async Callback: Callback được gọi ở thời điểm sau bởi một host/API/scheduler.
- Captured Parameter: Parameter của outer function được callback dùng qua Closure.
- Delayed Execution: Callback chạy sau khi creator function có thể đã return.
- Retention through Callback: Callback còn reachable nên bindings cần thiết vẫn có thể reachable.
- Closure vs Scheduling: Closure quyết định lexical data access; scheduling quyết định thời điểm callback được gọi.
Supporting (Hỗ trợ)
- Timer callback là ví dụ đơn giản để quan sát delayed execution.
- Callback có thể capture primitive binding hoặc object reference.
- Nhiều async callbacks có thể giữ bindings của các creator invocations khác nhau.
- Shared outer mutable binding có thể làm nhiều callbacks ảnh hưởng nhau.
Awareness (Biết tồn tại)
- Timer không bảo đảm callback chạy chính xác sau đúng số millisecond yêu cầu.
- Promise callbacks có scheduling semantics khác timer callbacks.
- Race condition và stale closure sẽ xuất hiện khi state thay đổi theo thời gian.
- Các vấn đề đó được học sâu hơn ở Stage 3 và Lesson 1.4.8.
6. Worked Example (Ví dụ phân tích từng bước)
Worked Example — Trace callback sau khi outer return:
function search(query) {
const normalized = query.trim().toLowerCase();
setTimeout(() => {
console.log("Searching:", normalized);
}, 1000);
return "scheduled";
}
const result = search(" JavaScript ");
console.log(result);Step 1 — search(" JavaScript ") được gọi:
Tạo Function Execution Context và environment:
query → " JavaScript "
normalized → "javascript"Step 2 — Callback được tạo:
() => {
console.log("Searching:", normalized);
};Callback cần resolve binding:
normalizednên giữ reference cần thiết đến outer environment.
Step 3 — Callback được đăng ký:
Callback được truyền cho setTimeout.
Ở mức lesson này, chỉ cần hiểu:
Có một external/host mechanism giữ callback để gọi sau.
Chưa cần trace task queue hay Event Loop.
Step 4 — search() return:
return "scheduled";Execution Context của search rời Call Stack.
Nhưng callback vẫn còn reachable qua timer registration và vẫn cần normalized.
Step 5 — Code ngoài tiếp tục:
console.log(result);in:
scheduledStep 6 — Callback được gọi sau:
Khi host gọi callback, callback resolve:
normalized → "javascript"Output:
Searching: javascriptStep 7 — Kết luận:
outer execution ended
≠
captured environment immediately goneAsync chỉ kéo thời điểm callback execution ra xa creator function. Closure giúp callback vẫn có lexical access tới dữ liệu cần thiết.
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 callback creation + captured binding.
Prediction 1
function schedule(message) {
setTimeout(() => {
console.log(message);
}, 0);
}
schedule("A");Callback sẽ in gì?
Prediction 2
function schedule(value) {
value = value + 1;
setTimeout(() => {
console.log(value);
}, 0);
}
schedule(10);Callback sẽ in gì?
Prediction 3
let value = 1;
function schedule() {
setTimeout(() => {
console.log(value);
}, 0);
}
schedule();
value = 2;Callback có thể đọc binding nào?
[Đáp án & Giải thích]
Prediction 1:
- Callback in
"A". - Callback được tạo trong invocation
schedule("A")và capture parameter bindingmessage.
- Callback in
Prediction 2:
- Callback in
11. - Parameter binding
valuecủa invocation được update thành11trước khi callback chạy. Callback đọc current value của cùng binding đó.
- Callback in
Prediction 3:
- Callback capture global binding
value, không phải một copy cố định của số1. - Khi callback chạy sau khi
value = 2, callback có thể đọc value hiện tại2của cùng binding. - Đây là cầu nối sang Stale Closure, nhưng lesson này chỉ yêu cầu nhận ra binding được capture, chưa phân tích stale-state patterns sâu hơn.
- Callback capture global binding
Transfer Check — Hai invocation độc lập:
function schedule(label) {
setTimeout(() => {
console.log(label);
}, 0);
}
schedule("A");
schedule("B");Hãy trả lời:
- Có bao nhiêu invocation của
schedule? - Có bao nhiêu parameter binding
label? - Callback A giữ binding nào?
- Callback B giữ binding nào?
- Hai callbacks có bắt buộc cùng in một value không?
Gợi ý trả lời
- Có hai invocations.
- Có hai parameter bindings
label. - Callback đầu giữ
labelcủa invocation đầu, có value"A". - Callback sau giữ
labelcủa invocation sau, có value"B". - Không. Mỗi callback giữ environment của creator invocation tương ứng.
8. Implementation Lab (Bài lab thực hành)
Lab 1 — Guided: Delayed Greeter
Implement:
function greetLater(name) {
// TODO
}
greetLater("Alice");Expected callback behavior:
Hello, AliceYêu cầu:
namephải đến từ parameter binding củagreetLater.- Callback phải dùng Closure.
- Không copy
namesang global variable.
[Đáp án tham khảo]
function greetLater(name) {
setTimeout(() => {
console.log(`Hello, ${name}`);
}, 0);
}- Giải thích: Callback capture parameter
name.greetLaterreturn trước khi callback chạy, nhưng callback vẫn có thể resolve binding đó.
Lab 2 — Partial Scaffold: Capture normalized value
Hoàn thiện:
function logSearchLater(query) {
const normalized = query.trim().toLowerCase();
setTimeout(() => {
// TODO
}, 0);
}
logSearchLater(" React ");Expected:
reactSau đó trả lời:
- Callback capture
query,normalized, hay cả hai? - Identifier nào callback thực sự dùng?
- Engine có bắt buộc giữ mọi local binding của outer function không?
[Đáp án tham khảo]
function logSearchLater(query) {
const normalized = query.trim().toLowerCase();
setTimeout(() => {
console.log(normalized);
}, 0);
}- Callback trực tiếp dùng
normalized. - Ở mental model level, ta chỉ cần nói callback cần outer binding
normalized. - Không nên kết luận engine phải giữ toàn bộ mọi local binding nếu callback không cần chúng; optimization detail nằm ngoài scope.
Lab 3 — Independent: Per-request callback state
Implement:
function scheduleRequest(requestId, payload) {
// TODO
}
scheduleRequest("req-1", { type: "login" });
scheduleRequest("req-2", { type: "logout" });Expected callback output tương ứng:
req-1: login
req-2: logoutConstraints:
- Không dùng shared global
currentRequest. - Mỗi invocation phải giữ request data riêng.
- Callback phải đọc
requestIdvàpayloadtừ creator invocation. - Không dùng Promise hoặc
async/await.
[Đáp án tham khảo]
function scheduleRequest(requestId, payload) {
setTimeout(() => {
console.log(`${requestId}: ${payload.type}`);
}, 0);
}- Giải thích: Mỗi invocation tạo parameter bindings riêng. Callback của
req-1và callback củareq-2giữ hai environments khác nhau.
9. Edge Cases (Các trường hợp ngoại lệ)
Edge Case 1 — Capture binding, không snapshot primitive
function schedule() {
let value = 1;
setTimeout(() => {
console.log(value);
}, 0);
value = 2;
}
schedule();Phân tích
Callback capture binding value.
Outer function đổi chính binding đó từ 1 sang 2 trước khi kết thúc.
Khi callback chạy sau, nó đọc current value của binding và in 2.
Không nên nói callback "nhớ value lúc đăng ký".
Edge Case 2 — Capture object reference
function schedule(config) {
setTimeout(() => {
console.log(config.mode);
}, 0);
config.mode = "production";
}
const config = {
mode: "development",
};
schedule(config);Phân tích
Callback capture parameter binding config, binding này trỏ tới một object.
Sau đó object bị mutate.
Khi callback chạy, nó đọc property hiện tại của cùng object reference và có thể in "production".
Closure không deep-copy object state.
Edge Case 3 — Hai async callbacks dùng shared outer binding
let currentId = null;
function schedule(id) {
currentId = id;
setTimeout(() => {
console.log(currentId);
}, 0);
}
schedule("A");
schedule("B");Phân tích
Hai callbacks không capture parameter id; chúng cùng dùng shared global binding currentId.
Call thứ hai đổi currentId thành "B".
Khi callbacks chạy sau, cả hai có thể đọc cùng current value "B".
Đây là ownership bug, không phải timer bug.
10. Debug Lab (Bài lab gỡ lỗi)
Debug Lab — Hai async callbacks cùng đọc request cuối
Symptom (Triệu chứng): Developer mong đợi:
A
Bnhưng cả hai callbacks đều log:
B
BReproduction (Tái hiện lỗi):
let currentRequestId;
function scheduleRequest(requestId) {
currentRequestId = requestId;
setTimeout(() => {
console.log(currentRequestId);
}, 0);
}
scheduleRequest("A");
scheduleRequest("B");Evidence (Bằng chứng):
- Có hai invocations của
scheduleRequest. - Mỗi invocation có parameter binding
requestIdriêng. - Nhưng callback không dùng
requestId. - Callback dùng global/shared binding
currentRequestId. - Invocation thứ hai reassign shared binding thành
"B"trước khi callbacks chạy.
Hypothesis (Giả thuyết):
Cả hai callbacks cùng resolve một global binding currentRequestId, nên đều đọc value cuối cùng.
Verification (Xác minh):
Đổi callback thành:
setTimeout(() => {
console.log(requestId);
}, 0);Nếu output trở thành:
A
Bthì giả thuyết được xác nhận.
Root Cause (Nguyên nhân gốc rễ):
Data ownership sai:
request-specific data
should belong to
each creator invocation
but code stores it in
one shared global bindingFix (Sửa):
Capture parameter binding của từng invocation:
function scheduleRequest(requestId) {
setTimeout(() => {
console.log(requestId);
}, 0);
}Prevention (Phòng ngừa):
- Khi async callback cần per-request data, ưu tiên creator parameter/local binding tương ứng.
- Tránh shared mutable binding nếu ownership thuộc từng async operation.
- Khi nhiều async callbacks trả cùng value ngoài dự kiến, trace binding identity trước.
- Tách hai câu hỏi:
- Callback chạy khi nào?
- Callback đọc binding nào?
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 đó, làm một state ownership decision.
Bạn cần schedule nhiều jobs:
scheduleJob("job-A");
scheduleJob("job-B");Requirement:
Mỗi callback phải log đúng job id của chính operation.
Hai options:
Option A — Shared state
let currentJobId;
function scheduleJob(jobId) {
currentJobId = jobId;
setTimeout(() => {
console.log(currentJobId);
}, 0);
}Option B — Per-invocation state
function scheduleJob(jobId) {
setTimeout(() => {
console.log(jobId);
}, 0);
}Decision đúng là Option B.
Engineering Decision
Khi async callback cần data, hãy hỏi:
Data belongs to:
this operation?
all operations?
latest operation?
global system?Binding placement phải phản ánh ownership đó.
12. Production Scenario (Tình huống thực tế)
Production Scenario — Delayed Audit Log
Một hệ thống muốn ghi audit sau khi request handler đã hoàn tất setup:
function handleRequest(requestId, userId) {
const message = `${requestId}:${userId}`;
setTimeout(() => {
console.log("AUDIT", message);
}, 0);
}Context: Mỗi request có requestId và userId riêng.
Constraint: Audit callback chạy sau khi handleRequest đã return.
Reasoning:
- Mỗi call
handleRequesttạo environment riêng. messagelà local binding của từng invocation.- Callback được tạo trong invocation đó.
- Callback capture
message. - Outer execution kết thúc.
- Callback vẫn reachable qua host scheduling mechanism.
- Khi callback chạy, nó resolve đúng
messagecủa creator invocation.
Mental model:
Request A
└── message_A
↑
└── callback_A
Request B
└── message_B
↑
└── callback_BProduction lesson:
Async code thường giữ context qua thời gian bằng Closure.
Nhưng nếu context ownership bị đặt vào shared mutable state, nhiều async operations có thể ảnh hưởng nhau. Stage 3 sẽ mở rộng vấn đề này thành race condition và concurrency.
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 flow từ
search(query)tới lúc timer callback dùng lạiquery. - Hỏi AI: "Why can a JavaScript setTimeout callback access a function parameter after the outer function has returned? Explain closure separately from event-loop scheduling."
- Inspect: AI có trộn "Closure" với "Event Loop" thành một cơ chế duy nhất không?
- Challenge: Đưa code shared-state bug:
let currentId;
function schedule(id) {
currentId = id;
setTimeout(() => console.log(currentId), 0);
}và hỏi binding nào callback thực sự capture.
- Verify: Tự trace creation site, binding ownership và callback lifetime.
Gợi ý
Câu trả lời đủ depth phải tách:
Closure:
what data remains accessible
Async scheduling:
when callback runsNếu AI nói "Event Loop stores variables for callback", explanation đó không đạt yêu cầu.
Đáp án tham khảo
- Callback được tạo trong lexical environment của outer function.
- Callback sử dụng parameter/local binding nên giữ khả năng resolve binding đó.
- Callback được truyền cho host API và còn reachable sau khi outer function return.
- Execution Context của outer function rời Call Stack, nhưng environment cần thiết vẫn có thể reachable.
- Host gọi callback sau theo scheduling mechanism riêng.
- Khi callback chạy, Variable Resolution vẫn theo lexical environment chain cũ.
- Event Loop không phải cơ chế tạo Closure.
14. Teach Back (Dạy lại)
Giải thích cho một đồng nghiệp junior trong 2 phút:
"Function
search(query)đã return rồi. Tại sao callback trongsetTimeoutvẫn dùng đượcquerykhi chạy sau?"
Yêu cầu:
- Dùng đúng terminology: lexical environment, closure, callback, reference, Execution Context.
- Tách Closure khỏi async scheduling.
- Không nói timer "copy biến".
- Giải thích được shared-state counterexample.
Mô phỏng
- Bạn nói:
- Khi
search(query)chạy, một invocation và lexical environment được tạo, trong đó có parameter bindingquery. - Callback của
setTimeoutđược tạo trong environment đó và dùng identifierquery, nên Closure giữ khả năng resolve binding này. - Callback được truyền cho host API để gọi sau. Vì callback vẫn được giữ reference, environment cần thiết vẫn có thể reachable.
- Sau đó
searchreturn và Execution Context của nó rời Call Stack. Điều đó không đồng nghĩa captured environment lập tức biến mất. - Khi callback được gọi sau, nó vẫn đi theo lexical environment chain nơi nó được tạo và tìm thấy
query. - Async scheduling chỉ quyết định callback chạy sau; Closure quyết định callback vẫn truy cập được data nào.
- Nếu callback dùng một global
currentQuerythay vì parameterquery, nhiều async callbacks có thể cùng đọc shared latest value.
- Khi
💡 Hình dung callback là một lá thư được gửi đi sau nhưng vẫn mang địa chỉ quay về đúng environment nơi nó được tạo.
Gợi ý đánh giá bản thân
- Bạn có trace được call → create callback → register → return → callback run không?
- Bạn có phân biệt outer Execution Context với retained environment không?
- Bạn có chỉ ra callback capture parameter hay shared global binding không?
- Bạn có giải thích được vì sao timer không phải nguyên nhân tạo Closure không?
15. Assessment (Đánh giá)
| Objective (Mục tiêu) | Hình thức đánh giá |
|---|---|
| Trace async callback lifecycle | Worked Example + Teach Back |
| Giải thích Closure sau outer return | Teach Back |
| Phân biệt Closure vs scheduling | Mental Model + AI Exercise |
| Dự đoán captured binding behavior | Prediction — 3/3 đúng |
| Implement per-operation async state | Lab 1–3 |
| Debug shared async state | Debug Lab — xác định đúng binding ownership |
| Chuẩn bị cho stale closure | Prediction 3 + Edge Case 1 |
16. Exit Criteria (Tiêu chí qua bài)
- [ ] Có thể trace
call → create callback → capture → register → outer return → callback run. - [ ] Có thể giải thích tại sao callback vẫn dùng được parameter sau outer return.
- [ ] Có thể phân biệt Closure mechanism với async scheduling mechanism.
- [ ] Dự đoán đúng 3/3 prediction scenarios.
- [ ] Có thể chỉ ra callback capture parameter/local binding hay shared outer binding.
- [ ] Implement được async callback giữ per-operation data riêng.
- [ ] Debug được case hai callbacks cùng đọc shared state cuối cùng.
- [ ] Không nói
setTimeout"copy variables" hoặc "tạo Closure". - [ ] Không cần dùng Event Loop internals để giải thích lexical access.
- [ ] Sẵn sàng phân tích captured binding thay đổi theo thời gian ở Lesson 1.4.8.
17. Spiral Connection (Liên kết xoắn ốc)
Previous (Trước): Closure Formation → Lifetime → Private State → Factory Functions → Closure trong Loop → Closure + Callback. Bạn đã biết callback giữ lexical environment nơi nó được tạo.
Current (Hiện tại): Closure + Async. Bạn thêm yếu tố thời gian: outer function có thể return trước, nhưng callback chạy sau vẫn resolve captured bindings.
Next (Tiếp theo):
- Lesson 1.4.8 — Stale Closure (captured state và "latest state" không phải lúc nào cũng giống nhau)
- Stage 3 — Event Loop, Promise,
async/await, concurrency và race condition- Stage 4 — Browser scheduling / Web APIs
- Stage 8 — React Hooks và stale closure
- Stage 11 — Memory retention và debugging