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

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.

Nhập một tác vụ như được mô tả trong bản phân tích hiệu suất của Công cụ cho nhà phát triển của Chrome. Tác vụ nằm ở đầu ngăn xếp, với một trình xử lý sự kiện nhấp chuột, một lệnh gọi hàm và nhiều 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 ở phía 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 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.

Một tác vụ dài trong trình phân tích hiệu suất của Công cụ cho nhà phát triển của Chrome. 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ụ mất nhiều thời gian 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.

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

Một nhiệm vụ dài so với cùng một nhiệm vụ được chia thành một nhiệm 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 minh hoạ một nhiệm vụ dài so với chính nhiệm vụ đó được chia thành 5 nhiệm vụ ngắn 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.

Mô tả việc chia nhỏ một công việc có thể tạo điều kiện thuận lợi cho người dùng tương tác như thế nào. Ở 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. Ở phía dưới cùng, tác vụ đã chia nhỏ cho phép trình xử lý sự kiện chạy sớm hơn dự kiế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, 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ụ.

Chức năng saveSettings như mô tả trong trình phân tích hiệu suất của Chrome. Trong khi hàm cấp cao nhất gọi 5 hàm khác, 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 này 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ả 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 thao tác quan trọng dành cho người dùng diễn ra trước các nhiệm 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ụ để 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 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.

Cùng một chức năng saveSettings được mô tả trong trình phân tích hiệu suất của Chrome, chỉ với kết quả. 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() hiện 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 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.

Hỗ trợ trình duyệt

  • 94
  • 94
  • x

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 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 đặ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'});
};

Ở đâ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.

Chức năng saveSettings như mô tả trong trình phân tích hiệu suất của Chrome, nhưng khi sử dụng postTask. postTask sẽ chia từng chức năng saveSettings để chạy và ưu tiên các chức năng này sao cho 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 saveSettings() được chạy, hàm này sẽ lên lịch cho các hàm riêng lẻ bằng cách sử dụng postTask(). Công việc quan trọng dành cho người dùng đượ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 sẽ đượ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 thực thi nhanh hơn vì công việc vừa được chia nhỏ được ưu tiên phù hợp.

Đâ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ẽ dùng await scheduler.yield().

Ba biểu đồ mô tả các nhiệm vụ không tạo ra, tạo ra và tiếp tục. Nếu không mang lại lợi nhuận, bạn sẽ phải đối mặt với nhiều nhiệm vụ mất nhiều thời gian. Với năng suất, bạn sẽ có thêm nhiều nhiệm vụ ngắn hơn, nhưng có thể bị các nhiệm vụ khác không liên quan làm gián đoạn. Với khả năng tạo ra và tiếp tục, sẽ có nhiều tác vụ có thời gian ngắn hơn, nhưng thứ tự thực thi của chúng 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 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()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.