Callbacks
รูปแบบ callback
หัวข้อที่มีชื่อว่า “รูปแบบ callback”Callback คือฟังก์ชันที่คุณส่งให้อีกฟังก์ชันหนึ่ง เพื่อให้ถูกเรียกภายหลังเมื่องานทำเสร็จ นี่คือวิธีดั้งเดิมของ JavaScript ในการจัดการงาน async — ส่งฟังก์ชันเข้าไป แล้ว runtime จะเรียกกลับเมื่อผลลัพธ์พร้อม
setTimeout(function() { console.log('This runs after 1 second');}, 1000);อาร์กิวเมนต์ตัวที่สองของ setTimeout คือ callback คุณมอบการควบคุมให้ runtime แล้วฟังก์ชันของคุณจะถูกเรียกกลับเมื่อ timer ทำงาน
ขนบ error-first
หัวข้อที่มีชื่อว่า “ขนบ error-first”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 ที่งงงวย
การซ้อน callback
หัวข้อที่มีชื่อว่า “การซ้อน callback”เมื่องาน 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 ลองรันเพื่อดูลำดับผลลัพธ์
ผลลัพธ์จะเป็น Step 1 done: result-A แล้วตามด้วย Step 2 done: result-B เสมอ — แต่ละขั้นทำงานหลังขั้นก่อนหน้าทำเสร็จเท่านั้น
รอ event โดยไม่ต้องซ้อน: events.once กับ events.on
หัวข้อที่มีชื่อว่า “รอ event โดยไม่ต้องซ้อน: events.once กับ events.on”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 ที่ทำงานตลอดไป:
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
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Callback แบบ error-first คือสัญญา ไม่ใช่คำแนะนำ: ฟังก์ชันที่คุณได้รับจะถูกเรียกครั้งเดียวเท่านั้น โดยมี err อยู่ในช่องแรกเสมอ ทุกอย่างที่ต่อจากนั้น — การ retry, การ log, ขั้นตอนถัดไปใน pipeline — ขึ้นอยู่กับว่าทุกจุดที่เรียกใช้ทำตามสัญญานี้ทุกครั้ง รวมถึงตอนที่มันไม่สะดวก (การ return ทันทีหลังจัดการ err แทนที่จะปล่อยให้ไหลต่อไปใช้ data) events.once/events.on ไม่ได้มาแทนสัญญานี้ มันแค่อยู่ทับข้างบน โดยเปลี่ยน “ลงทะเบียน callback แล้วอย่าลืมถอดออก” ให้กลายเป็น “await Promise” หรือ “iterate stream” สำหรับกรณีทั่วไปที่ต้องรอ event หนึ่งหรือหลายตัวของ EventEmitter
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”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 เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ 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 ครอบ