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ì?

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.

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ư 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.

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, khiến kết quả hiển thị cho người dùng khi chạy hàm không xuất hiện cho đến khi tất cả đều hoàn tất.
Một hàm saveSettings() gọi năm hàm. Công việc này được chạy trong một tác vụ nguyên khối dài, chặn mọi phản hồi hình ảnh cho đến khi cả 5 hàm hoàn tất.

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

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

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();
}
Hàm saveSettings như mô tả trong trình phân tích hiệu suất của Chrome, hiện được chia thành hai tác vụ. Tác vụ đầu tiên gọi hai hàm, sau đó trả về, cho phép bố cục và hoạt động vẽ diễn ra và đưa ra phản hồi rõ ràng cho người dùng. Do đó, sự kiện nhấp chuột sẽ hoàn tất trong 64 mili giây nhanh hơn nhiều. Tác vụ thứ hai gọi ba hàm cuối cùng.
Quá trình thực thi hàm saveSettings() hiện được chia thành hai tác vụ. Do đó, bố cục và vẽ có thể chạy giữa các tác vụ, giúp người dùng phản hồi hình ảnh nhanh hơn, được đo lường bằng hoạt động tương tác với con trỏ ngắn hơn nhiều.

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.

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, sẽ 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(), phần tiếp tục sẽ tiếp tục từ nơi bạn đã dừng lại trước khi chuyển sang các tác vụ khác.

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.

Một loạt các hàm công việc, hiển thị trong bảng hiệu suất của Công cụ của Chrome cho nhà phát triển, với quá trình thực thi được chia thành nhiều tác vụ
Các công việc được phân thành nhiều tác vụ.

Không sử dụng isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

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