Hàm Callbacks trong JavaScript

Javascript nâng cao | by Học Javascript

Trong JavaScript, callback là một khái niệm quan trọng, giúp lập trình viên kiểm soát luồng thực thi của chương trình, đặc biệt trong các tác vụ bất đồng bộ như xử lý sự kiện, gọi API hoặc đọc dữ liệu từ tệp. Callback là một hàm được truyền vào một hàm khác như một đối số và sẽ được gọi sau khi hàm kia hoàn tất.

Callback đóng vai trò quan trọng trong lập trình JavaScript, giúp mã nguồn linh hoạt hơn và dễ mở rộng. Tuy nhiên, việc sử dụng callback không đúng cách có thể dẫn đến tình trạng Callback Hell, gây khó khăn trong bảo trì mã. Trong bài viết này, chúng ta sẽ tìm hiểu về callback, cách sử dụng, các vấn đề thường gặp và cách khắc phục chúng để viết mã JavaScript hiệu quả hơn.

Hàm Callback là gì?

Trong JavaScript, callback là một hàm được truyền vào một hàm khác dưới dạng đối số và được thực thi sau khi hàm đó hoàn tất nhiệm vụ của mình. Callback giúp lập trình viên kiểm soát luồng thực thi của chương trình, đặc biệt hữu ích trong các tác vụ bất đồng bộ như xử lý sự kiện, đọc dữ liệu từ API hoặc tương tác với cơ sở dữ liệu.

Cách hoạt động của Callback

Khi một hàm nhận một hàm khác làm tham số, nó có thể gọi hàm đó tại một thời điểm nhất định, chẳng hạn như sau khi hoàn tất một công việc nào đó. Điều này giúp JavaScript có thể thực hiện các tác vụ bất đồng bộ mà không làm gián đoạn chương trình.

Ví dụ minh họa về Callback

Ví dụ đơn giản về callback:

function greet(name, callback) {
    console.log("Xin chào, " + name + "!");
    callback();
}

function sayGoodbye() {
    console.log("Hẹn gặp lại!");
}

// Gọi hàm greet và truyền sayGoodbye làm callback
greet("Nguyễn", sayGoodbye);

Kết quả in ra:

Xin chào, Nguyễn!  
Hẹn gặp lại!  

Trong ví dụ trên, hàm sayGoodbye được truyền vào greet và được thực thi sau khi câu "Xin chào" được in ra.

Cách sử dụng Callback trong JavaScript

Truyền một hàm như một tham số cho một hàm khác

JavaScript cho phép truyền một hàm như một tham số vào một hàm khác, sau đó gọi lại hàm đó trong quá trình thực thi. Điều này giúp linh hoạt trong việc xử lý logic chương trình.

Ví dụ:

function processData(data, callback) {
    console.log("Đang xử lý dữ liệu: " + data);
    callback();
}

function onComplete() {
    console.log("Xử lý dữ liệu hoàn tất!");
}

// Gọi hàm processData và truyền onComplete làm callback
processData("Thông tin khách hàng", onComplete);

Kết quả in ra:

Đang xử lý dữ liệu: Thông tin khách hàng  
Xử lý dữ liệu hoàn tất!  

Viết và gọi một hàm Callback

Ngoài cách truyền một hàm đã định nghĩa sẵn, bạn cũng có thể truyền trực tiếp một hàm ẩn danh (anonymous function) hoặc một hàm mũi tên (arrow function) làm callback.

Ví dụ với hàm ẩn danh:

function downloadFile(url, callback) {
    console.log("Đang tải file từ: " + url);
    setTimeout(function() {
        console.log("Tải file hoàn tất!");
        callback();
    }, 2000);
}

// Gọi hàm với một hàm ẩn danh làm callback
downloadFile("https://example.com/file", function() {
    console.log("Bắt đầu xử lý file...");
});

Kết quả in ra sau 2 giây:

Đang tải file từ: https://example.com/file  
Tải file hoàn tất!  
Bắt đầu xử lý file...  

Ví dụ với arrow function:

downloadFile("https://example.com/file", () => {
    console.log("Bắt đầu xử lý file...");
});

