Bạn đã được khuyên "đừng chặn luồng chính" và "chia nhỏ các tác vụ cần nhiều thời gian", nhưng làm những việc đó có nghĩa là gì?
Xuất bản: Ngày 30 tháng 9 năm 2022, Lần cập nhật gần đây nhất: Ngày 19 tháng 12 năm 2024
Lời khuyên phổ biến để duy trì tốc độ của ứng dụng JavaScript thường xoay quanh những lời khuyên sau:
- "Đừng chặn luồng chính".
- "Chia nhỏ các tác vụ cần nhiều thời gian".
Đây là lời khuyên hữu ích, nhưng bạn cần làm gì? Việc gửi ít JavaScript hơn là điều tốt, nhưng liệu điều đó có tự động đồng nghĩa với việc giao diện người dùng thích ứng nhanh hơn không? Có thể, nhưng cũng có thể không.
Để hiểu cách tối ưu hoá các tác vụ trong JavaScript, trước tiên, bạn cần biết tác vụ là gì và cách trình duyệt xử lý các tác vụ đó.
Việc cần làm là gì?
Tác vụ là mọi công việc riêng biệt mà trình duyệt thực hiện. Công việc đó bao gồm việc kết xuất, phân tích cú pháp HTML và CSS, chạy JavaScript và các loại công việc khác mà bạn có thể không có quyền kiểm soát trực tiếp. Trong tất cả những điều này, JavaScript bạn viết có lẽ là nguồn tác vụ lớn nhất.
click bắt đầu, được hiển thị trong trình phân tích hiệu suất của Công cụ của Chrome cho nhà phát triển.
Các tác vụ liên quan đến JavaScript ảnh hưởng đến hiệu suất theo một số cách:
- Khi trình duyệt tải tệp JavaScript xuống trong quá trình khởi động, trình duyệt sẽ đưa các tác vụ vào hàng đợi để phân tích cú pháp và biên dịch JavaScript đó để có thể thực thi sau.
- Vào những thời điểm khác trong vòng đời của trang, các tác vụ sẽ được đưa vào hàng đợi khi JavaScript thực hiện công việc như phản hồi các hoạt động tương tác thông qua trình xử lý sự kiện, ảnh động dựa trên JavaScript và hoạt động trong nền như thu thập dữ liệu phân tích.
Tất cả những việc này (ngoại trừ trình chạy web và các API tương tự) đều diễn ra trên luồng chính.
Luồng chính là gì?
Luồng chính là nơi hầu hết các tác vụ chạy trong trình duyệt và là nơi hầu hết JavaScript bạn viết được thực thi.
Luồng chính chỉ có thể xử lý một tác vụ tại một thời điểm. Bất kỳ tác vụ nào mất hơn 50 mili giây đều là tác vụ cần nhiều thời gian. Đối với các tác vụ vượt quá 50 mili giây, tổng thời gian của tác vụ trừ đi 50 mili giây được gọi là khoảng thời gian chặn của tác vụ.
Trình duyệt chặn các hoạt động tương tác xảy ra trong khi một tác vụ có độ dài bất kỳ đang chạy, nhưng người dùng không nhận thấy điều này miễn là các tác vụ không chạy quá lâu. Tuy nhiên, khi người dùng cố gắng tương tác với một trang có nhiều tác vụ cần nhiều thời gian, giao diện người dùng sẽ có cảm giác không phản hồi và thậm chí có thể bị hỏng nếu luồng chính bị chặn trong một khoảng thời gian rất dài.
Để ngăn luồng chính bị chặn quá lâu, bạn có thể chia một tác vụ cần nhiều thời gian thành nhiều tác vụ nhỏ hơn.
Điều này rất quan trọng vì khi các tác vụ được chia nhỏ, trình duyệt có thể phản hồi công việc có mức độ ưu tiên cao hơn sớm hơn nhiều, bao gồm cả hoạt động tương tác của người dùng. Sau đó, các tác vụ còn lại sẽ chạy cho đến khi hoàn tất, đảm bảo công việc bạn đã đưa vào hàng đợi ban đầu được hoàn thành.
Ở đầu hình trước, một trình xử lý sự kiện được đưa vào hàng đợi bằng một lượt tương tác của người dùng phải đợi một tác vụ cần nhiều thời gian trước khi có thể bắt đầu. Điều này làm chậm quá trình tương tác. Trong trường hợp này, người dùng có thể nhận thấy độ trễ. Ở dưới cùng, trình xử lý sự kiện có thể bắt đầu chạy sớm hơn và hoạt động tương tác có thể diễn ra ngay lập tức.
Giờ đây, khi đã biết lý do cần chia nhỏ các tác vụ, bạn có thể tìm hiểu cách thực hiện việc này trong JavaScript.
Chiến lược quản lý việc cần làm
Một lời khuyên phổ biến trong cấu trúc phần mềm là chia nhỏ công việc thành các hàm nhỏ hơn:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
Trong ví dụ này, có một hàm tên là saveSettings() gọi 5 hàm để xác thực biểu mẫu, hiện vòng quay, gửi dữ liệu đến phần phụ trợ của ứng dụng, cập nhật giao diện người dùng và gửi dữ liệu phân tích.
Về mặt khái niệm, saveSettings() có cấu trúc tốt. Nếu cần gỡ lỗi một trong các hàm này, bạn có thể duyệt qua cây dự án để tìm hiểu chức năng của từng hàm. Việc chia nhỏ công việc như thế này giúp bạn dễ dàng điều hướng và duy trì dự án.
Tuy nhiên, vấn đề tiềm ẩn ở đây là JavaScript không chạy từng hàm này dưới dạng các tác vụ riêng biệt vì chúng được thực thi trong hàm saveSettings(). Điều này có nghĩa là cả 5 hàm sẽ chạy dưới dạng một tác vụ.
saveSettings() gọi 5 hàm. Công việc được chạy dưới dạng một tác vụ nguyên khối dài, chặn mọi phản hồi trực quan cho đến khi cả 5 hàm hoàn tất.
Trong trường hợp tốt nhất, chỉ một trong các hàm đó cũng có thể đóng góp 50 mili giây trở lên vào tổng thời lượng của tác vụ. Trong trường hợp xấu nhất, nhiều tác vụ trong số đó có thể chạy lâu hơn, đặc biệt là trên các thiết bị có tài nguyên hạn chế.
Trong trường hợp này, saveSettings() được kích hoạt bằng một lượt nhấp của người dùng và vì trình duyệt không thể hiển thị phản hồi cho đến khi toàn bộ hàm chạy xong, nên kết quả của tác vụ cần nhiều thời gian này là giao diện người dùng chậm và không phản hồi, đồng thời sẽ được đo lường là Lượt tương tác đến nội dung hiển thị tiếp theo (INP) kém.
Trì hoãn việc thực thi mã theo cách thủ công
Để đảm bảo các tác vụ quan trọng dành cho người dùng và phản hồi của giao diện người dùng diễn ra trước các tác vụ có mức độ ưu tiên thấp hơn, bạn có thể nhường quyền cho luồng chính bằng cách tạm thời làm gián đoạn công việc để trình duyệt có cơ hội chạy các tác vụ quan trọng hơn.
Một phương thức mà các nhà phát triển đã sử dụng để chia nhỏ các tác vụ là setTimeout(). Với kỹ thuật này, bạn truyền hàm đến setTimeout(). Thao tác này sẽ hoãn việc thực thi lệnh gọi lại thành một tác vụ riêng biệt, ngay cả khi bạn chỉ định thời gian chờ là 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);
}
Đây được gọi là yielding (nhường quyền ưu tiên) và hoạt động hiệu quả nhất cho một loạt hàm cần chạy tuần tự.
Tuy nhiên, không phải lúc nào mã của bạn cũng được sắp xếp theo cách này. Ví dụ: bạn có thể có một lượng lớn dữ liệu cần được xử lý trong một vòng lặp và tác vụ đó có thể mất nhiều thời gian nếu có nhiều lần lặp lại.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Việc sử dụng setTimeout() ở đây có vấn đề do tính công thái học của nhà phát triển và sau 5 vòng setTimeout() lồng nhau, trình duyệt sẽ bắt đầu áp dụng độ trễ tối thiểu là 5 mili giây cho mỗi setTimeout() bổ sung.
setTimeout cũng có một nhược điểm khác khi nói đến việc nhường: khi bạn nhường cho luồng chính bằng cách trì hoãn mã để chạy trong một tác vụ tiếp theo bằng setTimeout, tác vụ đó sẽ được thêm vào cuối hàng đợi. Nếu có các tác vụ khác đang chờ, các tác vụ đó sẽ chạy trước mã bị trì hoãn của bạn.
Một API nhường quyền chuyên dụng: scheduler.yield()
scheduler.yield() là một API được thiết kế riêng để nhường quyền cho luồng chính trong trình duyệt.
Đây không phải là cú pháp cấp ngôn ngữ hay cấu trúc đặc biệt; scheduler.yield() chỉ là một hàm trả về Promise sẽ được phân giải trong một tác vụ trong tương lai. Bất kỳ mã nào được liên kết để chạy sau khi Promise đó được giải quyết (trong chuỗi .then() rõ ràng hoặc sau khi await trong hàm không đồng bộ) sau đó sẽ chạy trong tác vụ trong tương lai đó.
Trong thực tế: hãy chèn một await scheduler.yield() và hàm sẽ tạm dừng thực thi tại thời điểm đó và nhường cho luồng chính. Quá trình thực thi phần còn lại của hàm (gọi là phần tiếp theo của hàm) sẽ được lên lịch chạy trong một tác vụ vòng lặp sự kiện mới. Khi tác vụ đó bắt đầu, lời hứa được chờ đợi sẽ được giải quyết và hàm sẽ tiếp tục thực thi từ nơi dừng lại trước đó.
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() hiện được chia thành hai tác vụ. Do đó, bố cục và nội dung hiển thị có thể chạy giữa các tác vụ, giúp người dùng phản hồi nhanh hơn bằng hình ảnh, được đo lường bằng lượt tương tác con trỏ ngắn hơn nhiều.
Tuy nhiên, lợi ích thực sự của scheduler.yield() so với các phương pháp nhường khác là việc tiếp tục được ưu tiên, nghĩa là nếu bạn nhường ở giữa một tác vụ, thì việc tiếp tục tác vụ hiện tại sẽ chạy trước khi bất kỳ tác vụ tương tự nào khác được bắt đầu.
Việc này giúp tránh việc mã từ các nguồn tác vụ khác làm gián đoạn thứ tự thực thi mã của bạn, chẳng hạn như các tác vụ từ tập lệnh của bên thứ ba.
scheduler.yield(), quá trình tiếp tục sẽ bắt đầu từ nơi đã dừng lại trước khi chuyển sang các tác vụ khác.
Hỗ trợ nhiều trình duyệt
scheduler.yield() chưa được hỗ trợ trong tất cả trình duyệt, vì vậy, bạn cần có một phương án dự phòng.
Một giải pháp là đưa scheduler-polyfill vào bản dựng, sau đó bạn có thể sử dụng trực tiếp scheduler.yield(); polyfill sẽ xử lý việc quay lại các hàm lập lịch tác vụ khác để hoạt động tương tự trên các trình duyệt.
Ngoài ra, bạn có thể viết một phiên bản ít phức tạp hơn trong một vài dòng, chỉ sử dụng setTimeout được gói trong một Promise làm phương án dự phòng nếu không có scheduler.yield().
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Mặc dù các trình duyệt không hỗ trợ scheduler.yield() sẽ không được ưu tiên tiếp tục, nhưng chúng vẫn sẽ nhường chỗ để trình duyệt duy trì khả năng phản hồi.
Cuối cùng, có thể có trường hợp mã của bạn không thể nhường cho luồng chính nếu việc tiếp tục không được ưu tiên (ví dụ: một trang bận rộn đã biết, nơi việc nhường có nguy cơ không hoàn thành công việc trong một thời gian). Trong trường hợp đó, scheduler.yield() có thể được coi là một loại cải tiến tăng dần: lợi nhuận trong các trình duyệt có scheduler.yield(), nếu không thì tiếp tục.
Bạn có thể thực hiện việc này bằng cách phát hiện tính năng và quay lại chờ một microtask duy nhất trong một dòng lệnh tiện dụng:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Chia nhỏ công việc chạy trong thời gian dài bằng scheduler.yield()
Lợi ích của việc sử dụng bất kỳ phương thức nào trong số này để dùng scheduler.yield() là bạn có thể await trong bất kỳ hàm async nào.
Ví dụ: nếu có một mảng công việc cần chạy thường dẫn đến một tác vụ cần nhiều thời gian, bạn có thể chèn lệnh tạm dừng để chia nhỏ tác vụ đó.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Quá trình tiếp tục của runJobs() sẽ được ưu tiên, nhưng vẫn cho phép các công việc có mức độ ưu tiên cao hơn (chẳng hạn như phản hồi trực quan cho hoạt động đầu vào của người dùng) chạy mà không phải đợi danh sách công việc có thể dài hoàn tất.
Tuy nhiên, đây không phải là cách sử dụng hiệu quả của việc tạo ra. scheduler.yield() nhanh và hiệu quả, nhưng có một số hao tổn. Nếu một số công việc trong jobQueue rất ngắn, thì mức hao tổn có thể nhanh chóng tăng lên, khiến thời gian tạm dừng và tiếp tục nhiều hơn thời gian thực hiện công việc thực tế.
Một phương pháp là gộp các công việc theo lô, chỉ nhường quyền kiểm soát giữa các công việc nếu đã đủ lâu kể từ lần nhường quyền kiểm soát gần nhất. Thời hạn thông thường là 50 mili giây để cố gắng ngăn các tác vụ trở thành tác vụ cần nhiều thời gian, nhưng có thể điều chỉnh thời hạn này để cân bằng giữa khả năng thích ứng và thời gian hoàn thành hàng đợi công việc.
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();
}
}
}
Kết quả là các công việc được chia nhỏ để không bao giờ mất quá nhiều thời gian chạy, nhưng trình chạy chỉ nhường cho luồng chính khoảng 50 mili giây một lần.
Không sử dụng isInputPending()
API isInputPending() cung cấp một cách để kiểm tra xem người dùng có cố gắng tương tác với một trang hay không và chỉ trả về nếu có một hoạt động đầu vào đang chờ xử lý.
Điều này cho phép JavaScript tiếp tục nếu không có dữ liệu đầu vào nào đang chờ xử lý, thay vì tạo ra và kết thúc ở cuối hàng đợi tác vụ. Điều này có thể giúp cải thiện đáng kể hiệu suất (như được trình bày chi tiết trong Ý định phát hành) cho những trang web có thể không trả về luồng chính.
Tuy nhiên, kể từ khi ra mắt API đó, chúng tôi đã hiểu rõ hơn về việc nhường quyền, đặc biệt là khi ra mắt INP. Chúng tôi không còn đề xuất sử dụng API này nữa, mà thay vào đó, bạn nên tạm dừng bất kể có dữ liệu đầu vào đang chờ xử lý hay không vì một số lý do:
isInputPending()có thể trả vềfalsekhông chính xác mặc dù người dùng đã tương tác trong một số trường hợp.- Đầu vào không phải là trường hợp duy nhất mà các tác vụ nên nhường quyền kiểm soát. Ảnh động và các bản cập nhật giao diện người dùng thông thường khác cũng có thể quan trọng không kém trong việc cung cấp một trang web thích ứng.
- Kể từ đó, các API nhường quyền toàn diện hơn đã được ra mắt để giải quyết các vấn đề về nhường quyền, chẳng hạn như
scheduler.postTask()vàscheduler.yield().
Kết luận
Việc quản lý tác vụ là một thách thức, nhưng việc này đảm bảo rằng trang của bạn phản hồi nhanh hơn với các hoạt động tương tác của người dùng. Không có một lời khuyên duy nhất nào cho việc quản lý và ưu tiên các nhiệm vụ, mà có một số kỹ thuật khác nhau. Xin nhắc lại, sau đây là những điều chính bạn cần cân nhắc khi quản lý việc cần làm:
- Nhường luồng chính cho các tác vụ quan trọng, dành cho người dùng.
- Sử dụng
scheduler.yield()(với tính năng dự phòng trên nhiều trình duyệt) để mang lại hiệu quả và nhận được các phần tiếp theo được ưu tiên - Cuối cùng, hãy thực hiện ít công việc nhất có thể trong các hàm của bạn.
Để tìm hiểu thêm về scheduler.yield(), scheduler.postTask() liên quan đến việc lập lịch tác vụ rõ ràng và mức độ ưu tiên của tác vụ, hãy xem Tài liệu về API lập lịch tác vụ theo mức độ ưu tiên.
Với một hoặc nhiều công cụ này, bạn có thể sắp xếp công việc trong ứng dụng để ưu tiên nhu cầu của người dùng, đồng thời đảm bảo rằng công việc ít quan trọng hơn vẫn được hoàn thành. Điều này sẽ mang lại trải nghiệm người dùng tốt hơn, phản hồi nhanh hơn và thú vị hơn khi sử dụng.
Xin chân thành cảm ơn Philip Walton đã xem xét kỹ thuật cho hướng dẫn này.