Bạn nhận được thông báo "không chặn luồng chính" và "chia nhỏ những công việc dài" nhưng làm những việc đó có nghĩa là gì?
Lời khuyên phổ biến để duy trì tốc độ nhanh của các ứng dụng JavaScript thường là lời khuyên sau:
- "Đừng chặn luồng chính".
- "Chia nhỏ những việc cần làm dài."
Đây là lời khuyên rất hữu ích, nhưng đề xuất này bao gồm những gì? JavaScript ít vận chuyển là tốt, nhưng điều đó có tự động tương đương với giao diện người dùng thích ứng hơn không? Có thể, như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ý chúng.
Nhiệm vụ là gì?
Nhiệm 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 hiển thị, 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 số tất cả những câu trên, 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 quan đến JavaScript sẽ tác động đến hiệu suất theo một số cách:
- Khi tải tệp JavaScript xuống trong quá trình khởi động, trình duyệt sẽ xếp hàng đợi các tác vụ để 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 suốt thời gian hoạt động 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 lượt 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 số liệu phân tích).
Tất cả những nội dung 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à là nơi hầu hết các 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à tác vụ mất nhiều thời gian. Đố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 để không cho các tương tác xảy ra trong khi một tác vụ có thời lượng bất kỳ đang chạy, nhưng người dùng sẽ không nhận biết được đ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 khi có nhiều tác vụ dài, giao diện người dùng sẽ cảm thấy 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 khoảng thời gian rất dài.
Để luồng chính không bị chặn quá lâu, bạn có thể chia một nhiệm vụ dài thành nhiều nhiệm vụ nhỏ hơn.
Điều này rất quan trọng vì khi 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ả tương tác của người dùng. Sau đó, các tác vụ còn lại sẽ chạy đến khi hoàn tất, đảm bảo công việc mà ban đầu bạn đưa vào hàng đợi được thực hiện xong.
Ở đầu hình trước, một trình xử lý sự kiện đã đưa vào hàng đợi theo một hoạt động tương tác của người dùng phải đợi một tác vụ mất nhiều thời gian trước khi nó có thể bắt đầu. Điều này khiến hoạt động tương tác bị trì hoãn. Trong trường hợp này, người dùng có thể đã nhận thấy độ trễ. Ở phía 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ể mang lại cảm giác tức thì.
Giờ thì bạn đã biết lý do tại sao việc chia nhỏ tác vụ lại quan trọng, bạn có thể tìm hiểu cách thực hiện trong JavaScript.
Chiến lược quản lý công việc
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 của bạn thành các chức năng nhỏ hơn:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
Trong ví dụ này, có một hàm tên là saveSettings()
. Hàm này gọi 5 hàm để xác thực một biểu mẫu, hiển thị 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 lý thuyết, saveSettings()
là một cấu trúc hợp lý. Nếu cần gỡ lỗi một trong các hàm này, bạn có thể di chuyển 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 hơn.
Tuy nhiên, một vấn đề tiềm ẩn ở đây là JavaScript không chạy từng hàm trong số 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 này sẽ chạy dưới dạng một tác vụ.
Trong trường hợp tốt nhất, ngay cả chỉ một trong các hàm đó cũng có thể đóng góp từ 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ị hạn chế về tài nguyên.
Trì hoãn quá trình thực thi mã theo cách thủ công
Một phương pháp mà các nhà phát triển đã sử dụng để chia nhỏ các tác vụ thành các nhiệm vụ nhỏ hơn là setTimeout()
. Với kỹ thuật này, bạn truyền hàm đến setTimeout()
. Thao tác này sẽ trì hoãn việc thực thi lệnh gọi lại vào 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à lợi nhuậ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 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 là một vấn đề do công thái 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 chạy nhanh. Tất cả đều cộng lại và setTimeout()
không phải là công cụ phù hợp để thực hiện công việc, ít nhất là khi bạn dùng theo cách này.
Sử dụng async
/await
để tạo điểm lợi nhuận
Để đảm bảo các nhiệm vụ quan trọng dành cho người dùng xảy ra trước các nhiệm vụ có mức độ ưu tiên thấp hơn, bạn có thể chuyển đến chuỗi chính bằng cách tạm thời làm gián đoạn hàng đợi tác vụ để đưa ra trình duyệt để chạy các tác vụ quan trọng hơn.
Như giải thích trước đó, bạn có thể dùng setTimeout
để nhường luồng chính 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
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
nó 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 các hàm để chạy và mang lại luồng chính sau mỗi lần 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à nhiệm vụ từng nguyên khối nay được chia thành các nhiệm vụ riêng biệt.
API trình lập lịch biểu chuyên dụng
setTimeout
là 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 chuyển sang luồng chính bằng cách trì hoãn mã để chạy trong tác vụ tiếp theo, tác vụ đó sẽ được thêm vào cuối của hàng đợi.
Nếu kiểm soát tất cả mã trên trang của mình, bạn có thể tạo trình lập lịch biểu của riêng mình với khả năng ưu tiên các tác vụ. Tuy nhiên, tập lệnh bên thứ ba sẽ không sử dụng trình lập lịch biểu của bạn. Trên thực tế, 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 tạo ra lượt tương tác của người dùng một cách rõ ràng.
API trình lập lịch biểu cung cấp hàm postTask()
cho phép lên lịch chi tiết hơn cho các tác vụ. Đây cũng là một cách giúp trình duyệt ưu tiên công việc sao cho các tác vụ có mức độ ưu tiên thấp được chuyển cho luồng chính. postTask()
sử dụng 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à chế độ 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'});
};
Ở đây, mức độ ưu tiên của các tác vụ được lên lịch theo cách mà các tác vụ được trình duyệt ưu tiên (chẳng hạn như tương tác của người dùng) có thể thực hiện xen kẽ khi cần.
Đây là ví dụ đơn giản về cách sử dụng postTask()
. Bạn có thể tạo thực thể cho các đối tượng TaskController
khác nhau. Những đối tượng này có thể chia sẻ mức độ ưu tiên giữa các nhiệm 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 khi cần.
Lợi nhuận tích hợp sẵn khi tiếp tục sử dụng API scheduler.yield()
sắp tới
Một API bổ sung được đề xuất cho API trình lập lịch biểu là scheduler.yield()
, một API được thiết kế đặc biệt để tạo luồng chính trong trình duyệt. Cách sử dụng hàm này giống với 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 nhìn chung là quen thuộc, nhưng thay vì sử dụng yieldToMain()
, nó sẽ sử dụng
await scheduler.yield()
Lợi ích của scheduler.yield()
là tính tiếp tục, có nghĩa là nếu bạn tạo ra giữa một nhóm tác vụ, thì các tác vụ đã lên lịch khác sẽ tiếp tục theo cùng thứ tự sau điểm lợi nhuận. Điều này giúp tránh việc mã trong các tập lệnh của bên thứ ba làm gián đoạn thứ tự thực thi mã của bạn.
Việc sử dụng scheduler.postTask()
với priority: 'user-blocking'
cũng có nhiều khả năng tiếp tục do mức độ ưu tiên user-blocking
cao, vì vậy trong thời gian chờ đợi, bạn có thể sử dụng phương pháp này.
Việc sử dụng setTimeout()
(hoặc scheduler.postTask()
với priority: 'user-visibile'
hoặc không có priority
rõ ràng) sẽ lên lịch tác vụ ở cuối hàng đợi để các tác vụ khác đang chờ xử lý có thể chạy trước khi tiếp tục.
Không sử dụng isInputPending()
Hỗ trợ trình duyệt
- 87
- 87
- x
- x
API isInputPending()
cung cấp một cách kiểm tra xem người dùng đã cố tương tác với một trang hay chưa và chỉ tạo ra kết quả 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ì 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 ấn tượng, như đã nêu chi tiết trong phần Ý định giao hàng, đối với các trang web không thể quay lại luồng chính.
Tuy nhiên, kể từ khi ra mắt API đó, hiểu biết của chúng tôi về lợi nhuận đã tăng lên, đặc biệt là khi có sự ra mắt của INP. Bạn không nên sử dụng API này nữa. Thay vào đó, bạn nên tạo báo cáo bất kể dữ liệu đầu vào có đ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.- Dữ liệu đầu vào không phải là trường hợp duy nhất mà tác vụ cần mang lại. Ả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ó vai trò quan trọng như nhau trong việc cung cấp trang web thích ứng.
- Kể từ đó, chúng tôi đã ra mắt các API lợi nhuận toàn diện hơn để giải quyết các mối lo ngại đang phát sinh, chẳng hạn như
scheduler.postTask()
vàscheduler.yield()
.
Kết luận
Quản lý nhiệm vụ là một thách thức nhưng làm như vậy sẽ đảm bảo rằng trang của bạn phản hồi nhanh hơn với tương tác của người dùng. Không có một lời khuyên duy nhất nào giúp quản lý và sắp xếp thứ tự ưu tiên cho các công việc, mà có rất nhiều kỹ thuật. Xin nhắc lại rằng sau đây là những yếu tố chính mà bạn nên cân nhắc khi quản lý công việc:
- Chuyển đến luồng chính cho các tác vụ quan trọng dành cho người dùng.
- Ưu tiên việc cần làm 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 càng ít thao tác càng tốt trong các chức năng của bạn.
Với một hoặc nhiều công cụ này, bạn sẽ có thể cấu trúc công việc trong ứng dụng của mình để ưu tiên nhu cầu của người dùng, trong khi đảm bảo rằng vẫn hoàn thành các công việc ít quan trọng hơn. Như vậy, người dùng sẽ có trải nghiệm tốt hơn, phản hồi nhanh hơn và thú vị hơn.
Xin đặc biệt cảm ơn Philip Walton đã xem xét kỹ lưỡng tài liệu hướng dẫn này.
Hình thu nhỏ được lấy nguồn từ Unsplash, với sự hỗ trợ của Amirali Mirhashemian.