Closure trong JavaScript: Cách lưu trữ biến và tối ưu mã

JavaScript là một ngôn ngữ lập trình phổ biến, và một trong những khái niệm cốt lõi nhưng dễ gây bối rối với người mới học chính là closure. Hiểu rõ closure sẽ giúp bạn tối ưu mã, tránh những lỗi về phạm vi biến, và làm việc hiệu quả hơn trong các dự án phức tạp. Bài viết này sẽ giải thích closure, cách chúng được lưu trữ trong hàm trả về, cũng như cách xem cấu trúc bên trong bằng console.dir. Chúng ta cũng sẽ đi qua các tình huống thực tế, cách tối ưu mã, và cung cấp bài tập thực hành.

#1. Closure là gì?

Closure trong JavaScript là một cấu trúc cho phép một hàm “nhớ” môi trường (bao gồm các biến, hằng số, hàm…) tại thời điểm nó được định nghĩa, ngay cả khi hàm đó được thực thi ngoài phạm vi ban đầu. Nói đơn giản hơn, closure cho phép một hàm truy cập các biến được khai báo bên ngoài khối lệnh của nó.

#1.1. Ví dụ đơn giản về closure

Hãy xem đoạn mã sau:

1
2
3
4
5
6
7
8
9
10
11
12
function createCounter() {
let count = 0;
function increment() {
count++;
console.log('Current count:', count);
}
return increment;
}

const myCounter = createCounter();
myCounter(); // Current count: 1
myCounter(); // Current count: 2

Giải thích:

  • createCounter() định nghĩa biến count bên trong, sau đó trả về hàm increment().
  • myCounter là hàm increment() được lưu trữ và khi gọi myCounter(), biến count vẫn còn ở đó, dù createCounter() đã kết thúc. Đây chính là closure: hàm trả về (myCounter) vẫn “nhớ” giá trị count.

#2. Closure lưu trữ trong vùng nhớ của hàm trả về

Một điểm quan trọng: closure không đơn giản chỉ là khái niệm trừu tượng. Về mặt kỹ thuật, khi bạn trả về một hàm bên trong một hàm khác, JavaScript sẽ tạo ra một “tham chiếu” tới vùng nhớ có chứa các biến. Vùng nhớ này được gắn kèm cùng hàm trả về, cho phép hàm đó truy cập “môi trường” (environment) ban đầu.

#2.1. Sử dụng console.dir() để xem chi tiết closure

Một mẹo hữu ích là sử dụng console.dir() trong trình duyệt (Chrome, Firefox) để xem cấu trúc chi tiết của hàm, bao gồm cả closure.

Ví dụ:

1
2
3
4
5
6
7
8
9
function createGreeter(name) {
return function() {
console.log('Hello, ' + name + '!');
};
}

const greetJohn = createGreeter('John');
console.dir(greetJohn); // Quan sát ở console để thấy closure
greetJohn(); // Hello, John!

Giải thích:

  • Khi bạn dùng console.dir(greetJohn), trong tab “Console” của trình duyệt, bạn sẽ thấy một đối tượng hàm có chứa nhiều trường. Trong đó, phần [[Scopes]] (hoặc phần tương tự tùy thuộc vào trình duyệt) sẽ cho thấy closure của hàm greetJohn. Tại đây, bạn có thể thấy biến name được lưu trữ.

#3. Lỗi thường gặp và cách tối ưu trong thực tế

#3.1. Phạm vi biến và lỗi vô tình “rò rỉ” giá trị

Trong các dự án thực tế, việc sử dụng closure mà không kiểm soát có thể gây ra lỗi. Ví dụ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function createUsersGreet(users) {
let index = 0;
return function() {
if (index < users.length) {
console.log('Hi ' + users[index]);
index++;
} else {
console.log('No more users!');
}
};
}

const users = ['Alice', 'Bob', 'Charlie'];
const greetUsers = createUsersGreet(users);

// Vòng lặp gọi greetUsers nhiều lần
for (let i = 0; i < 5; i++) {
greetUsers();
}
// Kết quả:
// Hi Alice
// Hi Bob
// Hi Charlie
// No more users!
// No more users!

