Tối ưu hoá các thao tác dài

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.

Hình ảnh trực quan của một tác vụ như mô tả 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. Tác vụ này nằm ở đầu ngăn xếp, với trình xử lý sự kiện nhấp, lệnh gọi hàm và các mục khác bên dưới. Tác vụ này cũng bao gồm một số công việc kết xuất ở bên phải.
Một tác vụ do trình xử lý sự kiện click khởi động, 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 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.

Một tác vụ dài 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. Phần chặn của tác vụ (lớn hơn 50 mili giây) được mô tả bằng một mẫu gồm các sọc chéo màu đỏ.
Một tác vụ dài như mô tả trong trình phân tích hiệu suất của Chrome. Các tác vụ dài được biểu thị bằng hình tam giác màu đỏ ở góc của tác vụ, trong đó phần chặn của tác vụ được lấp đầy bằng một mẫu các sọc màu đỏ chéo.

Để 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.

Một tác vụ dài so với cùng một tác vụ được chia thành các tác vụ ngắn hơn. Tác vụ dài là một hình chữ nhật lớn, trong khi tác vụ được chia thành các phần là 5 hộp nhỏ hơn, tổng cộng có cùng chiều rộng với tác vụ dài.
Hình ảnh trực quan của một tác vụ dài so với cùng một tác vụ được chia thành 5 tác vụ ngắn 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.

Hình ảnh mô tả cách chia nhỏ một tác vụ có thể hỗ trợ tương tác của người dùng. Ở trên cùng, một tác vụ dài sẽ chặn trình xử lý sự kiện chạy cho đến khi tác vụ đó hoàn tất. Ở dưới cùng, tác vụ được chia thành các phần cho phép trình xử lý sự kiện chạy sớm hơn.
Hình ảnh minh hoạ những gì xảy ra với các lượt tương tác khi tác vụ quá dài và trình duyệt không thể phản hồi đủ nhanh với các lượt tương tác, so với khi các tác vụ dài hơn được chia thành các tác vụ nhỏ hơ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ụ.

Hàm saveSettings như mô tả trong trình phân tích hiệu suất của Chrome. Mặc dù hàm cấp cao nhất gọi 5 hàm khác, nhưng tất cả công việc đều diễn ra trong một tác vụ dài chặn luồng chính.
Một hàm saveSettings() gọi năm hàm. Công việc được chạy trong một tác vụ nguyên khối dài.

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.

Cùng một hàm saveSettings được mô tả trong trình phân tích hiệu suất của Chrome, chỉ với việc trả về. Kết quả là tác vụ từng mang tính nguyên khối nay được chia thành 5 tác vụ riêng biệt – mỗi tác vụ cho một hàm.
Hàm saveSettings() hiện thực thi các hàm con dưới dạng 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.

Hỗ trợ trình duyệt

  • Chrome: 94.
  • Edge: 94.
  • Firefox: phía sau một cờ.
  • Safari: không được hỗ trợ.

Nguồn

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 đặt priority.
  • '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.

Hàm saveSettings được mô tả trong trình phân tích hiệu suất của Chrome, nhưng sử dụng postTask. postTask phân tách từng hàm saveSettings chạy và ưu tiên các hàm đó để hoạt động tương tác của người dùng có cơ hội chạy mà không bị chặn.
Khi chạy saveSettings(), hàm này sẽ lên lịch cho các hàm riêng lẻ bằng postTask(). Công việc quan trọng mà người dùng nhìn thấy được lên lịch ở mức độ ưu tiên cao, trong khi công việc mà người dùng không biết được lên lịch chạy ở chế độ nền. Điều này cho phép các hoạt động tương tác của người dùng được thực thi nhanh hơn, vì công việc được chia nhỏ được ưu tiên một cách thích hợp.

Đâ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()

Hỗ trợ trình duyệt

  • Chrome: 129.
  • Edge: 129.
  • Firefox: không được hỗ trợ.
  • Safari: không được hỗ trợ.

Nguồn

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().

Ba sơ đồ mô tả các tác vụ không nhường quyền, nhường quyền và nhường quyền và tiếp tục. Nếu không có việc nhường quyền, sẽ có các tác vụ dài. Khi nhường quyền, có nhiều tác vụ ngắn hơn nhưng có thể bị các tác vụ không liên quan khác làm gián đoạn. Với việc trả về và tiếp tục, có nhiều tác vụ ngắn hơn, nhưng thứ tự thực thi của các tác vụ đó vẫn được giữ nguyên.
Khi bạn sử dụng scheduler.yield(), quá trình thực thi tác vụ sẽ tiếp tục từ nơi bị gián đoạn ngay cả sau điểm trả về.

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()

Hỗ trợ trình duyệt

  • Chrome: 87.
  • Edge: 87.
  • Firefox: không được hỗ trợ.
  • Safari: không được hỗ trợ.

Nguồn

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()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.