Closure là một hàm biết ghi nhớ
Mỗi khi bạn định nghĩa một hàm trong JavaScript, nó âm thầm giữ một liên kết tới các biến xung quanh. Khi hàm chạy sau đó, kể cả ở một nơi hoàn toàn khác, nó vẫn nhìn thấy những biến đó. Đó chính là closure.
Ví dụ ngắn nhất:
makeGreeter chạy, trả về một hàm bên trong, rồi kết thúc. Bạn sẽ nghĩ biến cục bộ name của nó biến mất, vì hàm đã chạy xong. Nhưng hàm bên trong vẫn dùng name, nên JavaScript giữ nó lại. greetAda nhớ "Ada". greetBoris nhớ "Boris". Hai closure, hai giá trị được ghi nhớ riêng biệt.
Closure sinh ra từ phạm vi từ vựng
Quy tắc đằng sau closure gọi là phạm vi từ vựng (lexical scope): một hàm nhìn thấy các biến từ nơi nó được viết, không phải nơi nó được gọi. "Từ vựng" chỉ có nghĩa là "dựa trên vị trí của nó trong mã nguồn".
show in ra "I'm outside", không phải "I'm inside caller". Nó được viết cạnh biến outer ở cấp ngoài cùng, nên đó là biến nó nhìn thấy. Gọi nó từ một nơi tình cờ có biến outer riêng cũng không thay đổi gì.
Closure chỉ là phạm vi từ vựng sống lâu hơn hàm bên ngoài. Biến không biến mất vì vẫn còn thứ tham chiếu đến nó.
Mỗi lần gọi có một closure riêng
Mỗi lần gọi mới hàm bên ngoài sẽ tạo ra các biến mới, và mọi hàm bên trong được trả về từ lần gọi đó ghi nhớ những biến này. Đó là lý do greetAda và greetBoris ở trên không xung đột với nhau.
Ví dụ kinh điển là một bộ đếm:
a và b mỗi cái giữ một count riêng. Không gì bên ngoài hàm được trả về có thể đụng tới những biến đó: count hoàn toàn riêng tư. Đây không phải tính năng ngôn ngữ nào được bật lên; nó là hệ quả tự nhiên của cách closure hoạt động.
Biến riêng tư không cần lớp
Vì các biến được bao bọc chỉ truy cập được qua hàm được trả về, bạn có thể dùng closure để dựng các đối tượng nhỏ với trạng thái thật sự riêng tư:
balance không phải thuộc tính của đối tượng được trả về: nó sống trong closure. Cách duy nhất để đọc hoặc thay đổi nó là qua các phương thức bạn đã công khai. Lớp với trường #private cũng làm được điều này, nhưng cách dùng closure đã có từ hàng chục năm trước và vẫn xuất hiện khắp hệ sinh thái.
Cái bẫy kinh điển trong vòng lặp
Closure khiến người ta vấp nhiều nhất là bên trong vòng lặp. Xem điều gì xảy ra với var:
Bạn mong đợi 0, 1, 2. Bạn nhận được 3, 3, 3. Lý do là: var có phạm vi hàm, nên chỉ có một biến i cho cả vòng lặp. Cả ba closure đều bắt cùng một biến, và khi chúng chạy thì vòng lặp đã kết thúc, i bằng 3.
Chuyển sang let:
Giờ nó in ra 0, 1, 2. let có phạm vi khối: mỗi lần lặp tạo ra một ràng buộc mới cho i, nên mỗi closure bắt giá trị riêng của nó. Đây là lý do lớn nhất để ưu tiên let thay vì var.
Closure bắt biến, không bắt giá trị
Một điểm tinh tế nhưng quan trọng: closure giữ chính biến, không phải ảnh chụp giá trị của biến lúc hàm được định nghĩa.
printMessage đọc message lúc nó chạy, không phải lúc nó được tạo. Nếu bạn muốn một ảnh chụp, hãy chép giá trị vào một biến cục bộ trước, và đó thực chất là điều let làm bên trong vòng lặp for.
Một khuôn mẫu thực tế: once
Đây là một tiện ích nhỏ dùng closure để bảo đảm một hàm chỉ chạy đúng một lần:
called và result là trạng thái riêng tư, sống lâu bằng hàm được trả về. Không cờ toàn cục, không đối tượng thừa. Khuôn mẫu này, hàm hỗ trợ nhỏ, trạng thái riêng tư, closure, là một trong những điều hữu ích nhất mà JavaScript làm được.
Đôi lời về bộ nhớ
Closure giữ các biến nó bắt được còn sống chừng nào vẫn còn thứ tham chiếu đến closure. Thường thì đó là điều bạn muốn, nhưng nếu bạn gắn closure vào thứ sống lâu (như event listener của DOM hay bộ nhớ đệm toàn cục) và nó bắt một thứ lớn, thứ đó không thể được dọn rác cho đến khi closure biến mất.
function attach() {
const hugeData = new Array(1_000_000).fill("...");
document.addEventListener("click", () => {
console.log(hugeData.length);
});
}
Chừng nào listener còn được gắn, hugeData vẫn nằm trong bộ nhớ. Gỡ listener (hoặc tránh bắt những gì bạn không cần) thì tham chiếu sẽ được giải phóng. Bạn không cần quản lý chi li chuyện này, chỉ cần biết closure và bộ nhớ có liên quan với nhau.
Những điều cần nhớ
- Closure là một hàm cộng với các biến nó nhìn thấy lúc được định nghĩa.
- Mỗi lần gọi hàm bên ngoài tạo ra một bộ biến mới cho các closure bên trong.
- Closure cho bạn trạng thái riêng tư mà không cần đến lớp.
- Bên trong vòng lặp, hãy dùng
letđể mỗi lần lặp có ràng buộc riêng. - Closure bắt chính biến, không phải giá trị lúc được tạo.
Tiếp theo: từ khóa this
Closure lo phần các biến xung quanh một hàm. Mảnh ghép tiếp theo là hàm được gọi trên đối tượng nào, điều mà trong JavaScript do this quyết định, và nó hoạt động rất khác so với các biến được bắt mà ta vừa tìm hiểu.
Câu hỏi thường gặp
Closure trong JavaScript là gì?
Closure là một hàm ghi nhớ các biến từ phạm vi nơi nó được định nghĩa, kể cả sau khi phạm vi bên ngoài đó đã chạy xong. Về mặt kỹ thuật, mọi hàm trong JavaScript đều là closure; thuật ngữ này thường được nhắc đến khi một hàm được trả về hoặc truyền đi nơi khác mà vẫn dùng các biến từ phạm vi ban đầu của nó.
Closure có ích gì?
Closure cho phép một hàm mang theo trạng thái riêng tư của nó. Bạn có dữ liệu gắn với một hàm mà không cần đến lớp hay biến toàn cục. Các cách dùng phổ biến gồm bộ đếm, callback chỉ chạy một lần, hàm hỗ trợ có ghi nhớ kết quả (memoize), và che giấu chi tiết cài đặt phía sau một API nhỏ.
Vì sao closure hoạt động kỳ lạ trong vòng lặp dùng var?
var có phạm vi hàm, nên mọi lần lặp dùng chung một biến. Các closure tạo ra trong vòng lặp đều tham chiếu đến đúng biến đó, và khi chúng chạy thì biến đã mang giá trị cuối cùng. Hãy dùng let: nó có phạm vi khối, nên mỗi lần lặp có một ràng buộc riêng.