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 việc làm những việc đó có ý nghĩa gì?
Ngày xuất bản: 30 tháng 9 năm 2022, Ngày cập nhật gần đây nhất: 19 tháng 12 năm 2024
Những lời khuyên phổ biến để giúp ứng dụng JavaScript luôn nhanh chóng thường tóm tắt thành những lời khuyên sau:
- "Đừng chặn luồng chính."
- "Phân chia các việc cần làm dài hạn."
Đây là lời khuyên tuyệt vời, nhưng bạn cần làm gì để thực hiện lời khuyên này? Việc gửi ít JavaScript là tốt, nhưng điều đó có tự động đồng nghĩa với giao diện người dùng thích ứng hơn không? Có thể, nhưng cũng có thể không.
Để hiểu cách tối ưu hoá 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ụ đó.
Tác vụ là gì?
Tác vụ là bất kỳ 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 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. Trong số tất cả những điều này, JavaScript mà bạn viết có lẽ là nguồn tác vụ lớn nhất.
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ẽ đư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 các 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 hoạt động, chẳng hạn như phản hồi các lượt tương tác thông qua trình xử lý sự kiện, ảnh động do JavaScript điều khiển và hoạt động trong nền như thu thập số liệu phân tích.
Tất cả những việc này (ngoại trừ worker 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à nơi thực thi gần như tất cả JavaScript bạn viết.
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à tác vụ dài. Đố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ụ 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.
Để tránh 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 sớm 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 bạn đã đưa vào hàng đợi ban đầu được thực hiện.
Ở đầu hình trước, 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ụ dài 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ể cảm thấy ngay lập tức.
Giờ đây, bạn đã biết tầm quan trọng của việc chia nhỏ 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ý tác vụ
Một lời khuyên phổ biến 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 có tên là saveSettings()
gọi 5 hàm để xác thực một biểu mẫu, hiển thị một 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 số liệu phân tích.
Về mặt khái niệm, saveSettings()
được thiết kế 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ư vậy giúp bạn dễ dàng di chuyển và duy trì dự án hơn.
Tuy nhiên, có một 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()
. Tức là cả 5 hàm sẽ chạy dưới dạng một tác vụ.
Trong trường hợp tốt nhất, ngay cả 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ế 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à Tương tác đến lượt vẽ tiếp theo (INP) kém.
Trì 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 trên 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 làm gián đoạn công việc của mình để cho 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 các tác vụ thành các tác vụ nhỏ hơn liên quan đến 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à hoạt động trả về và hoạt động hiệu quả nhất đối với 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 rất nhiều thời gian nếu có nhiều vòng lặp.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Việc sử dụng setTimeout()
ở đây sẽ gặp vấn đề do yếu tố công thái học của nhà phát triển, và sau 5 vòng lặp setTimeout()
lồng nhau, trình duyệt sẽ bắt đầu áp dụng độ trễ tối thiểu 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 quyền: khi bạn nhường quyền 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 phần cuối của hàng đợi. Nếu có các tác vụ khác đang chờ xử lý, thì các tác vụ đó sẽ chạy trước mã bị trì hoã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 để chuyển sang 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ụ trong tương lai. Mọi mã được tạo chuỗi để chạy sau khi Promise
đó được phân giải (trong một 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ế: hãy chèn await scheduler.yield()
và hàm sẽ tạm dừng quá trình thực thi tại thời điểm đó và chuyển sang luồng chính. Quá trình thực thi phần còn lại của hàm (được gọi là tiếp tục 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 đã chờ sẽ được phân giải và hàm sẽ tiếp tục thực thi từ nơi bị gián đoạn.
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();
}
Tuy nhiên, lợi ích thực sự của scheduler.yield()
so với các phương pháp nhường quyền 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 nhường quyền 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 đầu bất kỳ tác vụ tương tự nào khác.
Điều 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ã, chẳng hạn như các tác vụ từ tập lệnh của bên thứ ba.
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ó phương án dự phòng.
Một giải pháp là thả 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ên 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 Lời hứa 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 nhận được tính năng tiếp tục được ưu tiên, nhưng các trình duyệt này vẫn sẽ mang lại khả năng phản hồi cho trình duyệt.
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 đã biết là bận, trong đó việc nhường có nguy cơ không hoàn thành công việc trong một khoảng thời gian). Trong trường hợp đó, scheduler.yield()
có thể được coi là một loại tính năng cải tiến dần dần: tạo ra 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 tác vụ vi mô trong một dòng tiện lợi:
// 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 để sử 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 loạt công việc cần chạy và thường kết thúc bằng một tác vụ dài, bạn có thể chèn năng suất để 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ông việc có mức độ ưu tiên cao hơn như phản hồi trực quan đối với dữ liệu đầu vào của người dùng để chạy, không phải chờ 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ả tính năng nhường quyền. 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ì chi phí hao tổn có thể nhanh chóng tăng lên nhiều thời gian hơn để trả về và tiếp tục so với việc thực thi công việc thực tế.
Một phương pháp là phân lô các công việc, chỉ trả về giữa các công việc nếu đã đủ thời gian kể từ lần trả về gần đây nhất. Thời hạn phổ biến là 50 mili giây để cố gắng không để 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 để đánh đổi giữa khả năng phản hồi và thời gian hoàn tất 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ỉ trả về 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 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ì trả về 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 đáng kể, như được nêu chi tiết trong phần Ý định vận chuyển, đối với những trang web có thể không trả về luồng chính.
Tuy nhiên, kể từ khi API đó ra mắt, chúng tôi đã hiểu rõ hơn về việc nhường quyền, đặc biệt là khi giới thiệu INP. Bạn không nên sử dụng API này nữa mà nên trả về bất kể dữ liệu đầu vào có đang chờ xử lý hay không vì một số lý do sau:
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ụ phải trả về. Ả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 quan trọng không kém trong việc cung cấp trang web thích ứng.
- Kể từ đó, các API trả về toàn diện hơn đã được giới thiệu để giải quyết các vấn đề về việc trả về, 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 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 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 để quản lý và ưu tiên các nhiệm vụ, mà là một số kỹ thuật khác nhau. Xin nhắc lại, đây là những điều chính bạn cần cân nhắc khi quản lý công việc:
- Chuyển sang 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()
(có phương án dự phòng trên nhiều trình duyệt) để tạo ra và nhận các phần tiếp tục được ưu tiên một cách hợp lý - 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 biểu tác vụ rõ ràng và việc ưu tiên tác vụ, hãy xem tài liệu về API Lập lịch biểu tác vụ đượ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 thực hiện. Đ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 cảm ơn đặc biệt Philip Walton đã kiểm tra kỹ thuật cho hướng dẫn này.
Hình thu nhỏ lấy từ Unsplash, do Amirali Mirhashemian cung cấp.