Đóng gói hàm (Closures) trong JavaScript

Javascript nâng cao | by Học Javascript

Trong JavaScript, Closure là một khái niệm quan trọng giúp các hàm có thể ghi nhớ và truy cập biến từ phạm vi (scope) mà chúng được tạo ra, ngay cả khi phạm vi đó đã kết thúc. Closure đóng vai trò quan trọng trong lập trình vì nó cho phép bảo vệ dữ liệu, tạo ra các hàm có trạng thái riêng và hỗ trợ lập trình hàm (Functional Programming).

Nhờ vào cơ chế ghi nhớ phạm vi, Closure giúp tránh ô nhiễm biến toàn cục, cung cấp cách thức để tạo biến private trong JavaScript (mô phỏng tính đóng gói của lập trình hướng đối tượng). Ngoài ra, nó còn được sử dụng rộng rãi trong các tình huống thực tế như xử lý bất đồng bộ, tạo hàm callback, module pattern, và tối ưu hiệu suất ứng dụng.

Hiểu rõ về Closure không chỉ giúp lập trình viên viết mã hiệu quả hơn mà còn là nền tảng để làm chủ JavaScript, đặc biệt trong các ứng dụng web phức tạp.

Closure là gì?

Closure là một tính năng của JavaScript cho phép một hàm có thể ghi nhớ phạm vi (scope) nơi nó được tạo ra và có thể truy cập các biến trong phạm vi đó, ngay cả khi hàm được gọi bên ngoài phạm vi ban đầu.

Nói cách khác, Closure giúp một hàm "nhớ" được các biến mà nó đã truy cập khi được định nghĩa, ngay cả khi phạm vi chứa nó đã kết thúc.

Cấu trúc của một Closure

Một Closure thường bao gồm:

  • Hàm cha (outer function): Định nghĩa các biến cục bộ.

  • Hàm con (inner function): Truy cập vào biến của hàm cha, ngay cả khi hàm cha đã kết thúc.

  • Một tham chiếu từ bên ngoài để duy trì trạng thái của các biến trong phạm vi của hàm cha.

Ví dụ minh họa cơ bản về Closure

function outerFunction() {
    let counter = 0; // Biến cục bộ trong outerFunction

    function innerFunction() {
        counter++; // innerFunction có thể truy cập biến counter của outerFunction
        console.log(counter);
    }

    return innerFunction; // Trả về innerFunction mà không thực thi ngay
}

const increment = outerFunction(); // Gọi outerFunction và lưu kết quả vào biến increment

increment(); // Output: 1
increment(); // Output: 2
increment(); // Output: 3

Giải thích:

  • Khi outerFunction() được gọi, nó tạo ra một biến counter và một hàm con innerFunction có thể truy cập counter.

  • Hàm innerFunction được trả về nhưng chưa thực thi ngay, thay vào đó nó được gán vào biến increment.

  • Khi increment() được gọi lần đầu tiên, nó tăng counter lên 1 và in ra 1.

  • Do Closure giữ lại tham chiếu đến counter, nên mỗi lần gọi increment(), counter vẫn tồn tại và tiếp tục tăng.

Lợi ích của Closure trong ví dụ này

  • Ghi nhớ trạng thái của biến (counter) ngay cả khi hàm cha (outerFunction) đã hoàn thành.

  • Tạo ra biến "private" vì counter không thể bị thay đổi từ bên ngoài innerFunction.

Closure là một trong những khái niệm quan trọng nhất trong JavaScript, giúp tăng cường tính bảo mật và hiệu quả của mã nguồn.

Cách hoạt động của Closure trong JavaScript

Phạm vi (Scope) và Chuỗi phạm vi (Scope Chain) trong JavaScript

Để hiểu Closure hoạt động như thế nào, trước tiên chúng ta cần hiểu về phạm vi (Scope) và chuỗi phạm vi (Scope Chain) trong JavaScript.

Phạm vi (Scope) là vùng mà một biến có thể được truy cập. Trong JavaScript, có ba loại phạm vi chính:

  • Global Scope (Phạm vi toàn cục): Biến được khai báo ngoài mọi hàm có thể được truy cập từ bất kỳ đâu trong chương trình.

  • Function Scope (Phạm vi hàm): Biến khai báo bên trong một hàm chỉ có thể được truy cập trong hàm đó.

  • Block Scope (Phạm vi khối): Biến khai báo với let hoặc const bên trong một khối {} chỉ tồn tại trong khối đó.

