คุณเคยได้รับคำแนะนำว่า "อย่าบล็อกเทรดหลัก" และ "แบ่งงานที่ใช้เวลานาน" แต่การทำสิ่งเหล่านั้นหมายความว่าอย่างไร
เผยแพร่: 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 ฟังก์ชัน ระบบจะเรียกใช้งานเป็นส่วนหนึ่งของงานแบบ Monolithic ที่ยาวนาน ซึ่งจะบล็อกการตอบสนองด้วยภาพจนกว่าฟังก์ชันทั้ง 5 จะเสร็จสมบูรณ์
ในกรณีที่ดีที่สุด ฟังก์ชันดังกล่าวเพียงฟังก์ชันเดียวก็อาจทำให้ระยะเวลาทั้งหมดของงานเพิ่มขึ้น 50 มิลลิวินาทีหรือมากกว่านั้น ในกรณีที่แย่ที่สุด งานเหล่านั้นอาจใช้เวลานานขึ้นมาก โดยเฉพาะในอุปกรณ์ที่มีทรัพยากรจำกัด
ในกรณีนี้ saveSettings()
จะทริกเกอร์เมื่อผู้ใช้คลิก และเนื่องจากเบราว์เซอร์ไม่สามารถแสดงการตอบสนองได้จนกว่าฟังก์ชันทั้งหมดจะทำงานเสร็จสิ้น ผลลัพธ์ของ Long Task นี้จึงทำให้ UI ทำงานช้าและไม่ตอบสนอง และจะวัดผลเป็น Interaction to Next Paint (INP) ที่ไม่ดี
เลื่อนการเรียกใช้โค้ดด้วยตนเอง
หากต้องการให้งานที่สำคัญซึ่งผู้ใช้ต้องทำและคำตอบของ UI เกิดขึ้นก่อนงานที่มีลำดับความสำคัญต่ำกว่า คุณสามารถส่งต่อให้เธรดหลักได้โดยหยุดทำงานชั่วคราวเพื่อให้เบราว์เซอร์มีโอกาสรันงานที่สำคัญกว่า
วิธีหนึ่งที่นักพัฒนาซอฟต์แวร์ใช้ในการแบ่งงานออกเป็นงานย่อยๆ คือการใช้ setTimeout()
เทคนิคนี้จะช่วยให้คุณส่งฟังก์ชันไปยัง setTimeout()
ได้ ซึ่งจะเลื่อนการเรียกใช้แฮนเดิลไปยังงานอื่น แม้ว่าคุณจะระบุการหมดเวลาเป็น 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()
แล้วฟังก์ชันจะหยุดการดำเนินการชั่วคราว ณ จุดนั้นและส่งต่อให้เทรดหลัก ระบบจะกำหนดเวลาการดำเนินการฟังก์ชันที่เหลือ ซึ่งเรียกว่าการดำเนินการต่อของฟังก์ชัน ให้ทำงานในงาน Event Loop ใหม่ เมื่อเริ่มงานนั้น ระบบจะแก้ไข 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 จะจัดการการเปลี่ยนไปใช้ฟังก์ชันการจัดกำหนดการงานอื่นๆ เพื่อให้ทำงานได้คล้ายกันในเบราว์เซอร์ต่างๆ
หรือจะเขียนเวอร์ชันที่ซับซ้อนน้อยกว่าใน 2-3 บรรทัดโดยใช้เฉพาะ 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()
พร้อมใช้งาน หรือดำเนินการต่อ
ซึ่งทำได้ทั้งโดยการตรวจหาฟีเจอร์และการย้อนกลับไปรอ Microtask เดียวในบรรทัดเดียวที่สะดวก ดังนี้
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
แบ่งงานที่ใช้เวลานานด้วย scheduler.yield()
ข้อดีของการใช้วิธีใดก็ตามในการใช้ scheduler.yield()
คือคุณสามารถawait
ในฟังก์ชัน async
ใดก็ได้
เช่น หากมีอาร์เรย์ของงานที่ต้องเรียกใช้ซึ่งมักจะรวมกันเป็นงานที่ยาว คุณสามารถแทรกผลลัพธ์เพื่อแบ่งงานได้
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();
}
}
}
ผลลัพธ์คือ งานจะแบ่งออกเป็นส่วนๆ เพื่อไม่ให้ใช้เวลานานเกินไปในการเรียกใช้ แต่โปรแกรมเรียกใช้จะส่งต่อให้เทรดหลักทุกๆ 50 มิลลิวินาทีโดยประมาณ

