DEV Community

Scope — Lexical Scope

Lexical scope và scope chain: vì sao biến tìm thấy ở đâu được quyết định lúc viết code

Scope của một biến — nơi nó có thể được nhìn thấy và truy cập — được xác định bởi vị trí hàm được viết ra trong mã nguồn, không phải nơi hàm được gọi. Đây là lexical scope (static scope). Khi engine cần resolve một tên biến, nó tìm trong scope hiện tại, không thấy thì leo lên scope cha theo cấu trúc lồng nhau lúc viết, cứ thế tới global. Chuỗi tìm kiếm đó là scope chain. Nắm chắc hai khái niệm này là điều kiện để hiểu closure, để khỏi viết bug closure-trong-loop kinh điển, và để biết vì sao một biến tưởng đã "chết" vẫn bị giữ trong bộ nhớ.

Cơ chế hoạt động

Mỗi execution context mang theo một tham chiếu tới environment của nó, và environment đó liên kết tới environment cha — chính là scope chain. "Cha" ở đây là scope bao quanh hàm tại nơi nó được định nghĩa, cố định lúc parse, không đổi theo nơi gọi.

const tax = 0.1
function makeInvoice(amount) {
  function withTax() {
    return amount * (1 + tax) // amount: scope cha; tax: global
  }
  return withTax()
}
Enter fullscreen mode Exit fullscreen mode

withTax không có amount hay tax của riêng nó. Engine resolve amount ở scope của makeInvoice (cha trực tiếp), tax ở global. Dù withTax được gọi từ đâu, kết quả resolve không đổi vì chuỗi scope được khóa theo cấu trúc lồng nhau trong code.

Điểm phân biệt quan trọng: lexical scope khác với this. this được quyết định lúc gọi (dynamic), còn biến thường resolve theo lexical scope (static). Lẫn hai cái này là nguồn của rất nhiều hiểu nhầm.

Vấn đề gặp trong production

Failure mode kinh điển nhất sinh ra từ scope là bug closure trong vòng lặp với var:

const handlers = []
for (var i = 0; i < buttons.length; i++) {
  handlers.push(() => console.log('clicked row', i)) // luôn in ra buttons.length
}
Enter fullscreen mode Exit fullscreen mode

var i là function-scoped — chỉ có một binding i duy nhất dùng chung cho mọi vòng lặp. Tất cả closure đẩy vào handlers đều đóng lên cùng một i đó, và tới lúc handler chạy, vòng lặp đã kết thúc nên i bằng buttons.length. Trong thực tế đây là lý do mọi nút in ra cùng một chỉ số, mọi callback gắn sai dữ liệu dòng. let sửa triệt để vì nó tạo binding mới cho mỗi vòng lặp:

for (let i = 0; i < buttons.length; i++) {
  handlers.push(() => console.log('clicked row', i)) // mỗi handler giữ i riêng
}
Enter fullscreen mode Exit fullscreen mode

Failure mode thứ hai, âm thầm hơn: memory leak do closure giữ reference. Vì closure giữ sống toàn bộ scope cha mà nó tham chiếu, một biến nặng vô tình nằm trong scope đó sẽ không bao giờ được thu hồi chừng nào closure còn sống:

function attach(el) {
  const huge = loadBigDataset() // vài MB
  el.addEventListener('click', () => {
    console.log('clicked') // không dùng huge, nhưng closure vẫn giữ scope chứa huge
  })
}
Enter fullscreen mode Exit fullscreen mode

Handler không hề dùng huge, nhưng nó được định nghĩa trong scope chứa huge, nên environment đó bị giữ sống cùng listener. Gắn listener kiểu này lên hàng nghìn phần tử trong một SPA chạy lâu là công thức cho heap phình dần tới khi tab crash. Cách sửa là không để biến nặng lọt vào scope của closure, hoặc giải phóng tường minh khi không cần (huge = null) và gỡ listener khi unmount.

Cách debug và monitor

Bug scope-trong-loop lộ ra khi mọi callback cùng dùng một giá trị cuối — thấy hành vi này, nghi ngay var trong vòng lặp. Với memory leak do closure, công cụ là heap snapshot trong DevTools (hoặc --inspect cho Node): chụp hai snapshot cách nhau một quãng thao tác, so sánh, tìm các object "Detached" hoặc các closure giữ retained size lớn bất thường. Cột Retained Size cho biết một closure đang giữ sống bao nhiêu bộ nhớ — closure giữ vài MB mà code không lý giải được là dấu hiệu một biến nặng lọt vào scope.

Tradeoff

Closure (hệ quả trực tiếp của lexical scope) là công cụ mạnh để đóng gói trạng thái riêng tư và tạo hàm có "trí nhớ", và là nền của phần lớn pattern hàm trong JS. Cái giá là mỗi closure neo sống cả một environment: tiện cho thiết kế nhưng dễ giữ bộ nhớ ngoài ý muốn nếu không cẩn thận về việc cái gì lọt vào scope. Quy tắc thực tế: giữ scope của closure càng gọn càng tốt — chỉ để trong tầm với những gì closure thật sự cần, đẩy dữ liệu nặng ra ngoài hoặc giải phóng sau khi dùng.

Câu hỏi phỏng vấn

Vì sao vòng lặp với var và closure in ra cùng một giá trị, còn let thì không? Giải thích theo scope.

var là function-scoped nên cả vòng lặp chỉ có một binding i duy nhất; mọi closure tạo trong vòng lặp đều đóng lên đúng binding đó, và tới khi chúng chạy thì vòng lặp đã xong, i mang giá trị cuối. let là block-scoped và đặc biệt trong vòng lặp for: mỗi lần lặp tạo một binding mới, nên mỗi closure giữ một i riêng với giá trị tại vòng đó. Mấu chốt là lexical scope — biến resolve theo cấu trúc lồng nhau lúc viết — quyết định closure đóng lên binding nào. Điểm cộng là nối sang hệ quả bộ nhớ: closure giữ sống environment cha, nên biến nặng lọt vào scope sẽ gây leak.

Hands-on

Lấy một danh sách thật được render từ dữ liệu (ví dụ danh sách đơn hàng, mỗi dòng một nút "xem chi tiết") và gắn handler trong vòng lặp var, quan sát mọi nút mở nhầm sang đơn cuối cùng. Đổi sang let để mỗi handler bắt đúng id của dòng nó. Sau đó dựng một kịch bản leak: trong một hàm gắn listener, tạo một mảng lớn rồi gắn một listener không dùng mảng đó; chạy thao tác gắn/gỡ nhiều lần, chụp heap snapshot trước và sau trong DevTools, và xác nhận bộ nhớ không được thu hồi cho tới khi gỡ listener hoặc cắt biến nặng khỏi scope.

Top comments (0)