Chuỗi phạm vi (Scope Chain): Khi JavaScript cần truy cập một biến, nó tìm kiếm biến đó trong phạm vi hiện tại. Nếu không tìm thấy, nó sẽ tìm trong phạm vi bên ngoài, tiếp tục tìm lên cấp cao hơn cho đến khi đạt phạm vi toàn cục.

Cách Closure ghi nhớ biến ngay cả khi phạm vi bên ngoài đã kết thúc

Thông thường, khi một hàm kết thúc, tất cả các biến cục bộ bên trong nó sẽ bị xóa khỏi bộ nhớ do cơ chế Garbage Collection. Tuy nhiên, nếu một hàm con bên trong vẫn tham chiếu đến các biến này, JavaScript sẽ không xóa chúng mà giữ lại để phục vụ hàm con. Đây chính là cách Closure ghi nhớ biến ngay cả khi hàm cha đã kết thúc.

Cụ thể, khi một hàm con được trả về từ một hàm cha, nó vẫn giữ quyền truy cập vào các biến của hàm cha, ngay cả khi hàm cha đã hoàn thành thực thi.

Ví dụ 1: Closure ghi nhớ biến của hàm cha

function outerFunction() {
    let message = "Hello, Closure!"; // Biến cục bộ trong outerFunction

    function innerFunction() {
        console.log(message); // Truy cập biến của outerFunction
    }

    return innerFunction; // Trả về hàm innerFunction nhưng chưa gọi ngay
}

const myClosure = outerFunction(); // outerFunction() thực thi và trả về innerFunction

myClosure(); // Output: "Hello, Closure!"

Giải thích:

  • outerFunction() chạy và tạo ra biến message, sau đó trả về innerFunction.

  • myClosure nhận giá trị là innerFunction, nhưng không gọi ngay.

  • Khi myClosure() được gọi sau này, innerFunction vẫn nhớ biến message của outerFunction, dù outerFunction đã hoàn thành.

Ví dụ 2: Closure duy trì trạng thái biến sau nhiều lần gọi

function counterFunction() {
    let count = 0; // Biến cục bộ của counterFunction

    return function() {
        count++; // Mỗi lần gọi sẽ tăng biến count
        console.log(count);
    };
}

const counter = counterFunction(); // counterFunction() trả về hàm và gán vào counter

counter(); // Output: 1
counter(); // Output: 2
counter(); // Output: 3

Giải thích:

  • counterFunction() chạy một lần và trả về một hàm, nhưng không bị xóa khỏi bộ nhớ vì biến count vẫn được Closure tham chiếu.

  • Mỗi lần gọi counter(), biến count vẫn tồn tại và tiếp tục tăng.

  • Closure giúp biến count tồn tại sau khi hàm cha đã kết thúc, thay vì bị xóa như các biến thông thường.

Ứng dụng của Closure trong JavaScript

Closure là một trong những tính năng mạnh mẽ và quan trọng nhất của JavaScript. Dưới đây là một số ứng dụng thực tế của Closure giúp tận dụng tối đa khả năng của nó trong lập trình.

Bảo vệ biến khỏi bị thay đổi từ bên ngoài (Data Encapsulation)

Closure giúp ẩn dữ liệu và ngăn chặn việc truy cập hoặc thay đổi biến từ bên ngoài, tạo ra một dạng biến private trong JavaScript.

Ví dụ: Tạo biến private bằng Closure

function createCounter() {
    let count = 0; // Biến private

    return {
        increment: function() {
            count++;
            console.log(count);
        },
        decrement: function() {
            count--;
            console.log(count);
        },
        getCount: function() {
            return count;
        }
    };
}

const counter = createCounter();
counter.increment(); // Output: 1
counter.increment(); // Output: 2
counter.decrement(); // Output: 1
console.log(counter.getCount()); // Output: 1

console.log(counter.count); // Undefined (không thể truy cập trực tiếp)

Giải thích:

  • Biến count được khai báo bên trong createCounter() nên không thể truy cập từ bên ngoài.

  • Closure giữ count sống sau khi createCounter() hoàn thành.

  • Chỉ có thể thay đổi giá trị của count thông qua các phương thức increment(), decrement(), getCount().

Tạo hàm có trạng thái riêng biệt (Stateful Functions)

Closure giúp tạo các hàm có trạng thái riêng biệt, giúp duy trì dữ liệu mà không cần biến toàn cục.

