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ì?
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ư thúc đẩy hoạt động 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 dữ liệu phân tích.
Tất cả những điều này (ngoại trừ worker web và các API tương tự) đều xảy 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.
Trì hoãn thực thi mã theo cách thủ công
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à toàn bộ mảng dữ liệu có thể mất rất nhiều thời gian để xử lý, ngay cả khi mỗi lần lặp lại riêng lẻ chạy nhanh chóng. Tất cả những điều này đều tích luỹ lại và setTimeout()
không phải là công cụ phù hợp cho công việc này, ít nhất là không phải khi được sử dụng theo cách này.
Sử dụng async
/await
để tạo điểm năng suất
Để đảm bảo các tác vụ quan trọng mà người dùng nhìn thấy 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 hàng đợi tác vụ để cho trình duyệt có cơ hội chạy các tác vụ quan trọng hơn.
Như đã giải thích trước đó, bạn có thể sử dụng setTimeout
để nhường quyền cho luồng chính. Tuy nhiên, để thuận tiện và dễ đọc hơn, bạn có thể gọi setTimeout
trong Promise
và truyền phương thức resolve
của nó làm lệnh gọi lại.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Lợi ích của hàm yieldToMain()
là bạn có thể await
hàm này trong bất kỳ hàm async
nào. Dựa trên ví dụ trước, bạn có thể tạo một mảng hàm để chạy và trả về luồng chính sau khi mỗi hàm chạy:
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();
}
}
Kết quả là tác vụ từng nguyên khối hiện đã được chia thành các tác vụ riêng biệt.
API trình lập lịch biểu chuyên dụng
setTimeout
là một cách hiệu quả để chia nhỏ các tác vụ, nhưng có thể có một nhược điểm: 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, tác vụ đó sẽ được thêm vào phần cuối của hàng đợi.
Nếu kiểm soát tất cả mã trên trang, bạn có thể tạo trình lập lịch biểu của riêng mình để ưu tiên các tác vụ. Tuy nhiên, tập lệnh của bên thứ ba sẽ không sử dụng trình lập lịch biểu của bạn. Do đó, bạn không thể ưu tiên công việc trong những môi trường như vậy. Bạn chỉ có thể chia nhỏ hoặc nhường quyền tương tác rõ ràng cho người dùng.
API trình lập lịch biểu cung cấp hàm postTask()
cho phép lập lịch biểu các tác vụ chi tiết hơn và là một cách giúp trình duyệt ưu tiên công việc để các tác vụ có mức độ ưu tiên thấp chuyển sang luồng chính. postTask()
sử dụng các lời hứa và chấp nhận một trong ba chế độ cài đặt priority
:
'background'
cho các tác vụ có mức độ ưu tiên thấp nhất.'user-visible'
cho các tác vụ có mức độ ưu tiên trung bình. Đây là giá trị mặc định nếu bạn không đặtpriority
.'user-blocking'
cho các tác vụ quan trọng cần chạy ở mức độ ưu tiên cao.
Hãy lấy mã sau đây làm ví dụ, trong đó API postTask()
được dùng để chạy 3 tác vụ ở mức độ ưu tiên cao nhất có thể và 2 tác vụ còn lại ở mức độ ưu tiên thấp nhất có thể.
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'});
};
Tại đây, mức độ ưu tiên của các tác vụ được lên lịch sao cho các tác vụ được ưu tiên theo trình duyệt (chẳng hạn như các lượt tương tác của người dùng) có thể hoạt động trong khoảng thời gian đó nếu cần.
Đây là một ví dụ đơn giản về cách sử dụng postTask()
. Bạn có thể tạo bản sao cho các đối tượng TaskController
khác nhau có thể chia sẻ mức độ ưu tiên giữa các tác vụ, bao gồm cả khả năng thay đổi mức độ ưu tiên cho các thực thể TaskController
khác nhau nếu cần.
Lợi tức tích hợp với tính năng tiếp tục bằng API scheduler.yield()
scheduler.yield()
là một API được thiết kế riêng để nhường cho luồng chính trong trình duyệt. Cách sử dụng hàm này tương tự như hàm yieldToMain()
được minh hoạ trước đó trong hướng dẫn này:
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();
}
}
Mã này khá quen thuộc, nhưng thay vì sử dụng yieldToMain()
, mã này sử dụng await scheduler.yield()
.
Lợi ích của scheduler.yield()
là tính năng tiếp tục, tức là nếu bạn trả về ở giữa một tập hợp tác vụ, thì các tác vụ khác theo lịch sẽ tiếp tục theo cùng thứ tự sau điểm trả về. Việc này giúp tránh mã của tập lệnh bên thứ ba làm gián đoạn thứ tự thực thi mã của bạ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 một cách ấn tượng, 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, liên quan đến người dùng.
- Ưu tiên các tác vụ bằng
postTask()
. - Hãy cân nhắc thử nghiệm với
scheduler.yield()
. - 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.
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ẽ tạo ra 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.