Scope และ Closures
var, let และ const
หัวข้อที่มีชื่อว่า “var, let และ const”JavaScript มี keyword การประกาศสามแบบ แต่ละแบบมีกฎ scoping ที่แตกต่างกัน:
| Keyword | Scope | Hoisted | Reassignable | Re-declarable |
|---|---|---|---|---|
var | Function | ใช่ (เป็น undefined) | ใช่ | ใช่ |
let | Block | ใช่ (TDZ) | ใช่ | ไม่ |
const | Block | ใช่ (TDZ) | ไม่ | ไม่ |
Block scope หมายความว่าตัวแปรมีอยู่เฉพาะระหว่างเครื่องหมาย {} ที่ใกล้ที่สุด Function scope หมายความว่าตัวแปรมีอยู่ตลอด function body ไม่ว่า block ด้านในจะเป็นอย่างไร
function example() { if (true) { var x = 1; // function-scoped — leaks outside the if let y = 2; // block-scoped — stays inside the if const z = 3; // block-scoped } console.log(x); // 1 — visible here console.log(y); // ReferenceError: y is not defined}Hoisting
หัวข้อที่มีชื่อว่า “Hoisting”การประกาศตัวแปรถูก hoist — เลื่อนไปที่ด้านบนของ scope โดย engine ก่อนการ execution
การ hoist ของ var จะ initialise ตัวแปรเป็น undefined ทันที ส่วน let/const ถูก hoist แต่อยู่ใน Temporal Dead Zone (TDZ) — การเข้าถึงก่อนบรรทัดที่ประกาศจะ throw ReferenceError
// @expect-throwconsole.log(a); // undefined — var is hoisted and initialisedvar a = 5;
console.log(b); // ReferenceError — let is in TDZlet b = 10;Function declarations ก็ถูก hoist อย่างสมบูรณ์ — สามารถเรียกก่อนบรรทัดที่เขียนได้ แต่ Function expressions (ที่กำหนดให้กับตัวแปร) ไม่ถูก hoist
Lexical scope
หัวข้อที่มีชื่อว่า “Lexical scope”Scope ใน JavaScript เป็น lexical (หรือ static): scope ของฟังก์ชันถูกกำหนดโดยตำแหน่งที่เขียนใน source code ไม่ใช่ตำแหน่งที่เรียก ฟังก์ชันด้านในสามารถอ่านตัวแปรจาก scope รอบนอกได้เสมอ
const prefix = 'LOG';
function outer() { const level = 'INFO';
function inner(msg) { // inner can read both prefix (module scope) and level (outer scope) console.log('[' + prefix + ':' + level + '] ' + msg); }
inner('started');}
outer(); // [LOG:INFO] startedClosures
หัวข้อที่มีชื่อว่า “Closures”Closure คือฟังก์ชันที่เก็บการเข้าถึงตัวแปรของ scope รอบนอกไว้แม้ว่าฟังก์ชันนั้นจะ return ไปแล้ว ฟังก์ชันด้านในไม่ได้คัดลอกค่า — แต่ถือ reference ที่มีชีวิตอยู่ไปยังตัวแปรนั้น
use case คลาสสิกคือ counter factory:
function makeCounter(start) { let count = start;
return { increment: function() { count += 1; }, decrement: function() { count -= 1; }, value: function() { return count; }, };}
const counter = makeCounter(0);counter.increment();counter.increment();counter.increment();counter.decrement();console.log(counter.value()); // 2count อาศัยอยู่ใน scope ของ makeCounter ทุกฟังก์ชันที่ return จาก makeCounter ถือ reference ไปยัง count ตัวเดียวกัน
Demo Closure ที่รันได้
หัวข้อที่มีชื่อว่า “Demo Closure ที่รันได้”สังเกตว่า c1 และ c2 เป็นอิสระจากกันอย่างสมบูรณ์ — การเรียก makeCounter แต่ละครั้งสร้างตัวแปร count ใหม่ใน scope ใหม่ และแต่ละ object ที่ return กลับมาจะ close over ตัวแปรของตัวเอง
Bug loop closure ของ var
หัวข้อที่มีชื่อว่า “Bug loop closure ของ var”นี่คือ closure bug ที่ถูกทำซ้ำมากที่สุด: var เป็น function-scoped ดังนั้น loop for ที่ใช้ var จะมี i แค่ ตัวเดียว ที่แชร์กันทุก iteration พอถึงเวลาที่ callback ที่ถูก defer ไว้ทำงานจริง loop ก็จบไปแล้วและ i ถือค่าสุดท้ายอยู่ — callback ทุกตัวเห็นตัวเลขผิดตัวเดียวกัน let แก้ปัญหานี้เพราะ loop จะสร้าง binding ของ i ใหม่ทุก iteration และแต่ละ callback close over copy ของตัวเอง
flowchart TD
subgraph var loop - one binding
V0["i (shared)"] --> V1["callback 0 reads i"]
V0 --> V2["callback 1 reads i"]
V0 --> V3["callback 2 reads i"]
V4["loop finishes: i = 3"] --> V0
end
subgraph let loop - one binding per iteration
L0["i = 0"] --> L1["callback 0 reads i = 0"]
L2["i = 1"] --> L3["callback 1 reads i = 1"]
L4["i = 2"] --> L5["callback 2 reads i = 2"]
end bug เดียวกันนี้กัดแรงที่สุดกับ callback แบบ async จริง (setTimeout, promise .then(), event listener) เพราะพอถึงเวลาที่ callback ตัวไหนก็ตาม ทำงาน loop จบไปแล้วเสมอ — ไม่มี var loop ตัวไหน “เร็วพอ” ที่จะหลีกเลี่ยง bug นี้ได้
การ dispose อัตโนมัติ: using และ Symbol.dispose
หัวข้อที่มีชื่อว่า “การ dispose อัตโนมัติ: using และ Symbol.dispose”Node 24 เพิ่ม using (และ await using) declaration: resource ที่มี method [Symbol.dispose]() (หรือ [Symbol.asyncDispose]()) จะถูก dispose อัตโนมัติทันทีที่ block ที่ครอบมันจบลง — ทั้งตอน return ปกติ และ ตอนที่มี error ถูก throw — โดยไม่ต้องมี try/finally ด้วยมือ นี่คือ block scope ที่ถูกนำมาใช้ทำ resource cleanup: using binding มี scope เหมือน let ทุกประการ และการ dispose เกิดขึ้นตรงจุดที่ scope ของ block นั้นจบลง
Output (verified on Node 24)
--- normal exit ---
using r1: r1
disposed: r1
after block
--- exit via throw ---
disposed: r2
caught: boomก่อนมี using การรับประกันแบบเดียวกันนี้ต้องใช้ try/finally ด้วยมือรอบ resource ทุกตัว — ลืมง่าย และผิดพลาดง่ายเมื่อฟังก์ชันหนึ่งมี resource มากกว่าหนึ่งตัวที่ต้อง release
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”{ } block ทุกอันที่ประกาศ let, const หรือ using คือ scope ใหม่ — ให้นึกภาพ loop ที่ใช้ let เป็นการสร้าง stack frame ใหม่ทุก iteration แต่ละอันมี i ของตัวเอง แทนที่จะเป็นกล่องเดียวที่ทุก iteration เขียนทับกัน var ไม่ได้รับการปฏิบัติแบบนี้เลย — มันเขียนลงใน binding เดียวที่เป็นของฟังก์ชันที่ครอบใกล้ที่สุดเสมอ (หรือ module ถ้าไม่มีฟังก์ชัน) using ใช้แนวคิด “block สร้าง lifetime ของตัวเอง” แบบเดียวกัน แต่ผูกการ dispose — ไม่ใช่แค่ lifetime ของตัวแปร — เข้ากับตอนที่ scope ของ block จบลง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
var | คุ้นเคยสำหรับคนที่มาจาก JS รุ่นเก่า; hoist ไปบนสุดของฟังก์ชัน | Function-scoped, re-declare ได้ และแชร์ binding เดียวตลอดทุก loop iteration — ต้นเหตุของ closure bug คลาสสิก |
let/const ใน loops | binding ที่ถูกต้องและเป็นอิสระต่อ iteration; TDZ จับ use-before-declare ได้ | allocation เยอะกว่า var ตัวเดียวเล็กน้อย (ไม่มีผลจริงในทางปฏิบัติ) |
Manual try/finally cleanup | ใช้ได้กับ JS ทุกเวอร์ชัน ไม่มี syntax ใหม่ | ยืดยาวเมื่อมีหลาย resource; ลืมง่ายใน code path หนึ่ง |
using/await using | รับประกันการ dispose ทุก exit path (return, throw, break) โดยไม่มี boilerplate | รองรับแค่ Node 24+ / browser ล่าสุดมากๆ; resource ต้อง implement Symbol.dispose/Symbol.asyncDispose |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Closure copy ค่าของตัวแปรที่มัน capture” มัน capture live reference ต่างหาก ถ้าตัวแปรเปลี่ยนทีหลัง closure ทุกตัวที่อ้างถึงมันจะเห็นค่าใหม่ — นี่คือสาเหตุที่แท้จริงของ bug loop ของ
var - “ใช้
letแทนvarเป็นแค่เรื่อง style” ใน loop ที่มี callback แบบ defer มันคือการแก้ correctness ไม่ใช่แค่ style —varทำให้เกิด bug จริงที่ reproduce ได้เสมอ - “
usingเป็นแค่try/finallyที่หรูขึ้น” มันมากกว่า syntax sugar: มันให้แต่ละ resource ประกาศ cleanup ของตัวเองตรงจุดที่มันถูกสร้าง ทำให้ cleanup ของหลาย resource อ่านจากบนลงล่างแทนที่จะซ้อนfinallyและมันรัน dispose ตามลำดับย้อนกลับของการประกาศให้อัตโนมัติ
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โค้ดที่ AI สร้างซึ่ง push callback (setTimeout, callback ของ array method ที่เก็บไว้ใช้ทีหลัง, event listener ที่ลงทะเบียนเป็นชุด) ไว้ใน loop for ที่ประกาศด้วย var เป็นหนึ่งใน bug ที่พบบ่อยและ review ยากที่สุด เพราะ loop รันถูกต้องและ bug จะโผล่ก็ต่อเมื่อ callback ที่ defer ไว้ทำงานจริงเท่านั้น — มักอยู่ห่างจาก loop มากในไฟล์หรือใน stack trace
🐛 หา bug ในโค้ดนี้
function scheduleGreetings(names) {for (var i = 0; i < names.length; i++) { setTimeout(function() { console.log('Hello, ' + names[i]); }, 100);}}
scheduleGreetings(['Alice', 'Bob', 'Carol']);เฉลย
var i เป็น function-scoped ดังนั้นมี i แค่ตัวเดียวที่แชร์กันทั้งสาม setTimeout callback พอถึงเวลาที่ timer ตัวแรกทำงาน (100 ms ต่อมา) loop จบไปแล้วและ i เป็น 3 — callback ทุกตัวอ่าน names[3] ซึ่งเป็น undefined พิมพ์ “Hello, undefined” สามครั้งแทนที่จะทักทายแต่ละชื่อครั้งเดียว วิธีแก้คือเปลี่ยนแค่ตัวอักษรเดียว: for (let i = ...) let สร้าง i binding ใหม่และเป็นอิสระให้แต่ละ iteration ดังนั้นแต่ละ callback close over ค่าของตัวเอง