ข้ามไปยังเนื้อหา

Callbacks

Callback คือฟังก์ชันที่คุณส่งให้อีกฟังก์ชันหนึ่ง เพื่อให้ถูกเรียกภายหลังเมื่องานทำเสร็จ นี่คือวิธีดั้งเดิมของ JavaScript ในการจัดการงาน async — ส่งฟังก์ชันเข้าไป แล้ว runtime จะเรียกกลับเมื่อผลลัพธ์พร้อม

setTimeout(function() {
console.log('This runs after 1 second');
}, 1000);

อาร์กิวเมนต์ตัวที่สองของ setTimeout คือ callback คุณมอบการควบคุมให้ runtime แล้วฟังก์ชันของคุณจะถูกเรียกกลับเมื่อ timer ทำงาน

API ในตัวของ Node ใช้ signature ที่สม่ำเสมอสำหรับ callback:

const fs = require('node:fs');
fs.writeFileSync('data.txt', 'hello from data.txt');
fs.readFile('data.txt', 'utf8', function(err, data) {
if (err) {
console.error('Read failed:', err.message);
return;
}
console.log('File contents:', data);
});

prefix node: ระบุชัดว่าเป็น built-in module — ใช้แทน require('fs') ได้ทุกที่ และเป็นข้อบังคับ (ไม่ใช่แค่แนะนำ) สำหรับ built-in ใหม่บางตัวอย่าง node:test กับ node:sqlite

รูปแบบคือ (err, data) — error มาก่อนเสมอ ผลลัพธ์มาทีหลัง ถ้างานสำเร็จ err จะเป็น null ถ้าล้มเหลว err จะเป็นออบเจ็กต์ Error และ data จะเป็น undefined

ตรวจ err ก่อนใช้ data เสมอ ถ้าคุณเพิกเฉยต่อ err ที่ไม่เป็น null แล้วพยายามใช้ data คุณจะเจอ error ที่งงงวย

เมื่องาน async หนึ่งขึ้นอยู่กับผลของอีกงานหนึ่ง คุณต้องซ้อน callback:

function step1(callback) {
setTimeout(function() { callback(null, 'A'); }, 0);
}
function step2(input, callback) {
setTimeout(function() { callback(null, 'B'); }, 0);
}
function step3(input, callback) {
setTimeout(function() { callback(null, 'C'); }, 0);
}
function handleError(err) {
console.error('Error:', err.message);
}
step1(function(err, resultA) {
if (err) return handleError(err);
step2(resultA, function(err, resultB) {
if (err) return handleError(err);
step3(resultB, function(err, resultC) {
if (err) return handleError(err);
console.log('Final:', resultC);
});
});
});

การซ้อนลึกเช่นนี้ — บางทีเรียกว่า callback hell หรือ pyramid of doom — อ่านยากและผิดพลาดง่าย Promise และ async/await (ในบทถัดไป) ช่วยแก้ปัญหานี้

สนิปเป็ตด้านล่างจำลองสองขั้นตอน async ด้วย setTimeout แต่ละขั้นใช้รูปแบบ callback แบบ error-first ลองรันเพื่อดูลำดับผลลัพธ์

JavaScript

ผลลัพธ์จะเป็น Step 1 done: result-A แล้วตามด้วย Step 2 done: result-B เสมอ — แต่ละขั้นทำงานหลังขั้นก่อนหน้าทำเสร็จเท่านั้น

EventEmitter เกิดก่อน Promise แต่ตอนนี้ node:events มี helper สองตัวที่เปลี่ยน callback ของ event ให้เป็นโค้ด async สมัยใหม่โดยไม่ต้องเปลี่ยนวิธีทำงานของ EventEmitter เอง once(emitter, name) คืน Promise ที่ resolve ด้วยอาร์กิวเมนต์ของ event ตอนที่มันยิงครั้งแรก — มีประโยชน์เมื่อต้องการ “รอเหตุการณ์นี้ครั้งเดียว” แทนที่จะลงทะเบียนแล้วต้องมาถอด listener เองทีหลัง on(emitter, name) คืน async iterator ทำให้ event ที่ยิงซ้ำ ๆ ถูกอ่านได้ด้วย for await แทน callback ที่ทำงานตลอดไป:

Node.js
Output (verified on Node 24)
once() resolved with: payload-A
on() saw tick 1
on() saw tick 2
done

ทั้งสอง helper ยังใช้ EventEmitter ตัวเดิมอยู่ข้างใต้ — once/on แค่ห่อการลงทะเบียน callback ด้วย Promise หรือ async iterator เพื่อให้โค้ดที่เหลือ await มันได้แทนที่จะซ้อน handler