Ví dụ: Tạo nhiều bộ đếm độc lập

function createCounter(start = 0) {
    let count = start;

    return function() {
        count++;
        console.log(count);
    };
}

const counter1 = createCounter(5);
const counter2 = createCounter(10);

counter1(); // Output: 6
counter1(); // Output: 7

counter2(); // Output: 11
counter2(); // Output: 12

Giải thích:

  • counter1counter2 có biến count độc lập, không ảnh hưởng lẫn nhau.

  • Closure giúp duy trì giá trị count riêng biệt cho từng instance của createCounter().

Dùng trong lập trình bất đồng bộ (Asynchronous Programming)

Closure giúp lưu trữ dữ liệu trong các callback, rất hữu ích khi làm việc với setTimeout(), setInterval() hoặc Promise.

Ví dụ: Dùng Closure trong setTimeout()

function delayedMessage(message, delay) {
    setTimeout(function() {
        console.log(message);
    }, delay);
}

delayedMessage("Hello after 2 seconds", 2000);

Giải thích:

  • Hàm setTimeout() chạy sau một khoảng thời gian, nhưng closure giúp nó vẫn nhớ được biến message.

  • Nếu không có closure, message có thể bị mất trước khi setTimeout() thực thi.

Tạo hàm Callback linh hoạt

Closure giúp tạo ra các hàm callback có tham số động, giúp code trở nên linh hoạt hơn.

Ví dụ: Closure với event listener

function createLogger(prefix) {
    return function(message) {
        console.log(`[${prefix}] ${message}`);
    };
}

const infoLogger = createLogger("INFO");
const errorLogger = createLogger("ERROR");

infoLogger("User logged in");  // Output: [INFO] User logged in
errorLogger("Server not responding"); // Output: [ERROR] Server not responding

Giải thích:

  • createLogger() tạo ra các hàm có tiền tố (prefix) riêng.

  • Closure giúp infoLogger()errorLogger() lưu trạng thái mà không dùng biến toàn cục.

Ứng dụng Closure trong Module Pattern trong JavaScript

Closure giúp tạo module riêng biệt, giúp mã nguồn dễ bảo trì hơn bằng cách ẩn đi các biến và chỉ cung cấp các phương thức cần thiết.

Ví dụ: Module Pattern với Closure

const CounterModule = (function() {
    let count = 0; // Biến private

    return {
        increment: function() {
            count++;
            console.log(count);
        },
        decrement: function() {
            count--;
            console.log(count);
        },
        getCount: function() {
            return count;
        }
    };
})();

CounterModule.increment(); // Output: 1
CounterModule.increment(); // Output: 2
CounterModule.decrement(); // Output: 1
console.log(CounterModule.getCount()); // Output: 1

Giải thích:

  • CounterModule là một module đóng gói với biến count private.

  • Chỉ có thể thao tác count thông qua các phương thức increment(), decrement(), getCount().

  • Tránh ô nhiễm biến toàn cục, giúp code dễ bảo trì hơn.

Các tình huống thường gặp với Closure trong JavaScript

Closure là một tính năng mạnh mẽ trong JavaScript, nhưng nó cũng có thể gây ra một số vấn đề phổ biến, đặc biệt là trong vòng lặp, hàm setTimeout(), và lập trình hướng đối tượng. Dưới đây là những tình huống hay gặp phải và cách khắc phục.

Closure trong vòng lặp (Loop and Closure problem) và cách khắc phục

Một vấn đề phổ biến khi sử dụng closure trong vòng lặp là biến trong closure có thể không giữ được giá trị mong muốn do JavaScript sử dụng biến tham chiếu khi chạy vòng lặp.

Ví dụ lỗi với var trong vòng lặp

