Bạn đã được yêu cầu "không chặn luồng chính" và "chia nhỏ các tác vụ dài", nhưng bạn có biết làm thế nào để thực hiện những việc đó không?
Xuất bản: ngày 30 tháng 9 năm 2022, Cập nhật lần gần đây nhất: ngày 19 tháng 12 năm 2024
Lời khuyên thường gặp để giữ cho các ứng dụng JavaScript hoạt động nhanh chóng thường được tóm gọn thành những lời khuyên sau:
- "Đừng chặn luồng chính".
- "Chia nhỏ các việc cần làm dài."
Đây là một lời khuyên hay, nhưng bạn cần làm gì để thực hiện lời khuyên này? Việc vận chuyển ít JavaScript là điều tốt, nhưng điều đó có tự động tương đương với giao diện người dùng phản hồi 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à bất kỳ phần công việc riêng biệt nào 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 kiểm soát trực tiếp được. Trong tất cả những điều này, JavaScript mà bạn viết có lẽ là nguồn lớn nhất của các tác vụ.
click
bắt đầu, xuất hiện 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 kết với JavaScript ảnh hưởng đến hiệu suất theo một số cách:
- Khi tải một tệp JavaScript xuống trong quá trình khởi động, trình duyệt sẽ xếp hàng các tác vụ để phân tích cú pháp và biên dịch JavaScript đó để có thể thực thi sau này.
- Vào những thời điểm khác trong vòng đời của trang, các tác vụ sẽ được xếp hàng đợi khi JavaScript thực hiện các thao tá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 ở chế độ nền như thu thập dữ liệu phân tích.
Tất cả những thứ này (ngoại trừ web worker 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à nơi hầu hết mọi đoạn mã JavaScript bạn viết đều đượ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. Mọi tác vụ mất hơn 50 mili giây đều là một tác vụ dài. Đối với những 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ụ dài, 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 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ụ dài 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 nhanh hơn nhiều, bao gồm cả cá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 mà bạn đã đưa vào hàng đợi ban đầu sẽ được thực hiện.
Ở đầu hình trên, một trình xử lý sự kiện do một lượt tương tác của người dùng xếp hàng phải đợi một tác vụ duy nhất kéo dài trước khi có thể bắt đầu. Điều này làm chậm quá trình diễn ra lượt 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à người dùng có thể cảm thấy tương tác diễn ra ngay lập tức.
Giờ đây, bạn đã 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ý công việc
Một lời khuyên thường gặp trong cấu trúc phần mềm là chia công việc của bạn 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 một chỉ báo xoay, 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 số liệu phân tích.
Về mặt khái niệm, saveSettings()
được thiết kế hợp lý. 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ì các dự án.
Tuy nhiên, vấn đề có thể xảy ra ở đâ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()
duy nhất gọi 5 hàm. Công việc này được chạy trong 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 những 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 nhiều, đặc biệt là trên các thiết bị bị hạn chế về tài nguyên.
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ụ dài 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.
Hoãn thực thi mã theo cách thủ công
Để đảm bảo các tác vụ quan trọng mà người dùng nhìn thấy và phản hồi 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ể chuyển sang luồng chính bằng cách tạm thời 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à nhà phát triển đã sử dụng để chia các tác vụ thành các tác vụ nhỏ hơn là setTimeout()
. Với kỹ thuật này, bạn sẽ truyền hàm đến setTimeout()
. Thao tác này sẽ hoãn thực thi lệnh gọi lại thành một tác vụ riêng, 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à phân luồng và hoạt động hiệu quả nhất đối với một loạt các 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 rấ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 tạo ra: khi bạn tạo ra luồng chính bằng cách hoãn mã để chạy trong một tác vụ tiếp theo bằng cách sử dụ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ờ, chúng sẽ chạy trước mã bị hoãn lại.
Một API chuyên biệt để tạo hiệu ứng chuyển đổi: scheduler.yield()
scheduler.yield()
là một API được thiết kế dành riêng cho việc 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ữ hoặc một 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ụ sau này. Mọi mã được liên kết để chạy sau khi Promise
đó được phân giải (trong chuỗi .then()
rõ ràng hoặc sau khi await
trong một hàm không đồng bộ) sẽ chạy trong tác vụ trong tương lai đó.
Trong thực tế: 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 quyền cho luồng chính. Việc 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ờ sẽ được giải quyết và hàm sẽ tiếp tục thực thi từ nơi dừng lại.
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()
được chia thành 2 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 nhận được phản hồi trực quan nhanh hơn, như được đo bằng tương tác con trỏ hiện 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 tạo khác là việc tiếp tục được ưu tiên. Điều này có nghĩa là nếu bạn tạo ở 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 bắt đầu.
Điều này giúp mã từ các nguồn tác vụ khác không 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()
, hoạt động tiếp tục sẽ bắt đầu từ nơi nó đã 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ả các trình duyệt, vì vậy bạn cần có một giải pháp dự phòng.
Một giải pháp là thả scheduler-polyfill
vào bản dựng của bạn, 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 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 scheduler.yield()
không có sẵn.
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 nhận được mức độ ưu tiên tiếp tục, nhưng chúng vẫn sẽ tạo ra lợi nhuận để trình duyệt luôn phản hồi.
Cuối cùng, có thể có những trường hợp mã của bạn không thể nhường cho luồng chính nếu phần tiếp tục của mã không được ưu tiên (ví dụ: một trang bậ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 tính năng nâng cao tăng dần: mang lại kết quả 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 vi tác vụ trong một dòng mã tiện dụng:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Chia công việc kéo dài thành nhiều phần 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
phương thức đó trong mọi hàm async
.
Ví dụ: nếu có một mảng các công việc cần chạy thường kết thúc bằng một tác vụ dài, bạn có thể chèn các kết quả để 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();
}
}
Việc tiếp tục runJobs()
sẽ được ưu tiên, nhưng vẫn cho phép các tác vụ có mức độ ưu tiên cao hơn (chẳng hạn như phản hồi trực quan cho dữ liệu đầu vào của người dùng) chạy mà không cần phải đợi danh sách có thể dài các tác vụ 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. scheduler.yield()
có tốc độ nhanh và hiệu quả, nhưng có một số chi phí phát sinh. Nếu một số công việc trong jobQueue
rất ngắn, thì chi phí phát sinh có thể nhanh chóng tăng lên thành nhiều thời gian dành cho việc tạo và tiếp tục hơn là thực hiện công việc thực tế.
Một cách tiếp cận là nhóm các công việc lại với nhau, chỉ tạo ra khoảng trống giữa các công việc nếu khoảng thời gian kể từ lần tạo khoảng trống gần nhất đủ dài. Thời hạn thường là 50 mili giây để cố gắng ngăn các tác vụ trở thành tác vụ dài, nhưng bạn có thể điều chỉnh thời hạn này để cân bằng giữa khả năng phản hồi 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ỉ tạo ra kết quả nếu có một dữ liệu đầ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 hiệu suất một cách đáng kể, như được trình bày chi tiết trong Ý định phát hành, đối với 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 tạo ra lợi nhuận, đặc biệt là khi INP được giới thiệu. Chúng tôi không còn đề xuất sử dụng API này nữa. Thay vào đó, bạn nên nhườ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ềfalse
khô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 tạo ra. Ảnh động và các nội dung 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 có khả năng phản hồi.
- Kể từ đó, các API tạo khoảng trống toàn diện hơn đã được ra mắt để giải quyết các vấn đề về việc tạo khoảng trống, chẳng hạn như
scheduler.postTask()
vàscheduler.yield()
.
Kết luận
Việc quản lý các tác vụ là một việc khó khăn, nhưng việc này giúp đảm bảo trang của bạn phản hồi nhanh hơn đối 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 về cách quản lý và ưu tiên các việc cần làm, mà có nhiều kỹ thuật khác nhau. Xin nhắc lại, đây là những điều chính mà bạn cần cân nhắc khi quản lý công việc:
- Nhường cho luồng chính đối với các tác vụ quan trọng, hướng đến người dùng.
- Sử dụng
scheduler.yield()
(với một phương án dự phòng trên nhiều trình duyệt) để tạo ra các phần tiếp theo được ưu tiên và mang lại cảm giác thoải mái - Cuối cùng, hãy thực hiện ít thao tác nhất có thể trong các hàm của bạn.
Để tìm hiểu thêm về scheduler.yield()
, tính năng lập lịch tác vụ rõ ràng tương ứng scheduler.postTask()
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 sao cho công việc ư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, nhạy bén hơn và thú vị hơn khi sử dụng.
Xin chân thành cảm ơn Philip Walton vì đã thẩm định kỹ thuật cho hướng dẫn này.
Hình thu nhỏ lấy từ Unsplash, do Amirali Mirhashemian cung cấp.