Vấn đề: Nếu bạn quên reset index hoặc logic kiểm tra không chặt chẽ, closure sẽ vẫn “nhớ” giá trị index, dẫn tới kết quả không mong muốn (in “No more users!” nhiều lần).

Cách khắc phục: Quản lý biến trong closure cẩn thận, hoặc nếu cần, tạo hàm reset hoặc tái tạo closure khi phù hợp.

#3.2. Phòng tránh vòng lặp vô hạn

Lỗi vòng lặp vô hạn thường xảy ra khi bạn quên điều kiện dừng trong closure. Ví dụ, nếu bạn có một closure đọc dữ liệu từ server và tăng dần chỉ số trang, nhưng không bao giờ dừng, sẽ tạo ra vòng lặp liên tục.

Cách phòng tránh:

  • Kiểm tra điều kiện dừng trước mỗi lần gọi.
  • Thiết kế logic kiểm soát biến trong closure rõ ràng, đặt giới hạn, hoặc sử dụng cờ boolean để kết thúc.

#4. Tối ưu mã trong dự án lớn

#4.1. Sử dụng closure để đóng gói logic

Trong dự án thực tế, bạn có thể dùng closure để tạo module nhỏ, đóng gói logic, tránh nhiễu biến toàn cục. Điều này làm mã dễ hiểu và bảo trì hơn.

Ví dụ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const userModule = (function() {
let users = [];
function addUser(name) {
users.push(name);
}
function listUsers() {
console.log(users);
}
return {
addUser,
listUsers
};
})();

userModule.addUser('Daisy');
userModule.listUsers(); // ['Daisy']

Ý nghĩa thực tế:

  • Bạn tạo ra một module với các hàm addUser, listUsers truy cập chung vào biến users thông qua closure.
  • Biến users được “đóng kín” (encapsulated), giúp tránh xung đột biến với phần mã khác.

#4.2. Phòng tránh vòng lặp vô hạn

Như đã đề cập, nếu bạn có vòng lặp duyệt qua dữ liệu từ server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createDataFetcher(apiUrl) {
let page = 1;
return async function() {
const response = await fetch(apiUrl + '?page=' + page);
const data = await response.json();
if (data.length === 0) {
console.log('No more data!');
return;
}
console.log('Data page ' + page + ':', data);
page++;
};
}

const fetchData = createDataFetcher('https://api.example.com/data');
fetchData(); // Lần 1
fetchData(); // Lần 2
// ... cho đến khi hết dữ liệu

Cách tối ưu:

  • Hàm createDataFetcher nhớ biến page trong closure, giúp bạn dễ dàng điều khiển logic phân trang mà không cần biến toàn cục.
  • Thêm điều kiện dừng (nếu data.length === 0) để tránh vòng lặp vô hạn.

#5. Bài tập và tự kiểm tra

  • Bài tập 1: Tạo một hàm createLogger(prefix) trả về một hàm khác, hàm này mỗi khi được gọi sẽ in ra prefix kèm nội dung bạn truyền vào, minh họa closure giữ prefix trong bộ nhớ.
  • Bài tập 2: Viết một closure quản lý danh sách sản phẩm trong giỏ hàng (cart), gồm hàm addItem()showCart().
  • Câu hỏi tự kiểm tra:
    • Closure là gì và tại sao nó quan trọng?
    • Làm thế nào để xem chi tiết closure trong trình duyệt?
    • Nêu một ví dụ thực tế sử dụng closure để tránh phạm vi biến tràn lan.

#6. Kết luận

Hiểu rõ về closure, Execution Context, và Call Stack là bước quan trọng giúp bạn viết mã JavaScript tối ưu, dễ bảo trì, và ít lỗi hơn. Khi nắm vững các khái niệm này, bạn có thể thiết kế ứng dụng phức tạp hơn mà vẫn giữ được tính gọn gàng, chặt chẽ trong quản lý biến và logic. Kỹ năng này rất hữu ích trong các dự án thực tế, khi bạn cần giảm thiểu thời gian debug, cải thiện hiệu năng, và giúp đội ngũ phát triển mở rộng, bảo trì mã dễ dàng.