Cách này giúp mã ngắn gọn hơn nhưng vẫn đảm bảo tính linh hoạt của callback.

Callback trong lập trình bất đồng bộ trong JavaScript

Giải thích về lập trình bất đồng bộ trong JavaScript

JavaScript là một ngôn ngữ đơn luồng (single-threaded), nghĩa là nó chỉ có thể thực thi một tác vụ tại một thời điểm. Tuy nhiên, trong nhiều trường hợp như:

  • Gọi API lấy dữ liệu (AJAX, Fetch API).

  • Đọc/ghi file (File System trong Node.js).

  • Thiết lập bộ đếm thời gian (setTimeout, setInterval).

  • Xử lý sự kiện người dùng (click, keypress).

Các tác vụ trên có thể mất thời gian để hoàn thành, và nếu thực hiện theo kiểu đồng bộ (synchronous), chương trình sẽ bị chặn và không thể tiếp tục thực thi các tác vụ khác cho đến khi tác vụ hiện tại hoàn tất.

Để giải quyết vấn đề này, JavaScript sử dụng mô hình bất đồng bộ (asynchronous programming), trong đó callback function đóng vai trò quan trọng trong việc xử lý kết quả sau khi một tác vụ hoàn thành mà không làm gián đoạn chương trình.

Callback trong setTimeout() và setInterval()

setTimeout(callback, time) – Chạy một lần sau khoảng thời gian xác định

Hàm setTimeout() nhận một hàm callback làm tham số đầu tiên và thực thi nó sau một khoảng thời gian (tính bằng mili-giây).

Ví dụ:

console.log("Bắt đầu...");

setTimeout(() => {
    console.log("Thực hiện sau 3 giây!");
}, 3000);

console.log("Chương trình tiếp tục chạy...");

Kết quả in ra:

Bắt đầu...  
Chương trình tiếp tục chạy...  
(đợi 3 giây)  
Thực hiện sau 3 giây!  

Giải thích: setTimeout() không chặn chương trình mà chỉ đặt một hẹn giờ để chạy callback sau 3 giây.

setInterval(callback, time) – Chạy liên tục sau mỗi khoảng thời gian xác định

Hàm setInterval() tương tự setTimeout(), nhưng nó thực thi callback lặp lại liên tục sau mỗi khoảng thời gian.

Ví dụ:

let count = 0;

const interval = setInterval(() => {
    count++;
    console.log(`Đếm: ${count}`);
    if (count === 5) {
        clearInterval(interval); // Dừng vòng lặp sau 5 lần
    }
}, 1000);

Kết quả in ra:

Đếm: 1  
Đếm: 2  
Đếm: 3  
Đếm: 4  
Đếm: 5  

Giải thích: setInterval() giúp lặp lại một công việc liên tục cho đến khi bị dừng bằng clearInterval().

Callback trong xử lý sự kiện (addEventListener)

Trong JavaScript, khi người dùng tương tác với trang web (như click chuột, nhập dữ liệu, kéo thả, v.v.), ta có thể sử dụng callback để xử lý sự kiện này.

Ví dụ:

document.getElementById("myButton").addEventListener("click", function () {
    console.log("Nút đã được nhấn!");
});

Khi người dùng nhấn vào nút có id="myButton", callback function sẽ được gọi và in ra "Nút đã được nhấn!".

Ví dụ minh họa về callback trong bất đồng bộ

Giả sử bạn muốn tải dữ liệu từ một API giả lập, nhưng không muốn chương trình bị chặn trong khi chờ dữ liệu tải xong.

function fetchData(callback) {
    console.log("Đang lấy dữ liệu...");

    setTimeout(() => {
        let data = { name: "Nguyễn Văn A", age: 25 };
        callback(data);
    }, 2000);
}

fetchData((data) => {
    console.log("Dữ liệu nhận được:", data);
});

Kết quả in ra:

Đang lấy dữ liệu...  
(đợi 2 giây)  
Dữ liệu nhận được: { name: "Nguyễn Văn A", age: 25 }  

Giải thích: Callback giúp đảm bảo rằng chương trình chỉ xử lý dữ liệu sau khi dữ liệu đã được tải thành công.

