ไวยากรณ์ async/await ของ JavaScript ถูกสร้างมาเพื่อช่วยให้เราพ้นจาก callback hell แต่แทนที่จะเป็นอย่างนั้น มันกลับนำมาซึ่งปัญหาที่เงียบเชียบและร้ายกาจกว่าเดิม นั่นคือโค้ดที่ดูเหมือนจะถูกต้องแต่กลับทำงานอย่างไม่คาดคิด เมื่อคุณเห็น await อยู่ในฟังก์ชัน คุณมักจะทึกทักเอาเองว่าทุกอย่างจะหยุดรออย่างเรียบร้อยทีละบรรทัด แต่บ่อยครั้งมันไม่ได้เป็นแบบนั้น ลูปทำงานข้ามขั้นตอนไปข้างหน้า ชุดข้อมูลทั้งชุดพังทลายลงเพียงเพราะคำขอ (request) เดียวล้มเหลว หรือไฟล์ entry point ก็เต็มไปด้วย async wrapper ที่ดูเกะกะโดยไม่จำเป็น หากคุณเคยเจอปัญหาเหล่านี้ รูปแบบ (patterns) ทั้งสามนี้จะช่วยจัดการให้ทุกอย่างเข้าที่เข้าทาง
เลิกใช้ await ภายใน forEach
นี่คือข้อผิดพลาดที่พบบ่อยซึ่งดูเหมือนจะไม่เป็นอันตรายเมื่อดูผ่านๆ:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
เมื่อรันโค้ดนี้ 'All done!' จะถูกพิมพ์ออกมาก่อนที่คำตอบ (response) แรกจะส่งกลับมาเสียอีก เพราะอะไรน่ะหรือ? เพราะ forEach จะรัน callback สำหรับทุก element ทันที โดยไม่รอ promise ที่อยู่ภายในแต่ละรอบการทำงาน (iteration) คำสำคัญ async จะเปลี่ยนแต่ละ callback ให้กลายเป็น promise ซึ่ง forEach จะเพิกเฉยไปทันที ลูปของคุณจะทำงานเสร็จสิ้นภายในเวลาไม่กี่ไมโครวินาที ในขณะที่ network requests ต่างก็แยกย้ายกันทำงานไปตามยถากรรม หากคุณต้องการจัดการ error ตามลำดับ หรือต้องการรับประกันว่า request หนึ่งจะเสร็จสิ้นก่อนที่อีก request จะเริ่ม รูปแบบนี้จะทำลายการรับประกันทั้งสองอย่างโดยที่คุณไม่รู้ตัว
ให้เปลี่ยนไปใช้ลูป for...of แทน:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
ตอนนี้ลูปจะหยุดรอที่ await แต่ละจุดจริงๆ โดย request ที่สองจะรอ request แรก และ 'All done!' จะพิมพ์ออกมาก็ต่อเมื่อทุกอย่างเสร็จสิ้นแล้วเท่านั้น
ใช้ for...of เมื่อลำดับมีความสำคัญ เช่น การอัปโหลดไฟล์ทีละไฟล์เพื่อไม่ให้เกิน rate limits, การเขียนข้อมูลลง database ตามลำดับที่กำหนด หรือการทำ API calls แบบต่อเนื่องที่ request ถัดไปต้องใช้ข้อมูลจาก response ก่อนหน้า หากคุณต้องการทำงานแบบขนาน (parallel execution) จริงๆ อย่าพยายามฝืนใช้ forEach แต่ให้ใช้ Promise.all อย่างชัดเจน เพื่อให้ผู้พัฒนาคนถัดไปเข้าใจเจตนาของคุณ แต่อย่าผสม await เข้ากับ forEach โดยหวังว่าจะได้พฤติกรรมแบบ synchronous เพราะมันไม่มีทางเกิดขึ้นได้
เลือกใช้ Promise.allSettled เมื่อ "ศูนย์" ไม่ใช่คำตอบที่ต้องการ
Promise.all นั้นทำงานตรงไปตรงมาตามความหมายของมัน เมื่อคุณส่ง array ของ promises ให้มัน มันจะคืนค่าเป็น array ของผลลัพธ์ แต่ประเด็นคือ ทันทีที่มี promise ใดก็ตาม reject ทั้งหมดจะ reject ทันที promise อื่นๆ ที่ยังค้างอยู่ (pending) จะถูกปล่อยให้ทำงานต่อไปเอง แต่คุณจะสูญเสียการเข้าถึงผลลัพธ์ของพวกมันไป ในสภาพแวดล้อมการทำงานจริง (production) พฤติกรรมแบบ "ได้ทั้งหมดหรือไม่ได้เลย" (all-or-nothing) แบบนี้สร้างปัญหาอย่างมาก
ลองจินตนาการว่าแอปพลิเคชันของคุณดึงข้อมูล widget ของ dashboard จาก 4 บริการที่แยกจากกัน ได้แก่ traffic analytics, revenue data, user feedback และ server health หาก revenue API เกิด timeout ขึ้นมาเพียงชั่วครู่ ภายใต้ Promise.all dashboard ทั้งหมดของคุณจะแสดง error ทันที ผลลัพธ์ที่ถูกต้องจากอีก 3 บริการที่เหลือจะหายวับไปในอากาศ ผู้ใช้จะเห็นแค่ตัวหมุนโหลด (spinner) แล้วตามด้วยหน้าจอแสดงความล้มเหลว เพียงเพราะข้อมูลแค่ 1 ใน 4 ส่วนทำงานผิดพลาด
Promise.allSettled มอบข้อตกลงที่สมเหตุสมผลกว่า มันจะรอจนกว่าทุก promise จะทำงานเสร็จสิ้น ไม่ว่าจะสำเร็จหรือล้มเหลวก็ตาม โดยค่าที่คืนมาจะเป็น array ของ object ที่อธิบายผลลัพธ์ของแต่ละรายการ:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
จะไม่มี response ใดถูกทิ้งไป คุณสามารถแสดงผลเท่าที่ทำได้และแยกส่วนที่ล้มเหลวออกมา รูปแบบนี้สำคัญมากเมื่อคุณต้องจัดการกับงานที่ไม่เกี่ยวข้องกัน เช่น การส่ง bulk notifications, การส่ง webhook ไปยัง third-party หรือการนำเข้าข้อมูลจาก CSV หลายๆ streams แม้คุณยังคงต้องมีการติดตาม error แบบรวมศูนย์ (centralized error tracking) แต่แอปพลิเคชันของคุณจะยังคงทำงานต่อไปได้โดยไม่ล่ม
ข้อควรระวังในทางปฏิบัติ: allSettled จะคืนค่ามาให้ครบทุกรายการ ดังนั้นคุณยังต้องคัดกรองผลลัพธ์และตัดสินใจว่า "ความสำเร็จบางส่วน" (partial success) หมายถึงอะไรสำหรับฟีเจอร์ของคุณ อย่าปฏิบัติกับ array ที่ได้มาเหมือนเป็นข้อมูลที่สำเร็จทั้งหมด (uniformly happy data) ควรตรวจสอบฟิลด์ status ก่อนที่จะนำข้อมูลใดๆ เข้าสู่ state layer ของคุณ
ประกาศ Top-Level await และเลิกใช้ Wrapper IIFE
เป็นเวลาหลายปีที่หากคุณต้องการ await อะไรบางอย่างที่ระดับ root ของไฟล์ คุณต้องห่อมันไว้ใน async function ที่เรียกใช้งานทันที (immediately invoked async function):
(async () => {
const config = await loadConfig();
startServer(config);
})();
วิธีนี้ใช้งานได้ แต่มันสร้างความรกรุงรัง (noise) โดย Top-level await ซึ่งเป็นฟีเจอร์พื้นฐานใน ES modules จะช่วยให้คุณตัดโค้ดส่วนเกิน (boilerplate) ที่ไม่จำเป็นออกไปได้:
const config = await loadConfig();
startServer(config);
ใช้สิ่งนี้ที่ entry point ของแอปพลิเคชัน หรือในโมดูลการตั้งค่า (configuration modules) ที่ต้องรอให้การเริ่มต้นระบบ (initialization) เสร็จสิ้นก่อนที่อย่างอื่นจะเริ่มทำงาน เช่น การโหลด environment files, การสร้าง database connection pool หรือการดึง remote feature flags ทั้งหมดนี้ล้วนเหมาะสมอย่างยิ่ง เนื่องจาก top-level await จะบล็อกการทำงานของ module graph (ไฟล์อื่นๆ ที่ import ไฟล์นี้จะรอจนกว่า promise ของคุณจะ resolve) ทำให้คุณมั่นใจได้ในสถานะของระบบ โค้ดส่วนที่เหลือของคุณสามารถ import db และมั่นใจได้ว่าการเชื่อมต่อพร้อมใช้งานแล้ว
มีข้อควรระวังอยู่สองประการ ประการแรก runtime หรือ bundler ของคุณต้องรองรับ ES modules ใน Node.js นั่นหมายถึงการใช้ส่วนขยาย .mjs หรือการตั้งค่า "type": "module" ใน package.json ประการที่สอง เนื่องจากความล่าช้าในระดับ module จะส่งผลกระทบต่อทุกตัวที่ทำการ import ดังนั้นควรจำกัดขอบเขตของงานที่ใช้ await ให้ชัดเจน การทำ sequential fetches ที่หนักหน่วงไว้ที่ส่วนบนสุดของ utility file ที่ถูก import บ่อยๆ จะทำให้การ cold start ของแอปพลิเคชันทั้งหมดช้าลง ควรสงวน top-level await ไว้สำหรับงาน bootstrap ที่แท้จริงซึ่ง module อื่นๆ จำเป็นต้องพึ่งพาอย่างยิ่งเท่านั้น
สิ่งที่จะเปลี่ยนไปจริงๆ เมื่อคุณนำรูปแบบเหล่านี้ไปใช้
ความสามารถในการคาดเดาได้ (Predictability) คือผลตอบแทนแรก เมื่อคุณอ่าน loop แบบ for...of คุณจะรู้ได้ทันทีว่า block ด้านล่างจะทำงานเสร็จสิ้นเมื่อใด จะไม่มี ghost promises ที่ทำงานซ้อนอยู่เบื้องหลัง และไม่มี callback ของ foreach ที่หลุดออกจาก error handlers ของคุณ ลำดับการควบคุม (control flow) ของคุณจะสอดคล้องกับรูปแบบของโค้ดที่ปรากฏบนหน้าจอ
ความยืดหยุ่น (Resilience) คือสิ่งถัดมา Promise.allSettled บังคับให้คุณต้องคิดถึงความล้มเหลวบางส่วน (partial failure) แทนที่จะหวังให้ทุกระบบภายนอกทำงานได้อย่างสมบูรณ์แบบเสมอไป ซอฟต์แวร์ที่ใช้งานจริง (Production) ไม่ได้มีแค่สองสถานะ (binary) บาง endpoint อาจจะทำงานไม่เสถียร (flake) การอ่านไฟล์บางอย่างอาจติดปัญหาเรื่องสิทธิ์การเข้าถึง (permission errors) การออกแบบโดยคำนึงถึงความเป็นจริงของความล้มเหลวที่เกิดขึ้นเป็นจุดๆ จะช่วยให้แอปพลิเคชันของคุณยังคงทำงานต่อไปได้โดยไม่ต้องละเลยข้อมูลที่สำคัญไป
ความชัดเจน (Clarity) คือสิ่งที่เชื่อมโยงทุกอย่างเข้าด้วยกัน for...of อ่านแล้วให้ความรู้สึกเหมือนการดำเนินเรื่องตามลำดับภาษาอังกฤษทั่วไป allSettled ก็ระบุเจตนาของมันไว้ในชื่ออยู่แล้ว ส่วน top-level await ก็ช่วยกำจัด wrapper แบบ IIFE ที่ดูซับซ้อนออกไป ทำให้ไฟล์ entry ของคุณเริ่มต้นด้วย business logic แทนที่จะเป็นลูกเล่นทางไวยากรณ์ (syntactic acrobatics) วิศวกรคนถัดไปที่มาแตะไฟล์นี้—ไม่ว่าจะเป็นตัวคุณเองในอีกหกเดือนข้างหน้า หรือเพื่อนร่วมทีมที่กำลังเร่งทำงานตามกำหนดการ—จะขอบคุณคุณอย่างแน่นอน
บทสรุปที่นำไปใช้ได้จริง
อย่าปฏิบัติกับ async/await เหมือนเป็นยาครอบจักรวาลที่คุณจะเอาไปโรยไว้บนโค้ดเดิมที่มีอยู่ ให้ตรวจสอบโปรเจกต์ปัจจุบันของคุณเพื่อหา anti-patterns เฉพาะเจาะจงสามประการนี้ ลองค้นหา await ที่อยู่ภายใน block ของ forEach แล้วแทนที่ด้วย for...of หรือการใช้ Promise.all อย่างตั้งใจ ตรวจสอบ Promise.all ทุกตัวที่ติดต่อกับบริการภายนอก และถามตัวเองว่าความล้มเหลวเพียงจุดเดียวควรจะทำให้การทำงานทั้งหมดพังพินาศ (torpedo) จริงๆ หรือไม่ หากไม่ ควรเปลี่ยนไปใช้ Promise.allSettled และจัดการกับผลลัพธ์ที่ผสมผสานกัน สุดท้าย ให้กำจัด async IIFEs ออกจาก entry points ของ ES module ของคุณ และปล่อยให้ top-level await จัดการลำดับขั้นตอนการ bootstrap โดยตรง สิ่งเหล่านี้เป็นการเปลี่ยนแปลงทางเทคนิคเล็กๆ น้อยๆ แต่เมื่อรวมกันแล้ว มันจะเปลี่ยนสคริปต์ asynchronous ที่เปราะบางให้กลายเป็นโค้ดที่คุณสามารถไว้วางใจได้จริงๆ