อย่าใช้ isInputPending()
API isInputPending()
มีวิธีตรวจสอบว่าผู้ใช้พยายามโต้ตอบกับหน้าเว็บหรือไม่ และจะแสดงผลเฉพาะในกรณีที่มีการป้อนข้อมูลที่รอดำเนินการ
ซึ่งจะช่วยให้ JavaScript ทำงานต่อไปได้หากไม่มีอินพุตที่รอดำเนินการ แทนที่จะหยุดชั่วคราวและไปอยู่ท้ายคิวของงาน ซึ่งอาจส่งผลให้ประสิทธิภาพดีขึ้นอย่างน่าประทับใจ ดังที่ระบุไว้ในความตั้งใจที่จะเปิดตัว สำหรับเว็บไซต์ที่อาจไม่กลับไปที่เทรดหลัก
อย่างไรก็ตาม ตั้งแต่เปิดตัว API ดังกล่าว ความเข้าใจเรื่องการเพิ่มประสิทธิภาพรายได้ของเราก็เพิ่มขึ้น โดยเฉพาะอย่างยิ่งเมื่อมีการเปิดตัว INP เราไม่แนะนำให้ใช้ API นี้อีกต่อไป แต่ขอแนะนำให้ใช้ yield ไม่ว่าอินพุตจะรอดำเนินการหรือไม่ก็ตามด้วยเหตุผลหลายประการดังนี้
isInputPending()
อาจแสดงผลfalse
อย่างไม่ถูกต้องแม้ว่าผู้ใช้จะโต้ตอบในบางสถานการณ์ก็ตาม- อินพุตไม่ใช่กรณีเดียวที่งานควรให้ผลลัพธ์ ภาพเคลื่อนไหวและการอัปเดตอินเทอร์เฟซผู้ใช้แบบปกติอื่นๆ อาจมีความสำคัญไม่แพ้กันในการมอบหน้าเว็บที่ตอบสนอง
- ต่อมาเราได้เปิดตัว API การเพิ่มประสิทธิภาพที่ครอบคลุมมากขึ้น ซึ่งช่วยแก้ปัญหาข้อกังวลเกี่ยวกับการเพิ่มประสิทธิภาพ เช่น
scheduler.postTask()
และscheduler.yield()
บทสรุป
การจัดการงานเป็นเรื่องท้าทาย แต่การทำเช่นนี้จะช่วยให้หน้าเว็บตอบสนองต่อการโต้ตอบของผู้ใช้ได้เร็วขึ้น ไม่มีคำแนะนำเพียงอย่างเดียวสำหรับการจัดการและจัดลำดับความสำคัญของงาน แต่มีเทคนิคต่างๆ มากมาย ขอย้ำอีกครั้งว่าสิ่งสำคัญที่คุณควรพิจารณาเมื่อจัดการงานมีดังนี้
- หลีกทางให้ชุดข้อความหลักสำหรับงานที่สำคัญซึ่งผู้ใช้ต้องทำ
- ใช้
scheduler.yield()
(พร้อมการสำรองข้อมูลข้ามเบราว์เซอร์) เพื่อให้ได้ผลลัพธ์ตามหลักสรีรศาสตร์และรับการดำเนินการต่อที่มีลำดับความสำคัญสูง - สุดท้ายนี้ ทำงานในฟังก์ชันให้น้อยที่สุด
ดูข้อมูลเพิ่มเติมเกี่ยวกับ scheduler.yield()
, การตั้งเวลางานที่ชัดเจนที่เกี่ยวข้องกับ scheduler.postTask()
และการจัดลําดับความสําคัญของงานได้ที่เอกสารประกอบของ Prioritized Task Scheduling API
เครื่องมือเหล่านี้อย่างน้อย 1 อย่างจะช่วยให้คุณจัดโครงสร้างงานในแอปพลิเคชันได้ เพื่อให้ความสำคัญกับความต้องการของผู้ใช้ ในขณะเดียวกันก็มั่นใจได้ว่างานที่มีความสำคัญน้อยกว่าจะยังคงดำเนินการต่อไปได้ ซึ่งจะช่วยให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่ดีขึ้น มีการตอบสนองมากขึ้น และใช้งานได้สนุกยิ่งขึ้น
ขอขอบคุณเป็นพิเศษสำหรับPhilip Walton ที่ตรวจสอบทางเทคนิคของคู่มือนี้
ภาพปกจาก Unsplash โดยได้รับความอนุเคราะห์จาก Amirali Mirhashemian