คุณเคยได้ยินว่า "อย่าบล็อกเธรดหลัก" และ "แบ่งงานที่มีระยะเวลานาน" แต่การทําเช่นนั้นหมายความว่าอย่างไร
คำแนะนำทั่วไปในการทำให้แอป 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()
ได้ การดำเนินการนี้จะเลื่อนการเรียกใช้ 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()
ก็ไม่ใช่เครื่องมือที่เหมาะสำหรับงาน อย่างน้อยก็ไม่ใช้วิธีนี้อีก
ใช้ async
/await
เพื่อสร้างจุดที่มีอัตราผลตอบแทน
คุณสามารถให้สิทธิ์แก่เธรดหลักได้โดยขัดจังหวะคิวงานชั่วคราวเพื่อให้เบราว์เซอร์มีโอกาสทำงานที่สำคัญกว่า เพื่อให้แน่ใจว่างานที่สำคัญซึ่งแสดงต่อผู้ใช้จะดำเนินการก่อนงานที่มีลำดับความสำคัญต่ำกว่า
ตามที่อธิบายไว้ก่อนหน้านี้ setTimeout
สามารถใช้เพื่อส่งมอบการควบคุมไปยังเธรดหลัก แต่เพื่อความสะดวกและการอ่านจะง่ายขึ้น คุณสามารถโทรหา setTimeout
ภายใน Promise
และส่งเมธอด resolve
เป็น Callback ได้
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()
ใช้การสัญญาและยอมรับการตั้งค่า 1 ใน 3 รายการของ 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();
}
}
ปกติแล้วโค้ดนี้จะคุ้นเคย แต่ใช้ yieldToMain()
แทน
await scheduler.yield()
ข้อดีของ 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