Callback trong lập trình đồng bộ trong JavaScript

Cách sử dụng callback trong các tình huống đồng bộ

Mặc dù callback thường được sử dụng trong lập trình bất đồng bộ, nhưng nó cũng có thể được sử dụng trong lập trình đồng bộ để tổ chức mã nguồn tốt hơn.

Ví dụ về sử dụng callback để xử lý dữ liệu theo từng bước:

function processNumber(number, callback) {
    let result = number * 2;
    callback(result);
}

processNumber(5, function (result) {
    console.log("Kết quả:", result);
});

Kết quả in ra:

Kết quả: 10  

Giải thích: Hàm processNumber nhận một số, nhân đôi nó, rồi gọi callback để xử lý kết quả.

Ví dụ minh họa callback trong xử lý dữ liệu

Giả sử bạn có một danh sách học sinh và muốn lọc ra những người có điểm số lớn hơn 7.

function filterStudents(students, callback) {
    let passedStudents = students.filter(callback);
    return passedStudents;
}

let students = [
    { name: "Lan", score: 8 },
    { name: "Minh", score: 6 },
    { name: "Hùng", score: 9 },
    { name: "Hải", score: 7 }
];

let result = filterStudents(students, (student) => student.score > 7);
console.log("Học sinh đạt yêu cầu:", result);

Kết quả in ra:

Học sinh đạt yêu cầu: [ { name: 'Lan', score: 8 }, { name: 'Hùng', score: 9 } ]  

Giải thích: Hàm filterStudents() nhận danh sách học sinh và một hàm callback để lọc dữ liệu.

Vấn đề Callback Hell trong JavaScript

Callback Hell là gì?

Callback Hell là một thuật ngữ dùng để mô tả tình trạng khi một đoạn mã JavaScript có quá nhiều callback lồng nhau, khiến mã nguồn trở nên rối rắm, khó đọc và khó bảo trì.

Điều này thường xảy ra trong các tác vụ bất đồng bộ như:

  • Gọi API liên tiếp (AJAX, Fetch API).

  • Đọc và xử lý file trong Node.js.

  • Thực thi nhiều bước phụ thuộc vào kết quả của các bước trước.

Vì sao Callback Hell gây khó khăn trong việc đọc hiểu và bảo trì mã nguồn?

  • Khó đọc: Mã lồng nhau quá nhiều cấp khiến việc hiểu logic trở nên khó khăn.
  • Khó bảo trì: Nếu muốn thay đổi một bước trong quy trình, ta phải sửa đổi nhiều vị trí.
  • Dễ xảy ra lỗi: Quá nhiều callback khiến việc debug và bắt lỗi trở nên khó khăn.

Ví dụ về Callback Hell

Dưới đây là một đoạn mã giả lập tải dữ liệu từ API với nhiều bước phụ thuộc vào kết quả của các bước trước:

function getUser(userId, callback) {
    setTimeout(() => {
        console.log("Lấy thông tin người dùng...");
        callback({ id: userId, name: "Nguyễn Văn A" });
    }, 1000);
}

function getOrders(user, callback) {
    setTimeout(() => {
        console.log(`Lấy danh sách đơn hàng của ${user.name}...`);
        callback(["Order1", "Order2", "Order3"]);
    }, 1000);
}

function getOrderDetails(orderId, callback) {
    setTimeout(() => {
        console.log(`Lấy chi tiết đơn hàng ${orderId}...`);
        callback({ orderId, items: ["Sản phẩm A", "Sản phẩm B"] });
    }, 1000);
}

// Gọi callback lồng nhau (Callback Hell)
getUser(1, (user) => {
    getOrders(user, (orders) => {
        getOrderDetails(orders[0], (details) => {
            console.log("Chi tiết đơn hàng:", details);
        });
    });
});
Kết quả in ra:
Lấy thông tin người dùng...  
Lấy danh sách đơn hàng của Nguyễn Văn A...  
Lấy chi tiết đơn hàng Order1...  
Chi tiết đơn hàng: { orderId: 'Order1', items: [ 'Sản phẩm A', 'Sản phẩm B' ] }  

