คุณเคยได้ยินว่า "อย่าบล็อกเธรดหลัก" และ "แบ่งงานที่มีระยะเวลานาน" แต่การทําเช่นนั้นหมายความว่าอย่างไร
เผยแพร่: 30 กันยายน 2022 อัปเดตล่าสุด: 19 ธันวาคม 2024
คำแนะนำทั่วไปในการทำให้แอป JavaScript ทำงานได้อย่างรวดเร็วมักจะสรุปเป็นคำแนะนำต่อไปนี้
- "อย่าบล็อกชุดข้อความหลัก"
- "แบ่งงานใหญ่ออกเป็นงานย่อยๆ"
นี่เป็นคำแนะนำที่ดีมาก แต่ต้องทํางานอะไรบ้าง การส่ง JavaScript น้อยลงนั้นเป็นเรื่องที่ดี แต่นั่นหมายความว่าอินเทอร์เฟซผู้ใช้จะตอบสนองได้ดีขึ้นโดยอัตโนมัติไหม อาจจะเป็นไปได้หรืออาจจะไม่
หากต้องการทำความเข้าใจวิธีเพิ่มประสิทธิภาพงานใน JavaScript ก่อนอื่นคุณต้องทราบว่างานคืออะไร และเบราว์เซอร์จัดการงานอย่างไร
งานคืออะไร
งานคืองานย่อยๆ ที่เบราว์เซอร์ทำ ซึ่งรวมถึงการแสดงผล การแยกวิเคราะห์ HTML และ CSS การรัน JavaScript และงานประเภทอื่นๆ ที่คุณอาจไม่สามารถควบคุมได้โดยตรง งานนี้ JavaScript ที่คุณเขียนอาจเป็นแหล่งที่มาของงานมากที่สุด
งานที่เกี่ยวข้องกับ 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 รายการจะทํางานเป็นงานเดียว
ในกรณีที่ดีที่สุด ฟังก์ชันเพียงฟังก์ชันเดียวอาจทำให้งานใช้เวลานานขึ้น 50 มิลลิวินาทีหรือมากกว่านั้น ในกรณีที่แย่ที่สุด งานเหล่านี้อาจทำงานได้นานขึ้นมาก โดยเฉพาะในอุปกรณ์ที่มีทรัพยากรจํากัด
ในกรณีนี้ saveSettings()
จะทริกเกอร์โดยการคลิกของผู้ใช้ และเนื่องจากเบราว์เซอร์ไม่สามารถแสดงการตอบกลับจนกว่าฟังก์ชันทั้งหมดจะทำงานเสร็จสิ้น ผลลัพธ์ของงานที่ใช้เวลานานนี้คือ 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()
แล้วฟังก์ชันจะหยุดการดําเนินการชั่วคราว ณ จุดนั้นและส่งมอบการควบคุมไปยังเธรดหลัก ระบบจะกำหนดเวลาให้การดำเนินการของฟังก์ชันที่เหลือซึ่งเรียกว่าการดำเนินการต่อของฟังก์ชันนั้นทำงานในภารกิจลูปเหตุการณ์ใหม่ เมื่องานนั้นเริ่มต้นขึ้น ระบบจะแก้ไข 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();
}
อย่างไรก็ตาม ประโยชน์ที่แท้จริงของ scheduler.yield()
เหนือกว่าแนวทางอื่นๆ ที่ให้ผลคือระบบจะให้ความสำคัญกับการดําเนินการต่อ ซึ่งหมายความว่าหากคุณ yield ในระหว่างที่ทํางานอยู่ การดำเนินการต่อของงานปัจจุบันจะทำงานก่อนที่จะเริ่มงานอื่นๆ ที่คล้ายกัน
วิธีนี้จะช่วยป้องกันไม่ให้โค้ดจากแหล่งที่มาของงานอื่นๆ ขัดจังหวะลําดับการเรียกใช้โค้ด เช่น งานจากสคริปต์ของบุคคลที่สาม
การรองรับข้ามเบราว์เซอร์
scheduler.yield()
ยังไม่รองรับในบางเบราว์เซอร์ จึงต้องมีทางเลือกสำรอง
วิธีหนึ่งคือวาง scheduler-polyfill
ลงในบิลด์ จากนั้นจะใช้ scheduler.yield()
ได้โดยตรง โพลีฟีลจะจัดการกับการเปลี่ยนกลับไปใช้ฟังก์ชันการตั้งเวลางานอื่นๆ เพื่อให้ทำงานในเบราว์เซอร์ต่างๆ คล้ายกันได้
หรือจะเขียนเวอร์ชันที่ซับซ้อนน้อยกว่าในไม่กี่บรรทัดก็ได้ โดยใช้เพียง 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 ที่ช่วยตรวจสอบคู่มือนี้ทางเทคนิค
ภาพปกจาก Unsplash โดยได้รับความอนุเคราะห์จาก Amirali Mirhashemian