Closure pattern: trạng thái riêng tư, factory, và memoization làm đúng cách
Closure là một hàm cùng với environment nơi nó được định nghĩa: nó "nhớ" và tiếp tục truy cập được các biến của scope cha kể cả khi hàm cha đã return. Đặc tính này không phải mẹo cú pháp — nó là cách JavaScript có được trạng thái riêng tư thật sự, là nền của factory function, module pattern, và memoization. Dùng đúng, closure cho đóng gói gọn gàng không cần class; dùng ẩu, nó giữ object sống ngoài ý muốn và rò bộ nhớ.
Cơ chế hoạt động
Khi một hàm trong được trả ra ngoài, environment của hàm cha không bị thu hồi vì hàm trong vẫn tham chiếu tới nó. Biến trong environment đó trở thành trạng thái riêng tư: không có cách nào truy cập từ bên ngoài ngoài qua chính closure.
function createRateLimiter(maxPerMinute) {
let hits = []
return function allow() {
const now = Date.now()
hits = hits.filter((t) => now - t < 60_000) // chỉ giữ hit trong 60s
if (hits.length >= maxPerMinute) return false
hits.push(now)
return true
}
}
const allow = createRateLimiter(100)
allow() // true/false, hits hoàn toàn riêng tư
hits không thể bị sửa từ bên ngoài, không nằm trên object nào để bị ghi đè. Đây là encapsulation thật, không phải quy ước _private.
Vấn đề gặp trong production
Factory function cho cấu hình đóng kín. Pattern phổ biến nhất là một factory nhận cấu hình một lần rồi trả về hàm đã "nạp" sẵn cấu hình đó:
function createClient({ baseUrl, token }) {
return {
get: (path) => fetch(`${baseUrl}${path}`, { headers: { Authorization: `Bearer ${token}` } }),
}
}
baseUrl và token sống trong closure, không lộ ra ngoài, không bị một phần code khác vô tình đổi. So với việc nhét cấu hình lên một biến module dùng chung, cách này tránh được lỗi state toàn cục bị ghi đè giữa các phần của ứng dụng.
Memoization. Closure giữ cache giữa các lần gọi:
function memoize(fn) {
const cache = new Map()
return function (arg) {
if (cache.has(arg)) return cache.get(arg)
const result = fn(arg)
cache.set(arg, result)
return result
}
}
Đây cũng là chỗ closure cắn lại: cache sống chừng nào hàm memoized còn sống, và nó không bao giờ tự dọn. Failure mode production là memoize một hàm nhận đối số đa dạng vô hạn (id người dùng, query string) — cache phình mãi tới khi hết heap. Map thường giữ key vĩnh viễn; muốn cache có giới hạn phải tự thêm chính sách evict (LRU, TTL) hoặc dùng WeakMap khi key là object có thể bị thu hồi:
function memoizeWeak(fn) {
const cache = new WeakMap() // key là object; mất reference thì entry tự được GC
return (obj) => {
if (cache.has(obj)) return cache.get(obj)
const r = fn(obj)
cache.set(obj, r)
return r
}
}
WeakMap không ngăn key bị garbage-collect, nên khi object key không còn ai tham chiếu, entry trong cache cũng biến mất — đúng thứ cần để memoize theo object mà không leak. Hạn chế: key bắt buộc là object, không dùng cho primitive như chuỗi/số.
Cách debug và monitor
Closure-leak biểu hiện là heap tăng đều theo số lần gọi một hàm, không phẳng lại sau khi traffic ổn định. Trong DevTools/--inspect, chụp heap snapshot và tìm các Map/array có retained size lớn nằm trong closure — đó thường là cache không giới hạn. Một cache memoization khỏe mạnh phải có trần (số entry tối đa) và/hoặc TTL; nếu không thấy cơ chế evict trong code, coi như nó là leak chờ nổ dưới tải thật.
Tradeoff
Closure so với class: closure cho trạng thái riêng tư thật và cú pháp gọn cho các đơn vị nhỏ (một counter, một limiter, một factory), không cần this nên tránh được cả lớp bug binding. Class cho cấu trúc rõ ràng hơn khi có nhiều method chia sẻ state, kế thừa, và tiết kiệm bộ nhớ hơn khi tạo nhiều instance (method nằm trên prototype, không tạo lại mỗi lần). Quy tắc thực tế: vài instance với state riêng tư gọn → closure/factory; nhiều instance cùng kiểu, nhiều method dùng chung → class. Và bất kỳ closure nào giữ cache đều phải đi kèm chính sách giới hạn, nếu không "tính năng nhớ" biến thành rò bộ nhớ.
Câu hỏi phỏng vấn
Closure dùng để làm gì trong thực tế, và rủi ro lớn nhất khi dùng là gì?
Closure cho phép một hàm giữ truy cập tới scope nơi nó được định nghĩa kể cả sau khi hàm cha return, nên dùng để: tạo trạng thái riêng tư (encapsulation không cần class), factory function nạp sẵn cấu hình, và memoization giữ cache giữa các lần gọi. Rủi ro lớn nhất là memory: closure neo sống cả environment cha, nên một cache không giới hạn (memoize trên đối số đa dạng vô hạn) hoặc một biến nặng lọt vào scope sẽ phình heap. Điểm ăn điểm là nêu cách kiểm soát: cache phải có trần/TTL hoặc dùng WeakMap khi key là object, và giữ scope của closure gọn nhất có thể.
Hands-on
Viết một memoize thật cho một hàm tốn kém (ví dụ chuẩn hóa và geocode một địa chỉ qua API), trước tiên bằng Map không giới hạn, rồi mô phỏng tải bằng cách gọi với hàng trăm nghìn địa chỉ khác nhau và quan sát heap tăng không dừng qua snapshot. Sau đó thêm một chính sách LRU đơn giản (giới hạn N entry, xóa entry cũ nhất khi vượt) và xác nhận heap phẳng lại. Cuối cùng viết một biến thể WeakMap memoize theo object request và kiểm tra rằng khi object không còn được tham chiếu, entry cache được thu hồi — so sánh hành vi bộ nhớ giữa ba bản.
Top comments (0)