Vấn đề: Code bị lồng nhau quá nhiều khiến việc mở rộng hoặc thay đổi rất khó khăn.

Cách khắc phục Callback Hell trong JavaScript

Sử dụng named functions thay vì callback lồng nhau

Thay vì viết trực tiếp callback bên trong các hàm, ta có thể tách chúng thành các hàm có tên để mã nguồn dễ đọc hơn.

function getUser(userId, callback) {
    setTimeout(() => {
        console.log("Lấy thông tin người dùng...");
        callback({ id: userId, name: "Nguyễn Văn A" });
    }, 1000);
}

function getOrders(user, callback) {
    setTimeout(() => {
        console.log(`Lấy danh sách đơn hàng của ${user.name}...`);
        callback(["Order1", "Order2", "Order3"]);
    }, 1000);
}

function getOrderDetails(orderId, callback) {
    setTimeout(() => {
        console.log(`Lấy chi tiết đơn hàng ${orderId}...`);
        callback({ orderId, items: ["Sản phẩm A", "Sản phẩm B"] });
    }, 1000);
}

// Định nghĩa các callback riêng biệt
function handleOrderDetails(details) {
    console.log("Chi tiết đơn hàng:", details);
}

function handleOrders(orders) {
    getOrderDetails(orders[0], handleOrderDetails);
}

function handleUser(user) {
    getOrders(user, handleOrders);
}

// Gọi hàm với callback có tên
getUser(1, handleUser);

Lợi ích: Mã dễ đọc hơn, không bị lồng quá nhiều cấp.

Sử dụng Promise để thay thế callback

JavaScript cung cấp Promise để giúp quản lý các tác vụ bất đồng bộ một cách dễ hiểu và có thể chaining (xâu chuỗi) các bước mà không bị callback hell.

function getUser(userId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("Lấy thông tin người dùng...");
            resolve({ id: userId, name: "Nguyễn Văn A" });
        }, 1000);
    });
}

function getOrders(user) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Lấy danh sách đơn hàng của ${user.name}...`);
            resolve(["Order1", "Order2", "Order3"]);
        }, 1000);
    });
}

function getOrderDetails(orderId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Lấy chi tiết đơn hàng ${orderId}...`);
            resolve({ orderId, items: ["Sản phẩm A", "Sản phẩm B"] });
        }, 1000);
    });
}

// Sử dụng Promise chaining để tránh Callback Hell
getUser(1)
    .then(getOrders)
    .then((orders) => getOrderDetails(orders[0]))
    .then((details) => console.log("Chi tiết đơn hàng:", details))
    .catch((error) => console.error("Lỗi:", error));

Lợi ích: Giúp mã nguồn rõ ràng hơn, dễ quản lý hơn.

Sử dụng async/await để xử lý bất đồng bộ hiệu quả hơn

async/await là cách viết hiện đại, giúp mã trông giống như đồng bộ nhưng vẫn hoạt động bất đồng bộ.

async function fetchOrderDetails() {
    try {
        const user = await getUser(1);
        const orders = await getOrders(user);
        const details = await getOrderDetails(orders[0]);
        console.log("Chi tiết đơn hàng:", details);
    } catch (error) {
        console.error("Lỗi:", error);
    }
}

fetchOrderDetails();

Dễ đọc hơn: Không cần .then(), không bị callback hell.
Dễ debug hơn: Có thể dùng try...catch để xử lý lỗi.

Kết bài

Hàm callback là một khái niệm quan trọng trong JavaScript, giúp xử lý các tác vụ bất đồng bộ như gọi API, đọc file, hoặc xử lý sự kiện. Tuy nhiên, việc lạm dụng callback có thể dẫn đến Callback Hell, khiến mã nguồn trở nên khó đọc, khó bảo trì và dễ xảy ra lỗi.

Để tránh vấn đề này, các kỹ thuật hiện đại như named functions, Promise và async/await đã ra đời, giúp tổ chức mã tốt hơn và cải thiện khả năng đọc hiểu. Trong thực tế, async/await là phương pháp được khuyến khích sử dụng vì nó giúp mã trông rõ ràng và dễ quản lý hơn.

Bài viết liên quan