JavaScript Callback (回呼函式)

Callback(回呼函式)是 JavaScript 異步程式設計的基礎概念。簡單來說,callback 就是「把一個函式當作參數傳給另一個函式,等到某件事完成後再執行它」。

什麼是 Callback?

在 JavaScript 中,函式是一級物件 (first-class object),可以像變數一樣被傳遞。當你把一個函式 A 傳給另一個函式 B,讓 B 在適當時機執行 A,這個 A 就是所謂的 callback function。

function greet(name, callback) {
    console.log('Hello, ' + name);
    callback();  // 執行傳入的 callback 函式
}

function sayGoodbye() {
    console.log('Goodbye!');
}

// 把 sayGoodbye 當作 callback 傳入
greet('Mike', sayGoodbye);

// 輸出:
// Hello, Mike
// Goodbye!

同步 Callback

Callback 不一定要用在異步情境。像陣列的 forEachmapfilter 方法,它們接收的函式參數也是 callback,但執行是同步的:

var numbers = [1, 2, 3];

// forEach 的 callback 會同步執行
numbers.forEach(function(num) {
    console.log(num * 2);
});

console.log('done');

// 輸出:
// 2
// 4
// 6
// done

異步 Callback

Callback 最常見的用途是處理異步操作。JavaScript 是單執行緒的語言,為了不阻塞程式執行,像網路請求、讀取檔案、計時器這類需要等待的操作,都會採用異步的方式,等操作完成後再透過 callback 通知你。

setTimeout 範例

console.log('開始');

setTimeout(function() {
    console.log('2 秒後執行');
}, 2000);

console.log('結束');

// 輸出:
// 開始
// 結束
// 2 秒後執行(2 秒後才出現)

setTimeout 不會阻塞程式,它會先把 callback 「註冊」起來,等 2 秒後再執行。

事件處理範例

DOM 事件處理也是典型的 callback 應用:

document.getElementById('myButton').addEventListener('click', function() {
    console.log('按鈕被點擊了!');
});

這裡的匿名函式就是 callback,當使用者點擊按鈕時才會執行。

Callback Hell(回呼地獄)

當你需要依序執行多個異步操作時,callback 會一層套一層,形成所謂的「callback hell」:

// 模擬異步操作
function fetchUser(userId, callback) {
    setTimeout(function() {
        callback({ id: userId, name: 'Mike' });
    }, 1000);
}

function fetchPosts(userId, callback) {
    setTimeout(function() {
        callback([{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }]);
    }, 1000);
}

function fetchComments(postId, callback) {
    setTimeout(function() {
        callback([{ id: 1, text: 'Nice!' }]);
    }, 1000);
}

// Callback Hell - 層層嵌套,難以閱讀和維護
fetchUser(1, function(user) {
    console.log(user);
    fetchPosts(user.id, function(posts) {
        console.log(posts);
        fetchComments(posts[0].id, function(comments) {
            console.log(comments);
            // 如果還有更多操作,會繼續嵌套下去...
        });
    });
});

這種結構有幾個問題:

  • 程式碼難以閱讀,呈現「金字塔」形狀
  • 錯誤處理困難,每一層都要處理錯誤
  • 不容易重複使用和測試

錯誤處理

使用 callback 處理異步操作時,常見的模式是把錯誤當作 callback 的第一個參數(稱為 Error-first callback):

function readFile(filename, callback) {
    setTimeout(function() {
        if (filename === '') {
            callback(new Error('檔案名稱不能為空'), null);
        } else {
            callback(null, '檔案內容...');
        }
    }, 1000);
}

readFile('test.txt', function(err, data) {
    if (err) {
        console.error('錯誤:' + err.message);
        return;
    }
    console.log(data);
});

現代替代方案

為了解決 callback hell 的問題,ES6 引入了 Promise,ES2017 又引入了 async/await,讓異步程式碼更容易閱讀和維護。

上面 callback hell 的範例,用 Promise 改寫會變成:

fetchUser(1)
    .then(function(user) {
        return fetchPosts(user.id);
    })
    .then(function(posts) {
        return fetchComments(posts[0].id);
    })
    .then(function(comments) {
        console.log(comments);
    })
    .catch(function(err) {
        console.error(err);
    });

用 async/await 改寫則更直觀:

async function main() {
    try {
        var user = await fetchUser(1);
        var posts = await fetchPosts(user.id);
        var comments = await fetchComments(posts[0].id);
        console.log(comments);
    } catch (err) {
        console.error(err);
    }
}

雖然有了更現代的語法,但理解 callback 仍然是學習 JavaScript 異步程式設計的基礎。許多 API 和函式庫仍然使用 callback 模式,而且 Promise 和 async/await 底層也是建立在 callback 機制之上。