คุณอาจเคยได้ยินคำแนะนำที่ว่า "อย่าบล็อกเทรดหลัก" และ "แบ่งงานที่ใช้เวลานานออกเป็นส่วนๆ" แต่คำแนะนำเหล่านี้หมายความว่าอย่างไร
เผยแพร่เมื่อวันที่ 30 กันยายน 2022 อัปเดตล่าสุดเมื่อวันที่ 19 ธันวาคม 2024
คำแนะนำทั่วไปในการทำให้แอป JavaScript ทำงานได้อย่างรวดเร็วมักจะสรุปได้ดังนี้
- "อย่าบล็อกเทรดหลัก"
- "แบ่งงานที่ใช้เวลานานออกเป็นส่วนๆ"
นี่เป็นคำแนะนำที่ดี แต่ต้องทำอะไรบ้าง การส่ง JavaScript น้อยลงเป็นสิ่งที่ดี แต่จะทำให้ได้อินเทอร์เฟซผู้ใช้ที่ตอบสนองได้ดีขึ้นโดยอัตโนมัติหรือไม่ อาจจะใช่หรือไม่ใช่ก็ได้
หากต้องการทำความเข้าใจวิธีเพิ่มประสิทธิภาพงานใน JavaScript คุณต้องทราบก่อนว่างานคืออะไรและเบราว์เซอร์จัดการงานอย่างไร
งานคืออะไร
งานคือการทำงานที่แยกกันซึ่งเบราว์เซอร์ทำ งานดังกล่าวรวมถึงการแสดงผล การแยกวิเคราะห์ HTML และ CSS การเรียกใช้ JavaScript และงานประเภทอื่นๆ ที่คุณอาจควบคุมโดยตรงไม่ได้ จากทั้งหมดนี้ JavaScript ที่คุณเขียนอาจเป็นแหล่งที่มาของงานที่ใหญ่ที่สุด
click ซึ่งแสดงในเครื่องมือวิเคราะห์ประสิทธิภาพของเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome
งานที่เชื่อมโยงกับ JavaScript ส่งผลต่อประสิทธิภาพใน 2 ลักษณะดังนี้
- เมื่อเบราว์เซอร์ดาวน์โหลดไฟล์ JavaScript ระหว่างการเริ่มต้นระบบ เบราว์เซอร์จะจัดคิวงานเพื่อแยกวิเคราะห์และคอมไพล์ JavaScript นั้นเพื่อให้เรียกใช้ได้ในภายหลัง
- ในเวลาอื่นๆ ตลอดอายุของหน้าเว็บ ระบบจะจัดคิวงานเมื่อ JavaScript ทำงาน เช่น การตอบสนองต่อการโต้ตอบผ่านเครื่องจัดการเหตุการณ์ ภาพเคลื่อนไหวที่ขับเคลื่อนด้วย JavaScript และกิจกรรมเบื้องหลัง เช่น การรวบรวมข้อมูลวิเคราะห์
สิ่งเหล่านี้ทั้งหมด ยกเว้น Web Worker และ API ที่คล้ายกัน จะเกิดขึ้นในเทรดหลัก
เทรดหลักคืออะไร
เทรดหลักคือที่ที่งานส่วนใหญ่ทำงานในเบราว์เซอร์ และเป็นที่ที่ JavaScript เกือบทั้งหมดที่คุณเขียนจะทำงาน
เทรดหลักจะประมวลผลงานได้ครั้งละ 1 งานเท่านั้น งานที่ใช้เวลานานกว่า 50 มิลลิวินาทีคืองานที่ใช้เวลานาน สำหรับงานที่ใช้เวลาเกิน 50 มิลลิวินาที เวลาทั้งหมดของงานลบด้วย 50 มิลลิวินาทีจะเรียกว่าระยะเวลาการบล็อกของงาน
เบราว์เซอร์จะบล็อกการโต้ตอบไม่ให้เกิดขึ้นขณะที่งานที่มีความยาวเท่าใดก็ตามกำลังทำงานอยู่ แต่ผู้ใช้จะไม่สังเกตเห็นตราบใดที่งานไม่ได้ทำงานนานเกินไป อย่างไรก็ตาม เมื่อผู้ใช้พยายามโต้ตอบกับหน้าเว็บเมื่อมีงานที่ใช้เวลานานหลายงาน อินเทอร์เฟซผู้ใช้จะดูเหมือนไม่ตอบสนอง และอาจถึงขั้นใช้งานไม่ได้หากเทรดหลักถูกบล็อกเป็นเวลานานมาก
คุณสามารถแบ่งงานที่ใช้เวลานานออกเป็นงานเล็กๆ หลายงานเพื่อป้องกันไม่ให้เทรดหลักถูกบล็อกนานเกินไป
ซึ่งเป็นสิ่งสำคัญเนื่องจากเมื่อแบ่งงานออกเป็นส่วนๆ เบราว์เซอร์จะตอบสนองต่องานที่มีลำดับความสำคัญสูงกว่าได้เร็วขึ้นมาก ซึ่งรวมถึงการโต้ตอบของผู้ใช้ด้วย หลังจากนั้น งานที่เหลือจะทำงานจนเสร็จสมบูรณ์ เพื่อให้มั่นใจว่างานที่คุณจัดคิวไว้ในตอนแรกจะเสร็จสิ้น
ที่ด้านบนของภาพก่อนหน้า ตัวแฮนเดิลเหตุการณ์ที่จัดคิวโดยการโต้ตอบของผู้ใช้ต้องรอให้งานที่ใช้เวลานานเพียงงานเดียวเสร็จสิ้นก่อนจึงจะเริ่มได้ ซึ่งทำให้การโต้ตอบล่าช้า ในสถานการณ์นี้ ผู้ใช้อาจสังเกตเห็นความล่าช้า ที่ด้านล่าง ตัวแฮนเดิลเหตุการณ์จะเริ่มทำงานได้เร็วขึ้น และการโต้ตอบอาจให้ความรู้สึกทันที
เมื่อทราบแล้วว่าเหตุใดการแบ่งงานจึงสำคัญ คุณก็ดูวิธีดำเนินการใน JavaScript ได้
กลยุทธ์การจัดการงาน
คำแนะนำที่พบบ่อยในสถาปัตยกรรมซอฟต์แวร์คือการแบ่งงานออกเป็นฟังก์ชันย่อยๆ ดังนี้
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
ในตัวอย่างนี้ มีฟังก์ชันชื่อ saveSettings() ที่เรียกใช้ฟังก์ชัน 5 รายการเพื่อตรวจสอบแบบฟอร์ม แสดงภาพหมุน ส่งข้อมูลไปยังแบ็กเอนด์ของแอปพลิเคชัน อัปเดตอินเทอร์เฟซผู้ใช้ และส่งข้อมูลวิเคราะห์
ในเชิงแนวคิด saveSettings() มีสถาปัตยกรรมที่ดี หากต้องการแก้ไขข้อบกพร่องของฟังก์ชันเหล่านี้ คุณสามารถไปยังส่วนต่างๆ ของโครงสร้างโปรเจ็กต์เพื่อดูว่าแต่ละฟังก์ชันทำอะไรได้บ้าง การแบ่งงานในลักษณะนี้จะช่วยให้จัดการและดูแลรักษาโปรเจ็กต์ได้ง่ายขึ้น
อย่างไรก็ตาม ปัญหาที่อาจเกิดขึ้นคือ JavaScript ไม่ได้เรียกใช้ฟังก์ชันแต่ละรายการเหล่านี้เป็นงานแยกกัน เนื่องจากฟังก์ชันเหล่านี้จะทำงานภายในฟังก์ชัน saveSettings() ซึ่งหมายความว่าทั้ง 5 ฟังก์ชันจะทำงานเป็นงานเดียว
saveSettings() ที่เรียกใช้ 5 ฟังก์ชัน ระบบจะเรียกใช้การทำงานเป็นส่วนหนึ่งของงานขนาดใหญ่ที่ใช้เวลานานงานเดียว ซึ่งจะบล็อกการตอบสนองแบบภาพจนกว่าฟังก์ชันทั้ง 5 จะทำงานเสร็จสมบูรณ์
ในกรณีที่ดีที่สุด แม้แต่ฟังก์ชันเดียวก็อาจเพิ่มเวลา 50 มิลลิวินาทีขึ้นไปให้กับความยาวทั้งหมดของงาน ในกรณีที่แย่ที่สุด งานเหล่านั้นอาจใช้เวลานานขึ้นมาก โดยเฉพาะในอุปกรณ์ที่มีทรัพยากรจำกัด
ในกรณีนี้ saveSettings() จะทริกเกอร์โดยการคลิกของผู้ใช้ และเนื่องจากเบราว์เซอร์ไม่สามารถแสดงการตอบสนองจนกว่าฟังก์ชันทั้งหมดจะทำงานเสร็จ ผลลัพธ์ของงานที่ใช้เวลานานนี้คือ UI ที่ช้าและไม่ตอบสนอง และจะวัดเป็น Interaction to Next Paint (INP) ที่ไม่ดี
เลื่อนการเรียกใช้โค้ดด้วยตนเอง
คุณสามารถยอมให้เธรดหลักทำงานก่อนได้โดยการหยุดงานไว้ชั่วคราวเพื่อให้เบราว์เซอร์มีโอกาสเรียกใช้งานที่สำคัญกว่า เพื่อให้มั่นใจว่างานที่แสดงต่อผู้ใช้และการตอบสนองของ UI ที่สำคัญจะเกิดขึ้นก่อนงานที่มีลำดับความสำคัญต่ำกว่า
วิธีหนึ่งที่นักพัฒนาแอปใช้เพื่อแบ่งงานออกเป็นงานย่อยๆ คือ setTimeout() เทคนิคนี้จะส่งฟังก์ชันไปยัง setTimeout() ซึ่งจะเลื่อนการดำเนินการของ Callback ไปยังงานแยกต่างหาก แม้ว่าคุณจะระบุระยะหมดเวลาเป็น 0 ก็ตาม
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
ซึ่งเรียกว่าการยอมให้ทำงาน และวิธีนี้เหมาะที่สุดสำหรับชุดฟังก์ชันที่ต้องทำงานตามลำดับ
อย่างไรก็ตาม โค้ดของคุณอาจไม่ได้จัดระเบียบในลักษณะนี้เสมอไป เช่น คุณอาจมีข้อมูลจำนวนมากที่ต้องประมวลผลในลูป และงานนั้นอาจใช้เวลานานมากหากมีการทำซ้ำหลายครั้ง
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
การใช้ setTimeout() ที่นี่เป็นปัญหาเนื่องจากหลักสรีรศาสตร์ของนักพัฒนาซอฟต์แวร์ และหลังจาก setTimeout() ที่ซ้อนกัน 5 รอบ เบราว์เซอร์จะเริ่มกำหนดความล่าช้าขั้นต่ำ 5 มิลลิวินาทีสำหรับ setTimeout() เพิ่มเติมแต่ละรายการ
setTimeout ยังมีข้อเสียอีกอย่างหนึ่งเมื่อพูดถึงการส่งต่อ นั่นคือเมื่อคุณส่งต่อไปยังเธรดหลักโดยเลื่อนโค้ดให้ทำงานในงานถัดไปโดยใช้ setTimeout ระบบจะเพิ่มงานนั้นลงในส่วนท้ายของคิว หากมีงานอื่นๆ ที่รอดำเนินการอยู่ งานเหล่านั้นจะทำงานก่อนโค้ดที่เลื่อนเวลาออกไป
API การยอมให้ทำงานโดยเฉพาะ: scheduler.yield()
scheduler.yield() เป็น API ที่ออกแบบมาเพื่อส่งต่อการควบคุมไปยังเทรดหลักในเบราว์เซอร์โดยเฉพาะ
ซึ่งไม่ใช่ไวยากรณ์ระดับภาษาหรือโครงสร้างพิเศษ scheduler.yield() เป็นเพียงฟังก์ชันที่แสดงผล Promise ซึ่งจะได้รับการแก้ไขในงานในอนาคต โค้ดที่เชื่อมโยงให้ทำงานหลังจากที่ Promise ได้รับการแก้ไข (ไม่ว่าจะอยู่ในเชน .then() ที่ชัดเจนหรือหลังจาก await ในฟังก์ชันแบบไม่พร้อมกัน) จะทำงานในงานในอนาคตนั้น
ในทางปฏิบัติ: แทรก await scheduler.yield() แล้วฟังก์ชันจะหยุดการดำเนินการชั่วคราว ณ จุดนั้นและส่งต่อไปยังเทรดหลัก การดำเนินการส่วนที่เหลือของฟังก์ชัน ซึ่งเรียกว่าความต่อเนื่องของฟังก์ชัน จะได้รับการกำหนดเวลาให้ทำงานในงานวนรอบเหตุการณ์ใหม่ เมื่องานนั้นเริ่มขึ้น ระบบจะแก้ไข Promise ที่รออยู่ และฟังก์ชันจะดำเนินการต่อจากจุดที่หยุดไว้
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
saveSettings() จะแบ่งออกเป็น 2 งาน ด้วยเหตุนี้ เลย์เอาต์และการแสดงผลจึงทำงานระหว่างงานได้ ซึ่งช่วยให้ผู้ใช้ได้รับการตอบสนองด้วยภาพที่รวดเร็วขึ้น โดยวัดจากการโต้ตอบกับตัวชี้ที่สั้นลงมากในตอนนี้
อย่างไรก็ตาม ประโยชน์ที่แท้จริงของ scheduler.yield() เหนือแนวทางการให้สิทธิ์อื่นๆ คือการให้ความสำคัญกับความต่อเนื่อง ซึ่งหมายความว่าหากคุณให้สิทธิ์ในระหว่างงาน ความต่อเนื่องของงานปัจจุบันจะทำงานก่อนที่จะเริ่มงานอื่นๆ ที่คล้ายกัน
ซึ่งจะช่วยป้องกันไม่ให้โค้ดจากแหล่งที่มาของงานอื่นๆ ขัดจังหวะลำดับการดำเนินการของโค้ด เช่น งานจากสคริปต์ของบุคคลที่สาม
scheduler.yield() การสนทนาต่อจะเริ่มจากจุดที่ค้างไว้ก่อนที่จะไปยังงานอื่นๆ
การรองรับข้ามเบราว์เซอร์
เบราว์เซอร์บางประเภทยังไม่รองรับ scheduler.yield() จึงจำเป็นต้องมีทางเลือกสำรอง
วิธีแก้ปัญหาหนึ่งคือการวาง scheduler-polyfill ลงในบิลด์ จากนั้นจะใช้ scheduler.yield() ได้โดยตรง โดย Polyfill จะจัดการการกลับไปใช้ฟังก์ชันการจัดกำหนดการงานอื่นๆ เพื่อให้ทำงานในลักษณะเดียวกันในเบราว์เซอร์ต่างๆ
หรือจะเขียนเวอร์ชันที่ซับซ้อนน้อยกว่าในโค้ดไม่กี่บรรทัดก็ได้ โดยใช้เพียง setTimeout ที่รวมอยู่ใน Promise เป็นทางเลือกสำรองหากไม่มี scheduler.yield()
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
แม้ว่าเบราว์เซอร์ที่ไม่มีการรองรับ scheduler.yield() จะไม่ได้รับการจัดลำดับความสำคัญให้ทำงานต่อ แต่เบราว์เซอร์จะยังคงทำงานต่อไปเพื่อตอบสนองต่อผู้ใช้
สุดท้าย อาจมีบางกรณีที่โค้ดของคุณไม่สามารถยอมให้เทรดหลักทำงานได้หากการดำเนินการต่อไม่ได้รับการจัดลำดับความสำคัญ (เช่น หน้าเว็บที่ทราบว่ามีงานยุ่งซึ่งการยอมให้เทรดหลักทำงานมีความเสี่ยงที่จะทำงานไม่เสร็จเป็นระยะเวลาหนึ่ง) ในกรณีนี้ scheduler.yield() อาจถือเป็นการเพิ่มประสิทธิภาพแบบต่อเนื่องประเภทหนึ่ง นั่นคือให้ผลลัพธ์ในเบราว์เซอร์ที่ scheduler.yield() พร้อมใช้งาน ไม่เช่นนั้นก็ดำเนินการต่อ
ซึ่งทำได้ทั้งการตรวจหาฟีเจอร์และการกลับไปรอไมโครทาสก์เดียวในบรรทัดเดียวที่สะดวก
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
แบ่งงานที่ใช้เวลานานด้วย scheduler.yield()
ประโยชน์ของการใช้วิธีการเหล่านี้ในการใช้ scheduler.yield() คือคุณสามารถawaitในฟังก์ชัน async ใดก็ได้
เช่น หากคุณมีอาร์เรย์ของงานที่ต้องเรียกใช้ซึ่งมักจะรวมกันเป็นงานที่ใช้เวลานาน คุณสามารถแทรก Yield เพื่อแบ่งงานออกเป็นส่วนๆ ได้
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
ระบบจะจัดลำดับความสำคัญของการดำเนินการต่อของ runJobs() แต่ยังคงอนุญาตให้งานที่มีลำดับความสำคัญสูงกว่า เช่น การตอบสนองต่ออินพุตของผู้ใช้ด้วยภาพ ทำงานได้ โดยไม่ต้องรอให้รายการงานที่อาจใช้เวลานานเสร็จสิ้น
อย่างไรก็ตาม วิธีนี้ไม่ใช่การใช้การให้สิทธิ์อย่างมีประสิทธิภาพ scheduler.yield() เป็นวิธีที่รวดเร็วและมีประสิทธิภาพ แต่ก็มีค่าใช้จ่ายเพิ่มเติมบางอย่าง หากงานบางอย่างใน jobQueue สั้นมาก ค่าใช้จ่ายในการดำเนินการอาจเพิ่มขึ้นอย่างรวดเร็วเป็นเวลาที่ใช้ในการยอมให้ใช้ทรัพยากรและกลับมาทำงานต่อมากกว่าการทำงานจริง
วิธีหนึ่งคือการจัดกลุ่มงาน โดยจะยอมให้งานอื่นทำงานได้ก็ต่อเมื่อผ่านไปนานพอแล้วนับตั้งแต่การยอมให้งานอื่นทำงานครั้งล่าสุด กำหนดเวลาทั่วไปคือ 50 มิลลิวินาทีเพื่อพยายามป้องกันไม่ให้งานกลายเป็นงานที่ใช้เวลานาน แต่สามารถปรับเปลี่ยนได้เพื่อแลกกับความสามารถในการตอบสนองและเวลาในการดำเนินการคิวงานให้เสร็จสมบูรณ์
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
ผลลัพธ์ที่ได้คือระบบจะแบ่งงานออกเป็นส่วนๆ เพื่อไม่ให้ใช้เวลาในการรันนานเกินไป แต่ Runner จะยอมให้เทรดหลักทำงานทุกๆ 50 มิลลิวินาทีเท่านั้น
ไม่ใช้ isInputPending()
isInputPending() API มีวิธีตรวจสอบว่าผู้ใช้พยายามโต้ตอบกับหน้าเว็บหรือไม่ และจะยอมรับก็ต่อเมื่อมีอินพุตที่รอดำเนินการอยู่
ซึ่งจะช่วยให้ JavaScript ทำงานต่อไปได้หากไม่มีอินพุตที่รอดำเนินการ แทนที่จะหยุดทำงานและไปต่อท้ายคิวงาน ซึ่งอาจส่งผลให้ประสิทธิภาพดีขึ้นอย่างน่าประทับใจ ดังที่อธิบายไว้ใน Intent to Ship สำหรับเว็บไซต์ที่อาจไม่ส่งกลับไปยังเทรดหลัก
อย่างไรก็ตาม นับตั้งแต่เปิดตัว API ดังกล่าว เรามีความเข้าใจเกี่ยวกับการยอมให้ทำงานมากขึ้น โดยเฉพาะอย่างยิ่งเมื่อมีการเปิดตัว INP เราไม่แนะนำให้ใช้ API นี้อีกต่อไป และแนะนำให้ใช้ Yield ไม่ว่าอินพุตจะรอดำเนินการหรือไม่ก็ตามด้วยเหตุผลหลายประการดังนี้
isInputPending()อาจแสดงผลfalseอย่างไม่ถูกต้องแม้ว่าผู้ใช้จะโต้ตอบในบางสถานการณ์ก็ตาม- อินพุตไม่ใช่กรณีเดียวที่งานควรยอมให้ดำเนินการ ภาพเคลื่อนไหวและการอัปเดตอินเทอร์เฟซผู้ใช้ตามปกติอื่นๆ อาจมีความสำคัญไม่แพ้กันในการสร้างหน้าเว็บที่ตอบสนองได้ดี
- ตั้งแต่นั้นมา มีการเปิดตัว API การสร้างผลลัพธ์ที่ครอบคลุมมากขึ้นซึ่งช่วยแก้ปัญหาเกี่ยวกับผลลัพธ์ เช่น
scheduler.postTask()และscheduler.yield()
บทสรุป
การจัดการงานเป็นเรื่องที่ท้าทาย แต่การทำเช่นนี้จะช่วยให้หน้าเว็บตอบสนองต่อการโต้ตอบของผู้ใช้ได้รวดเร็วยิ่งขึ้น ไม่มีคำแนะนำเดียวสำหรับการจัดการและจัดลำดับความสำคัญของงาน แต่มีเทคนิคต่างๆ มากมาย ขอย้ำอีกครั้งว่าสิ่งสำคัญที่คุณควรพิจารณาเมื่อจัดการงานมีดังนี้
- ส่งต่อให้เทรดหลักสำหรับงานสำคัญที่แสดงต่อผู้ใช้
- ใช้
scheduler.yield()(พร้อมการสำรองข้อมูลข้ามเบราว์เซอร์) เพื่อให้ได้ผลลัพธ์ตามหลักสรีรศาสตร์และได้รับการดำเนินการต่อตามลำดับความสำคัญ - สุดท้าย ให้ทำงานในฟังก์ชันให้น้อยที่สุด
ดูข้อมูลเพิ่มเติมเกี่ยวกับ scheduler.yield(), scheduler.postTask() ที่เกี่ยวข้องกับการจัดตารางเวลางานอย่างชัดเจน และการจัดลำดับความสำคัญของงานได้ที่เอกสารประกอบเกี่ยวกับ Prioritized Task Scheduling API
เมื่อใช้เครื่องมือเหล่านี้อย่างน้อย 1 รายการ คุณควรจัดโครงสร้างงานในแอปพลิเคชันเพื่อให้ความสำคัญกับความต้องการของผู้ใช้เป็นอันดับแรก ขณะเดียวกันก็มั่นใจได้ว่างานที่มีความสำคัญน้อยกว่าจะยังคงเสร็จสมบูรณ์ ซึ่งจะช่วยสร้างประสบการณ์ของผู้ใช้ที่ดีขึ้นซึ่งตอบสนองได้ดีขึ้นและใช้งานได้สนุกยิ่งขึ้น
ขอขอบคุณ Philip Walton เป็นพิเศษสำหรับการตรวจสอบทางเทคนิคของคู่มือนี้