คุณเคยได้ยินว่า "อย่าบล็อกเธรดหลัก" และ "แบ่งงานที่มีระยะเวลานาน" แต่การทําเช่นนั้นหมายความว่าอย่างไร
คำแนะนำทั่วไปในการทำให้แอป 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 มิลลิวินาทีหรือมากกว่านั้น ในกรณีที่แย่ที่สุด งานเหล่านี้อาจทำงานได้นานขึ้นมาก โดยเฉพาะในอุปกรณ์ที่มีทรัพยากรจํากัด
เลื่อนเวลาการเรียกใช้โค้ดด้วยตนเอง
วิธีหนึ่งที่นักพัฒนาแอปใช้เพื่อแบ่งงานออกเป็นงานเล็กๆ คือการใช้ 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()
ไม่ใช่เครื่องมือที่เหมาะสมสำหรับงานนี้อย่างน้อยก็เมื่อใช้ด้วยวิธีนี้
ใช้ async
/await
เพื่อสร้างจุดที่มีอัตราผลตอบแทน
คุณสามารถให้สิทธิ์แก่เธรดหลักได้โดยขัดจังหวะคิวงานชั่วคราวเพื่อให้เบราว์เซอร์มีโอกาสทำงานที่สำคัญกว่า เพื่อให้แน่ใจว่างานที่สำคัญซึ่งแสดงต่อผู้ใช้จะดำเนินการก่อนงานที่มีลำดับความสำคัญต่ำกว่า
ตามที่อธิบายไว้ก่อนหน้านี้ setTimeout
สามารถใช้เพื่อส่งมอบการควบคุมไปยังเธรดหลัก แต่เพื่อความสะดวกและอ่านง่ายขึ้น คุณสามารถเรียกใช้ setTimeout
ภายใน Promise
และส่งเมธอด resolve
ของ setTimeout
เป็นการเรียกกลับได้
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
ข้อดีของฟังก์ชัน yieldToMain()
คือคุณสามารถawait
ฟังก์ชันดังกล่าวในฟังก์ชัน async
ใดก็ได้ จากตัวอย่างก่อนหน้านี้ คุณอาจสร้างอาร์เรย์ของฟังก์ชันที่จะเรียกใช้ และส่งคืนไปยังเธรดหลักหลังจากเรียกใช้แต่ละรายการแล้ว ดังนี้
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
ผลที่ได้คืองานแบบโมโนลิธิกที่เคยมีตอนนี้แยกออกเป็นงานต่างๆ
Scheduler API โดยเฉพาะ
setTimeout
เป็นวิธีที่มีประสิทธิภาพในการแบ่งงาน แต่ก็มีข้อเสียอยู่ เมื่อคุณให้สิทธิ์แก่เธรดหลักโดยการเลื่อนโค้ดเพื่อเรียกใช้ในงานที่ตามมา ระบบจะเพิ่มงานนั้นไว้ที่ท้ายของคิว
หากควบคุมโค้ดทั้งหมดในหน้าเว็บ คุณสามารถสร้างตัวตั้งเวลาของคุณเองได้ ซึ่งสามารถจัดลําดับความสําคัญของงานได้ แต่สคริปต์ของบุคคลที่สามจะไม่ใช้ตัวตั้งเวลาของคุณ ด้วยเหตุนี้ คุณจึงไม่สามารถจัดลําดับความสําคัญงานในสภาพแวดล้อมดังกล่าว คุณทำได้เพียงแบ่งออกเป็นส่วนๆ หรือยอมให้ผู้ใช้โต้ตอบอย่างชัดแจ้ง
Scheduler API มีฟังก์ชัน postTask()
ซึ่งช่วยให้ตั้งเวลางานได้ละเอียดยิ่งขึ้น และเป็นวิธีหนึ่งที่ช่วยให้เบราว์เซอร์จัดลําดับความสําคัญของงานเพื่อให้งานที่มีลําดับความสําคัญต่ำทำงานในเธรดหลัก postTask()
ใช้ Promise และยอมรับการตั้งค่า priority
รูปแบบใดรูปแบบหนึ่งต่อไปนี้
'background'
สำหรับงานที่มีลำดับความสำคัญต่ำสุด'user-visible'
สำหรับงานที่มีลำดับความสำคัญปานกลาง การตั้งค่านี้เป็นค่าเริ่มต้นหากไม่ได้ตั้งค่าpriority
'user-blocking'
สำหรับงานที่สำคัญซึ่งต้องทำงานที่มีลำดับความสำคัญสูง
มาดูตัวอย่างโค้ดต่อไปนี้ที่ใช้ postTask()
API เพื่อเรียกใช้งาน 3 รายการที่มีลำดับความสำคัญสูงสุด และงานที่เหลืออีก 2 รายการที่มีลำดับความสำคัญต่ำสุด
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
ในส่วนนี้ ระบบจะกำหนดเวลาลำดับความสำคัญของงานในลักษณะที่งานที่มีลำดับความสำคัญสูงกว่าในเบราว์เซอร์ เช่น การโต้ตอบของผู้ใช้ จะได้ทำงานในระหว่างนั้นตามที่จำเป็น
นี่เป็นตัวอย่างง่ายๆ ของการใช้ postTask()
คุณสามารถสร้างอินสแตนซ์ออบเจ็กต์ TaskController
ที่แตกต่างกันซึ่งสามารถแชร์ลําดับความสําคัญระหว่างงานต่างๆ รวมถึงสามารถเปลี่ยนลําดับความสําคัญสําหรับอินสแตนซ์ TaskController
ที่แตกต่างกันได้ตามต้องการ
ผลตอบแทนในตัวที่มีการต่ออายุโดยใช้ scheduler.yield()
API
scheduler.yield()
เป็น API ที่ออกแบบมาเพื่อส่งมอบชุดข้อความหลักในเบราว์เซอร์โดยเฉพาะ การใช้งานจะคล้ายกับฟังก์ชัน yieldToMain()
ที่แสดงก่อนหน้านี้ในคู่มือนี้
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
โค้ดนี้คุ้นเคยกันดี แต่จะใช้ await scheduler.yield()
แทน yieldToMain()
ข้อดีของ scheduler.yield()
คือความต่อเนื่อง ซึ่งหมายความว่าหากคุณหยุดกลางคันระหว่างชุดงาน งานอื่นๆ ที่กำหนดเวลาไว้จะยังคงทำงานตามลำดับเดิมหลังจากจุดหยุด วิธีนี้จะช่วยป้องกันไม่ให้โค้ดจากสคริปต์ของบุคคลที่สามขัดจังหวะลําดับการเรียกใช้โค้ด
อย่าใช้ isInputPending()
isInputPending()
API มีวิธีตรวจสอบว่าผู้ใช้พยายามโต้ตอบกับหน้าเว็บหรือไม่ และจะแสดงผลก็ต่อเมื่อมีอินพุตที่รอดำเนินการ
ซึ่งจะช่วยให้ JavaScript ทำงานต่อไปได้หากไม่มีอินพุตที่รอดำเนินการแทนที่จะหยุดทำงานและไปอยู่ท้ายคิวงาน ซึ่งอาจส่งผลให้เกิดประสิทธิภาพที่ดีขึ้นอย่างน่าประทับใจ ตามที่ระบุไว้ในIntent to Ship สำหรับเว็บไซต์ที่อาจไม่ได้แสดงผลในชุดข้อความหลัก
อย่างไรก็ตาม นับตั้งแต่เปิดตัว API ดังกล่าว ความเข้าใจของเราเกี่ยวกับการสร้างรายได้ก็เพิ่มขึ้น โดยเฉพาะเมื่อมีการนำ INP มาใช้ เราไม่แนะนำให้ใช้ API นี้อีกต่อไป และขอแนะนำให้ใช้ Yield ไม่ว่าอินพุตจะรอดำเนินการหรือไม่ก็ตาม เนื่องด้วยเหตุผลต่อไปนี้
isInputPending()
อาจแสดงผลfalse
อย่างไม่ถูกต้องแม้ว่าผู้ใช้จะโต้ตอบในบางสถานการณ์ก็ตาม- อินพุตไม่ใช่กรณีเดียวที่งานควรให้ผล ภาพเคลื่อนไหวและการอัปเดตอินเทอร์เฟซผู้ใช้ทั่วไปอื่นๆ มีความสำคัญไม่แพ้กับการสร้างหน้าเว็บที่ปรับเปลี่ยนตามอุปกรณ์
- เราได้เปิดตัว API ที่ให้ผลลัพธ์ที่ครอบคลุมมากขึ้นซึ่งช่วยคลายข้อกังวลเกี่ยวกับผลลัพธ์ เช่น
scheduler.postTask()
และscheduler.yield()
บทสรุป
การจัดการงานเป็นเรื่องยาก แต่การจัดการงานจะช่วยให้หน้าเว็บตอบสนองต่อการโต้ตอบของผู้ใช้ได้เร็วขึ้น การจัดการและจัดลําดับความสําคัญของงานนั้นไม่มีคําแนะนําเดียว แต่มีหลายเทคนิค เราขอทบทวนสิ่งสำคัญที่ควรพิจารณาเมื่อจัดการงานดังนี้
- มอบสิทธิ์ให้ชุดข้อความหลักสำหรับงานที่สําคัญซึ่งแสดงต่อผู้ใช้
- จัดลำดับความสำคัญของงานด้วย
postTask()
- ลองใช้
scheduler.yield()
- สุดท้าย ทํางานในฟังก์ชันให้น้อยที่สุด
เมื่อใช้เครื่องมือเหล่านี้อย่างน้อย 1 รายการ คุณควรจัดโครงสร้างงานในแอปพลิเคชันเพื่อให้จัดลําดับความสําคัญตามความต้องการของผู้ใช้ ในขณะเดียวกันก็ทํางานที่ไม่สําคัญมากนักได้ ซึ่งจะสร้างประสบการณ์การใช้งานที่ดีขึ้นซึ่งตอบสนองและใช้งานได้ง่ายขึ้น
ขอขอบคุณเป็นพิเศษ Philip Walton ที่ช่วยตรวจสอบคู่มือนี้ทางเทคนิค
ภาพปกจาก Unsplash โดยได้รับความอนุเคราะห์จาก Amirali Mirhashemian