JavaScript Closures, Scope & this — Lexical Environments, Loop Bugs, and Binding Rules
Lexical scope, closures, the var-in-loop bug, memory tradeoffs, and this binding — with an interactive demo and real frontend patterns.
Bạn từng debug component React class mà this là undefined trong callback. Bạn từng thấy vòng for lên lịch ba timeout đều log 3. Cả hai trông bí ẩn cho đến khi bạn tách biến sống ở đâu (lexical scope và closure) khỏi this trỏ tới đâu (call-site binding).
Bài này dành cho engineer viết JavaScript production hàng ngày và muốn mental model chính xác — không phải đồn đại. Chúng ta đi qua scope chain, closure như cạnh reachability, bug loop kinh điển và cách sửa, tradeoff bộ nhớ, và quy tắc this theo thứ tự ưu tiên.
Mở demo đầy đủ:
Lexical scope và scope chain
Lexical scope nghĩa là visibility biến được xác định bởi nơi code được viết, không phải nơi nó được gọi. Khi engine parse một function, nó ghi lại binding outer nào visible — cấu trúc tĩnh đó là scope chain.
const globalMsg = 'global';
function outer() {
const outerMsg = 'outer';
function inner() {
const innerMsg = 'inner';
// Resolves: innerMsg → outerMsg → globalMsg → null
console.log(innerMsg, outerMsg, globalMsg);
}
return inner;
}
Mỗi lần gọi function tạo execution context với hai phần chính:
| Part | Role |
|---|---|
| Variable environment | let/const/class bindings for this scope; also parameters and var in function scope |
| Lexical environment | The environment record plus a reference to the outer lexical environment (parent link) |
Lookup duyệt chain: inner → outer → … → global → null. Nếu không tìm thấy binding, ReferenceError được ném trên đường resolve strict.
Block vs function scope:
varlà function-scoped (hoặc global nếu khai báo top level).letvàconstlà block-scoped — mỗi cặp\{ \}có thể chứa binding riêng.
var, let, const, hoisting, và Temporal Dead Zone
Cả ba declaration đều được hoist theo nghĩa engine đăng ký chúng trước khi dòng chạy. Khác biệt nằm ở khi nào bạn được đọc/ghi.
console.log(a); // undefined — var is hoisted and initialized to undefined
var a = 1;
console.log(b); // ReferenceError — let is hoisted but in TDZ
let b = 2;
| Declaration | Scope | Hoisted? | Initial value before line | TDZ? |
|---|---|---|---|---|
var | Function / global | Yes | undefined | No |
let | Block | Yes | Uninitialized | Yes — access throws |
const | Block | Yes | Must be assigned on line | Yes — access throws |
Temporal Dead Zone (TDZ) là khoảng từ khi vào scope đến khi dòng declaration chạy. Binding tồn tại trong environment record nhưng đánh dấu chưa khởi tạo; đọc nó là bất hợp pháp. Đó là lý do typeof undeclaredVar trả "undefined" nhưng typeof letInTDZ throw nếu let letInTDZ xuất hiện sau trong cùng scope.
const bắt buộc initializer và cấm rebind identifier — giá trị vẫn có thể mutable nếu là object:
const cfg = { retries: 3 };
cfg.retries = 5; // OK — mutating the object
// cfg = {} // TypeError — rebinding forbidden
Closure — định nghĩa qua value graph
Closure là function cộng lexical environment được giữ cần để resolve free variable khi gọi sau. Theo engine, inner function là heap object có link nội bộ [[Environment]] tới binding outer.
Nghĩ theo value graph từ root: Nếu callback, timer, hoặc DOM listener giữ function sống, toàn bộ subgraph environment bị capture vẫn reachable — kể cả biến không bao giờ đọc lại.
function makeCounter() {
let count = 0; // lives in outer lexical env
return function increment() {
count += 1;
return count;
};
}
const a = makeCounter();
const b = makeCounter();
a(); // 1
a(); // 2
b(); // 1 — separate environment, separate count
Mỗi lần gọi makeCounter() allocate environment record mới với count riêng. Function trả về close over record đó — data privacy kinh điển không cần class.
Pattern closure thực tế trong frontend
Module pattern và encapsulation
Trước khi ES module phổ biến, IIFE trả API public trong khi ẩn state:
const authStore = (() => {
let token = null;
return {
setToken(t) { token = t; },
getToken() { return token; },
clear() { token = null; },
};
})();
Code hiện đại ưu tiên export / import, nhưng ý tưởng vẫn vậy: chỉ closure expose những gì bạn định.
Memoization
function memoize(fn) {
const cache = new Map();
return function memoized(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
Cache Map sống trong outer environment — mọi lần gọi memoized đọc cùng bảng.
Event handler và partial application
function onFilterChange(fieldName) {
return (event) => {
queryState[fieldName] = event.target.value;
refetch();
};
}
emailInput.addEventListener('input', onFilterChange('email'));
Mỗi listener close over fieldName khác nhau không cần biến tạm global.
Currying và once
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
const initAnalytics = once(() => loadScript('/analytics.js'));
Flag called là private — caller không reset được trừ khi tạo wrapper mới.
Bug closure var-in-loop và ba cách sửa
Pattern này làm hỏng vô số phỏng vấn và log production:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3
Vì sao: var i là một binding cho cả function/global scope. Mỗi arrow function close over binding duy nhất đó; khi callback chạy, vòng lặp đã xong và i === 3.
Fix 1 — dùng let (binding mỗi iteration)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2
let trong for head tạo binding mới mỗi iteration trên engine hiện đại. Mỗi callback capture i khác nhau.
Fix 2 — IIFE capture (trước ES2015)
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => console.log(j), 100);
})(i);
}
Tham số IIFE j là binding mới mỗi lần gọi.
Fix 3 — tham số factory
for (var i = 0; i < 3; i++) {
setTimeout(((n) => () => console.log(n))(i), 100);
}
Cùng ý: đóng băng giá trị lúc schedule.
Chạy cả hai biến thể trong demo phía trên. Thấy
3,3,3vs0,1,2cạnh nhau hiệu quả hơn học thuộc quy tắc.
Hệ quả bộ nhớ — closure là tính năng, retention là chi phí
Closure không phải leak tự thân. Chúng thành vấn đề khi host sống lâu (singleton global, listener quên xóa, cache module-level) giữ subgraph capture lớn không cần thiết.
Footgun frontend phổ biến:
- Gắn closure capture DOM subtree hoặc API payload lớn lên
windowhoặc store sống lâu - Thay listener mà không remove cũ — mỗi cái giữ environment
- Helper debounce/throttle close over object nặng đã stale
Cắt retention bằng thu hẹp capture: chỉ extract primitive/ID, gán null reference khi xong, remove listener trong cleanup. Với mental model GC đầy đủ và workflow DevTools, xem JavaScript Memory Management & Leak Hunting.
this — cơ chế riêng biệt
this không lexical (trừ arrow function — xem bên dưới). Nó được xác định bởi cách function được gọi. Nhầm this với closure scope gây hầu hết bug “chạy trong method nhưng không trong callback”.
Quy tắc binding theo thứ tự ưu tiên
| Priority | Call form | this value |
|---|---|---|
| 1 (highest) | new Fn() | Newly created object |
| 2 | fn.call(ctx) / fn.apply(ctx) / fn.bind(ctx) | Explicit ctx (bound functions ignore later overrides) |
| 3 | obj.method() | obj |
| 4 (lowest) | fn() standalone | Strict: undefined; non-strict: global object |
function show() {
return this;
}
const box = { show };
show(); // undefined (strict)
box.show(); // box
show.call({ id: 1 }); // { id: 1 }
new show(); // {} (new object, unless constructor returns object)
bind tạo wrapper với this cố định và optional partial args:
const bound = show.bind({ tag: 'bound' });
bound.call({ tag: 'ignored' }); // still { tag: 'bound' }
Arrow function và lexical this
Arrow function không có binding this riêng. Chúng capture this lexical từ enclosing scope lúc tạo.
const timer = {
seconds: 0,
start() {
setInterval(() => {
this.seconds += 1; // `this` is timer — lexical from start()
}, 1000);
},
};
So với callback function thường:
startBroken() {
setInterval(function tick() {
this.seconds += 1; // `this` is global/window (or undefined in strict module)
}, 1000);
}
Dùng arrow cho callback cần this outer. Không dùng arrow làm object method nếu cần this động, hoặc constructor — new arrowFn() là syntax error.
Mất this trong callback — bài toán React class handler
Method class thường là function thường trên prototype. Truyền trần làm mất receiver:
class SearchBox extends React.Component {
constructor(props) {
super(props);
this.state = { q: '' };
this.handleChange = this.handleChange.bind(this); // fix A
}
handleChange(e) {
this.setState({ q: e.target.value }); // needs component as `this`
}
render() {
return (
<input onChange={this.handleChange} />
);
}
}
Cách sửa engineer dùng thực tế:
| Fix | Tradeoff |
|---|---|
bind in constructor | One function per instance; explicit |
| Class field arrow | Lexical this; instance field; slightly larger per instance |
| Inline arrow in JSX | New function each render — usually fine, watch memoized children |
Function component tránh hẳn vấn đề — hook close over state trong lexical scope, không phải this.
this trong DOM event handler
Với addEventListener, this của listener là element nhận event (trừ arrow passive hoặc bound function):
button.addEventListener('click', function onClick() {
console.log(this === button); // true
});
button.addEventListener('click', () => {
console.log(this); // lexical — likely undefined in module strict
});
Trong API kiểu jQuery, this thường là DOM node khớp trong event callback. Luôn kiểm tra API dùng call-site this hay truyền target làm argument — API hiện đại ưu tiên argument rõ ràng hơn this ma thuật.
Tổng hợp — checklist quyết định
Khi debug scope vs this:
- Biến không cập nhật / mọi callback thấy cùng giá trị? → Kiểm tra khai báo loop (
letvsvar), binding chung, timing async thislàundefinedhoặcwindowtrong callback? → Method bị extract không bind; dùng arrow hoặc receiver rõ- Memory tăng sau navigation? → Closure sống lâu vẫn được reference; audit listener và singleton module
- Cần state private? → Closure hoặc module scope; tránh ô nhiễm global
Nhận thức cấp principal: Closure trả lời “function này thấy biến nào?”.
thistrả lời “ai đang gọi tôi lúc này?”. Tách hai câu hỏi và một nửa hành vi “kỳ lạ” của JavaScript trở nên dự đoán được.