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

Có người yêu cầu bạn "không chặn luồng chính" và "chia nhỏ các công việc dài", nhưng việc này có ý nghĩa gì?

Những lời khuyên phổ biến để giúp ứng dụng JavaScript hoạt động nhanh 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ụ đó.

Nhiệm 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 câu trên, 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 bắt đầu trong, hiển thị trong trình phân tích hiệu suất của Chrome Công cụ cho nhà phát triển.

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 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 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 đ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ụ 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 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ẫu 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 nhiệm vụ dài được biểu thị bằng một hình tam giác màu đỏ ở góc của nhiệm vụ, với phần chặn của nhiệm vụ được tô bằng hoa văn sọc đỏ theo đường 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ụ chia nhỏ là 5 hộp nhỏ hơn 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 không cho trình xử lý sự kiện chạy cho đến khi tác vụ đó kết thúc. Ở 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ì sẽ xảy ra với lượt tương tác khi các nhiệm 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 nhiệm vụ dài hơn được chia thành các nhiệm 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ễ. Ở 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ờ đâ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ý 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 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, 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(). 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 5 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ị 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ẽ 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 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 sẽ gây ra vấn đề về công thái học cho 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à 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ể dùng setTimeout để nhường luồng 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à nhiệm vụ từng nguyên khối nay được chia thành 5 nhiệm vụ riêng biệt, mỗi nhiệm vụ cho một chức năng.
Hàm saveSettings() giờ đây thực thi các hàm con dưới dạng 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, 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ụ — nhưng 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ê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 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 trình duyệt ưu tiên (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 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 với việc tiếp tục sử dụng API scheduler.yield()

Hỗ trợ trình duyệt

  • Chrome: 129.
  • Cạnh: 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ế đặ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 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 đã dừng lại, kể cả sau điểm lợi nhuận.

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 nhóm 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ố 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 đá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()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.
  • Ư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 í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ẽ 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 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.