for (var i = 1; i <= 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

Kết quả mong đợi:

1
2
3

Kết quả thực tế:

4
4
4

Lý do:

  • Biến i được khai báo bằng var nên nó có phạm vi function scope, nghĩa là sau khi vòng lặp kết thúc, i = 4 (vì i tăng đến 4 thì vòng lặp dừng).

  • Tất cả các hàm setTimeout() đều tham chiếu đến cùng một biến i, nên khi chúng chạy sau 1 giây, giá trị của i là 4.

Sử dụng let để tạo block scope

for (let i = 1; i <= 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

Kết quả đúng:

1
2
3

Lý do:

  • letphạm vi block scope, mỗi lần lặp tạo một bản sao riêng của i, nên mỗi setTimeout() ghi nhớ giá trị i khác nhau.

Dùng IIFE để tạo closure riêng

for (var i = 1; i <= 3; i++) {
    (function(i) {
        setTimeout(function() {
            console.log(i);
        }, 1000);
    })(i);
}

Kết quả đúng:

1
2
3

Lý do:

  • Mỗi lần lặp, một hàm IIFE (function(i){}) được tạo mới và i được truyền vào, tạo một closure riêng biệt giữ giá trị của i.

Closure trong setTimeout() và setInterval()

Closure thường gây lỗi khi sử dụng với setTimeout() hoặc setInterval() nếu không xử lý đúng cách.

Ví dụ lỗi với setTimeout()

function startTimer() {
    var count = 1;
    setTimeout(function() {
        console.log(count);
        count++;
    }, 2000);
}

startTimer();
console.log(count); // Lỗi: count không được định nghĩa

Lỗi: count chỉ tồn tại trong phạm vi của startTimer(), nhưng nó không được cập nhật sau mỗi lần chạy setTimeout().

Dùng closure để duy trì trạng thái

function startTimer() {
    var count = 1;

    function printCount() {
        console.log(count);
        count++;
        setTimeout(printCount, 2000); // Gọi lại chính nó để lặp vô hạn
    }

    printCount();
}

startTimer();

Kết quả đúng:

1
2 (sau 2 giây)
3 (sau 2 giây)
...

Lý do:

  • printCount() tạo một closure giữ count trong phạm vi của startTimer(), giúp duy trì trạng thái count cho mỗi lần gọi.

Closure trong lập trình hướng đối tượng (OOP)

Trong lập trình hướng đối tượng, closure giúp bảo vệ dữ liệu private và tạo các phương thức có trạng thái riêng biệt.

Ví dụ sử dụng closure trong OOP để tạo biến private

function Counter() {
    let count = 0; // Biến private

    this.increment = function() {
        count++;
        console.log(count);
    };

    this.getCount = function() {
        return count;
    };
}

const myCounter = new Counter();
myCounter.increment(); // Output: 1
myCounter.increment(); // Output: 2
console.log(myCounter.getCount()); // Output: 2
console.log(myCounter.count); // Undefined (không thể truy cập trực tiếp)

Lợi ích:

  • countbiến private, không thể bị thay đổi từ bên ngoài.

  • increment()getCount() tạo closure giúp truy cập count một cách an toàn.

Ví dụ sử dụng closure để duy trì this

Khi sử dụng phương thức trong object, giá trị của this có thể bị thay đổi ngoài ý muốn.

Lỗi mất this khi dùng setTimeout()

const person = {
    name: "Alice",
    sayHello: function() {
        setTimeout(function() {
            console.log(`Hello, my name is ${this.name}`);
        }, 1000);
    }
};

person.sayHello(); // Output: Hello, my name is undefined

Lỗi:

  • setTimeout() chạy trong một context khác, nên this không trỏ đến person nữa.

Cách khắc phục: Dùng closure để giữ giá trị this

const person = {
    name: "Alice",
    sayHello: function() {
        const self = this; // Lưu lại this
        setTimeout(function() {
            console.log(`Hello, my name is ${self.name}`);
        }, 1000);
    }
};

person.sayHello(); // Output: Hello, my name is Alice

Giải thích:

  • self lưu lại giá trị của this, giúp phương thức bên trong setTimeout() truy cập đúng person.name.

Cách khắc phục: Dùng arrow function

const person = {
    name: "Alice",
    sayHello: function() {
        setTimeout(() => {
            console.log(`Hello, my name is ${this.name}`);
        }, 1000);
    }
};

person.sayHello(); // Output: Hello, my name is Alice

Giải thích:

  • Arrow function không có this riêng, nó kế thừa this từ phạm vi cha, giúp giữ đúng giá trị this trong setTimeout().

Lợi ích và hạn chế của Closure trong JavaScript

Closure là một tính năng mạnh mẽ của JavaScript, giúp cải thiện khả năng quản lý biến và logic của chương trình. Tuy nhiên, nó cũng có một số hạn chế cần lưu ý. Dưới đây là phân tích chi tiết về lợi ích và hạn chế của closure.

Lợi ích của Closure

Giúp tạo ra các hàm có trạng thái riêng biệt

Closure giúp tạo ra các hàm có trạng thái riêng mà không cần dùng biến toàn cục. Điều này rất hữu ích trong lập trình hàm và lập trình hướng đối tượng.

Ví dụ: Tạo bộ đếm có trạng thái riêng

function createCounter() {
    let count = 0; // Biến private
    
    return function() {
        count++; // Giữ trạng thái biến count
        console.log(count);
    };
}

const counter1 = createCounter();
const counter2 = createCounter();

counter1(); // Output: 1
counter1(); // Output: 2
counter2(); // Output: 1
  • count là một biến riêng của từng counter.

  • Không bị thay đổi bởi các hàm khác trong chương trình.

Hỗ trợ lập trình bất đồng bộ hiệu quả

Closure giúp duy trì trạng thái của biến trong các hàm bất đồng bộ như setTimeout(), setInterval(), hoặc Promise.

Ví dụ: Sử dụng closure với setTimeout()

function delayedMessage(message, delay) {
    setTimeout(function() {
        console.log(message);
    }, delay);
}

delayedMessage("Hello!", 2000); // Hiển thị "Hello!" sau 2 giây
  • message được giữ lại trong closure ngay cả khi delayedMessage() đã kết thúc.

  • Hữu ích trong các ứng dụng AJAX, WebSocket, và xử lý dữ liệu bất đồng bộ.

Giúp giảm thiểu xung đột biến trong phạm vi toàn cục

Closure giúp giữ biến trong phạm vi cục bộ, tránh làm ảnh hưởng đến các biến toàn cục.

Ví dụ: Tránh ô nhiễm biến toàn cục

function createLogger(name) {
    return function(message) {
        console.log(`[${name}] ${message}`);
    };
}

const infoLogger = createLogger("INFO");
const errorLogger = createLogger("ERROR");

infoLogger("This is an info message."); // Output: [INFO] This is an info message.
errorLogger("This is an error message."); // Output: [ERROR] This is an error message.
  • name được giữ trong phạm vi cục bộ của mỗi logger.

  • Tránh xung đột giữa các biến khác trong chương trình.

Hạn chế của Closure

Có thể gây rò rỉ bộ nhớ (Memory Leak) nếu không quản lý đúng cách

Closure có thể gây rò rỉ bộ nhớ nếu các biến được giữ lại trong bộ nhớ ngay cả khi chúng không còn cần thiết.

Ví dụ: Closure giữ lại tham chiếu không cần thiết

function createMemoryLeak() {
    let largeArray = new Array(1000000).fill("Memory Leak");

    return function() {
        console.log(largeArray.length); // Closure vẫn giữ largeArray trong bộ nhớ
    };
}

const leakyFunction = createMemoryLeak();
// largeArray vẫn tồn tại trong bộ nhớ dù không còn cần thiết

Hạn chế:

  • largeArray vẫn tồn tại trong bộ nhớ, ngay cả khi không còn được sử dụng.

  • Lãng phí tài nguyên hệ thống, có thể làm chậm ứng dụng.

Cách khắc phục: Giải phóng bộ nhớ khi không cần thiết

function createSafeClosure() {
    let largeArray = new Array(1000000).fill("Safe Closure");

    return function() {
        console.log(largeArray.length);
        largeArray = null; // Giải phóng bộ nhớ
    };
}

const safeFunction = createSafeClosure();
safeFunction(); // Sử dụng xong thì dữ liệu sẽ được giải phóng

Kết quả: Sau khi safeFunction() chạy, largeArray được giải phóng, tránh rò rỉ bộ nhớ.

Kết bài

Closure là một trong những khái niệm quan trọng và mạnh mẽ trong JavaScript, giúp quản lý biến hiệu quả, bảo vệ dữ liệu và hỗ trợ lập trình bất đồng bộ. Nhờ khả năng ghi nhớ phạm vi nơi nó được tạo ra, closure giúp tạo ra các hàm có trạng thái riêng biệt, tránh ô nhiễm biến toàn cục và nâng cao tính linh hoạt trong lập trình.

Tuy nhiên, closure cũng có những hạn chế nhất định, như rò rỉ bộ nhớ nếu không được quản lý đúng cách hoặc gây khó khăn trong việc debug. Do đó, khi sử dụng closure, cần cân nhắc kỹ lưỡng để tối ưu hiệu suất và tránh các lỗi tiềm ẩn.

Tóm lại, nếu hiểu và áp dụng closure một cách hợp lý, bạn có thể tận dụng tối đa sức mạnh của JavaScript để viết code sạch, hiệu quả và dễ bảo trì hơn.

Bài viết liên quan