jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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à thisundefined 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:

PartRole
Variable environmentlet/const/class bindings for this scope; also parameters and var in function scope
Lexical environmentThe 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: var là function-scoped (hoặc global nếu khai báo top level). letconst là 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;
DeclarationScopeHoisted?Initial value before lineTDZ?
varFunction / globalYesundefinedNo
letBlockYesUninitializedYes — access throws
constBlockYesMust be assigned on lineYes — 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 imộ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,3 vs 0,1,2 cạ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 window hoặ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

PriorityCall formthis value
1 (highest)new Fn()Newly created object
2fn.call(ctx) / fn.apply(ctx) / fn.bind(ctx)Explicit ctx (bound functions ignore later overrides)
3obj.method()obj
4 (lowest)fn() standaloneStrict: 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ế:

FixTradeoff
bind in constructorOne function per instance; explicit
Class field arrowLexical this; instance field; slightly larger per instance
Inline arrow in JSXNew 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:

  1. Biến không cập nhật / mọi callback thấy cùng giá trị? → Kiểm tra khai báo loop (let vs var), binding chung, timing async
  2. thisundefined hoặc window trong callback? → Method bị extract không bind; dùng arrow hoặc receiver rõ
  3. Memory tăng sau navigation? → Closure sống lâu vẫn được reference; audit listener và singleton module
  4. 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?”. this trả 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.


Đọc thêm

  • reachability, retained size, DevTools
  • function có thể dừng với local state được giữ
  • MDN: Closures, this