Callback แบบ error-first คือสัญญา ไม่ใช่คำแนะนำ: ฟังก์ชันที่คุณได้รับจะถูกเรียกครั้งเดียวเท่านั้น โดยมี err อยู่ในช่องแรกเสมอ ทุกอย่างที่ต่อจากนั้น — การ retry, การ log, ขั้นตอนถัดไปใน pipeline — ขึ้นอยู่กับว่าทุกจุดที่เรียกใช้ทำตามสัญญานี้ทุกครั้ง รวมถึงตอนที่มันไม่สะดวก (การ return ทันทีหลังจัดการ err แทนที่จะปล่อยให้ไหลต่อไปใช้ data) events.once/events.on ไม่ได้มาแทนสัญญานี้ มันแค่อยู่ทับข้างบน โดยเปลี่ยน “ลงทะเบียน callback แล้วอย่าลืมถอดออก” ให้กลายเป็น “await Promise” หรือ “iterate stream” สำหรับกรณีทั่วไปที่ต้องรอ event หนึ่งหรือหลายตัวของ EventEmitter

Callback ไม่มีกลไก compose ในตัว — การต่อ callback แบบ 3 ขั้นตอนหมายถึงการซ้อน 3 ระดับ, การจัดการ error แยกกัน 3 จุด และการลืม return แค่ครั้งเดียวก็ทำให้ callback ถูกเรียกซ้ำได้ นี่คือพีระมิดแห่งความหายนะที่ Promise และ async/await ถูกออกแบบมาให้แก้ (สองบทถัดไป) Callback ยังคงเป็นเครื่องมือที่ถูกต้องสำหรับสิ่งที่ยิง มากกว่าหนึ่งครั้ง ตามเวลา — event ของข้อมูลใน stream, timer ที่ยิงซ้ำ — ซึ่งโมเดล “settle ครั้งเดียว” ของ Promise ไม่เข้ากัน async iterator ของ events.on คือวิธีสมัยใหม่ในการอ่าน event ที่ยิงซ้ำแบบนี้โดยไม่ต้องเขียนขั้นตอน listener/cleanup เอง

  • “การลืม return หลังจัดการ err เป็นแค่เรื่อง style” มันคือ bug ที่ผิดจริง: ถ้าไม่มี return โค้ดจะไหลต่อไปใช้ data — ซึ่งเป็น undefined บน error path — มักทำให้เกิด TypeError ที่งงงวยและดูไม่เกี่ยวกับสาเหตุจริงเลย
  • “events.once กับ listener method .once() เป็นสิ่งเดียวกัน” เกี่ยวข้องกันแต่ต่างกัน: emitter.once(name, fn) ลงทะเบียน listener ที่ถอดตัวเองอัตโนมัติหลังยิงครั้งเดียว (ยังเป็นสไตล์ callback) ส่วน once(emitter, name) (import จาก node:events) คืน Promise สำหรับการรอครั้งเดียวแบบเดียวกัน
  • “API แบบ callback ช้าหรือแย่กว่า API แบบ Promise โดยธรรมชาติ” ประสิทธิภาพไม่ใช่ความต่าง — ergonomics ต่างหาก ทั้งคู่สุดท้ายก็คืนการควบคุมให้ event loop เหมือนกัน แค่ callback ต้องจัดการการซ้อน, การส่งต่อ error และ cleanup ด้วยมือเอง

โค้ดที่ AI สร้างเพื่อ “ทำให้ทันสมัย” มัก wrap ฟังก์ชันที่ return Promise อยู่แล้วด้วย new Promise(...) อีกชั้น — เป็นสัญชาตญาณตกค้างจากการแปลง callback API ที่ถูกเอามาใช้กับสิ่งที่ไม่เคยเป็น callback ตั้งแต่แรก

🐛 หา bug ในโค้ดนี้

function loadConfig() {
return new Promise(function (resolve, reject) {
fetchConfigFromApi().then(resolve, reject); // fetchConfigFromApi() already returns a Promise
});
}
เฉลย

fetchConfigFromApi() return Promise อยู่แล้ว การ wrap มันด้วย new Promise(...) จึงเพิ่มชั้นที่ไม่จำเป็น — นี่คือ “Promise constructor anti-pattern” (บางทีเรียก “deferred anti-pattern”) มันไม่ใช่แค่ซ้ำซ้อน: ถ้า executor ลืมต่อ reject (เช่น .then(resolve) ตัวเดียวไม่มีอาร์กิวเมนต์ error ซึ่งพิมพ์ผิดได้ง่าย) การ reject จาก fetchConfigFromApi() จะถูกกลืนไปเงียบ ๆ แทนที่จะส่งต่อ วิธีแก้คือตัด wrapper ทิ้งไปเลย — return fetchConfigFromApi(); — เพราะฟังก์ชันที่ return Promise อยู่แล้วไม่ต้องมี new Promise ครอบ

signature ที่ถูกต้องของ callback แบบ error-first ใน Node.js คือข้อใด?
ในรูปแบบ callback แบบ error-first เมื่อสำเร็จ err มีค่าเป็นอะไร?
"callback hell" คืออะไร?
โค้ดนี้พิมพ์อะไร? ถ้าไฟล์ไม่มีอยู่จริง จะเกิดอะไรขึ้น?
function readConfig(cb) {
  fs.readFile('config.json', 'utf8', function (err, data) {
    if (err) console.error(err); // missing return
    cb(null, JSON.parse(data));
  });
}
ทำไมการ wrap ฟังก์ชันที่ return Promise อยู่แล้วด้วย new Promise((resolve, reject) => fn().then(resolve, reject)) ถึงถือเป็น anti-pattern?