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

Có người yêu cầu bạn "không chặn chuỗi 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ì?

Jeremy Wagner
Jeremy Wagner

Nếu bạn đọc nhiều nội dung về hiệu suất web, thì lời khuyên để duy trì tốc độ nhanh cho ứng dụng JavaScript có xu hướng liên quan đến một số thông tin sau:

  • "Không chặn chuỗi chính."
  • "Chia nhỏ những việc cần làm dài."

Vậy có nghĩa là gì? Việc vận chuyển JavaScript ít hơn là tốt, nhưng điều đó có tự động được xem là có giao diện người dùng nhanh gọn hơn trong suốt vòng đời của trang không? Có thể, nhưng cũng có thể không.

Để hiểu được tầm quan trọng của việc tối ưu hoá các tác vụ trong JavaScript, bạn cần hiểu vai trò của các tác vụ và cách trình duyệt xử lý chúng, và bắt đầu bằng việc hiểu tác vụ là gì.

Việc cần làm 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ác tác vụ liên quan đến các tác vụ như hiển thị, phân tích cú pháp HTML và CSS, chạy mã JavaScript bạn viết và các tác vụ khác bạn có thể không kiểm soát trực tiếp được. Trong số tất cả những điều này, JavaScript mà bạn viết và triển khai cho web là một nguồn chính của các tác vụ.

Ảnh chụp màn hình một tác vụ như được mô tả 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. Nhiệm vụ nằm ở đầu ngăn xếp, với trình xử lý sự kiện nhấp chuột, lệnh gọi hàm và nhiều mục khác bên dưới. Nhiệm vụ này cũng bao gồm một số hoạt động kết xuất ở phía bên phải.
Hình ảnh mô tả một tác vụ do trình xử lý sự kiện click khởi động trong trình phân tích hiệu suất trong Công cụ của Chrome cho nhà phát triển.

Tác vụ tác động đến hiệu suất theo một số cách. Ví dụ: khi một trình duyệt tải một tệp JavaScript xuống trong quá trình khởi động, trình duyệt sẽ xếp các tác vụ vào hàng đợi để phân tích cú pháp và biên dịch JavaScript đó nhằm thực thi. Sau đó trong vòng đời của trang, các tác vụ sẽ được kích hoạt khi JavaScript của bạn 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 dựa trên JavaScript và hoạt động trong nền như thu thập số liệu phân tích). Tất cả quá trình này – ngoại trừ trình chạy 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ụ được chạy trong trình duyệt. Nó được gọi là chuỗi chính vì một lý do: nó là một chuỗi mà gần như tất cả JavaScript bạn viết đều thực hiện tác vụ.

Luồng chính chỉ có thể xử lý một tác vụ tại một thời điểm. Khi các tác vụ kéo dài quá một thời điểm nhất định (chính xác là 50 mili giây), chúng được phân loại là tác vụ dài. Nếu người dùng đang cố gắng tương tác với trang trong khi một tác vụ dài đang chạy (hoặc nếu cần phải cập nhật hiển thị quan trọng), trình duyệt sẽ bị trì hoãn trong việc xử lý công việc đó. Điều này dẫn đến độ trễ tương tác hoặc độ trễ hiển thị.

Một nhiệm vụ có thời gian 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ụ (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. Những việc cần làm dài được biểu thị bằng một hình tam giác màu đỏ ở góc của việc cần làm, với phần chặn của việc cần làm được điền bằng một mẫu sọc đỏ.

Bạn cần chia nhỏ việc cần làm. Tức là bạn phải thực hiện một nhiệm vụ dài rồi chia thành các nhiệm vụ nhỏ hơn, mất ít thời gian hơn để chạy riêng lẻ.

Một nhiệm vụ dài so với cùng một nhiệm vụ được chia thành các 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ụ được 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 về một nhiệm vụ dài so với cùng một 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 các tác vụ được chia nhỏ, trình duyệt sẽ có nhiều cơ hội hơn để phản hồi các công việc có mức độ ưu tiên cao hơn, bao gồm cả hoạt động tương tác của người dùng.

Hình ảnh mô tả việc chia nhỏ một nhiệm vụ có thể tạo điều kiện thuận lợi cho việc tương tác với người dùng như thế nào. Ở trên cùng, một tác vụ dài sẽ chặn một 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 nhỏ cho phép trình xử lý sự kiện chạy sớm hơn so với thường lệ.
Hình ảnh về những gì sẽ xảy ra với các hoạt động tương tác khi các tác vụ quá dài và trình duyệt không thể phản hồi đủ nhanh với các tác vụ 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 theo 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ể chạy. Điều này làm chậm quá trình tương tác diễn ra. Ở dưới cùng, trình xử lý sự kiện có cơ hội chạy sớm hơn. Vì trình xử lý sự kiện có cơ hội chạy giữa các tác vụ nhỏ hơn, nên trình xử lý này sẽ chạy sớm hơn so với khi phải đợi một tác vụ dài kết thúc. Trong ví dụ trên cùng, người dùng có thể nhận thấy độ trễ; ở dưới cùng, hoạt động tương tác có thể mang lại cảm giác tức thì.

Tuy nhiên, vấn đề là lời khuyên "chia nhỏ các việc cần làm dài" và "không chặn chuỗi chính" sẽ không đủ cụ thể trừ khi bạn đã biết cách thực hiện những việc đó. Đó là nội dung hướng dẫn này sẽ giải thích.

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 công việc của bạn thành các chức năng nhỏ hơn. Điều này mang lại cho bạn những lợi ích như khả năng đọc mã và khả năng bảo trì dự án tốt hơn. Việc này cũng giúp bạn viết mã kiểm thử dễ dàng hơn.

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

Trong ví dụ này, có một hàm tên là saveSettings() gọi 5 hàm trong đó để thực hiện công việc, chẳng hạn như xác thực một biểu mẫu, hiển thị danh sách màu sắc, gửi dữ liệu, v.v. Về mặt lý thuyết, cách triển khai này đã được thiết kế hợp lý. 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.

Tuy nhiên, vấn đề 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 chức năng đều chạy như một tác vụ duy nhất.

Chức năng saveSettings như được 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ả tác vụ đề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 như một phần của một nhiệm 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 nhất 50 mili giây 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 một chút — đặc biệt là trên các thiết bị bị hạn chế về tài nguyên. Dưới đây là một bộ chiến lược mà bạn có thể dùng để phân chia và sắp xếp thứ tự ưu tiên cho các nhiệm vụ.

Trì hoãn việc thực thi mã theo cách thủ công

Có một phương thức mà các nhà phát triển đã dùng để chia các tác vụ nhỏ hơn là setTimeout(). Với kỹ thuật này, bạn sẽ 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 thành 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);
}

Thao tác này sẽ hiệu quả nếu bạn có một loạt hàm cần chạy tuần tự, nhưng mã của bạn không phải lúc nào 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 vòng lặp và tác vụ đó có thể mất rất nhiều thời gian nếu bạn có hàng triệu mục.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Việc sử dụng setTimeout() ở đây khá rắc rối vì tính gọn nhẹ của nó khiến việc triển khai trở nên khó khă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 mục đều có thể được xử lý rất nhanh chóng. Tất cả đều tích hợp lại, và setTimeout() không phải là công cụ phù hợp cho công việc, ít nhất là khi được sử dụng theo cách này.

Ngoài setTimeout(), còn có một số API khác cho phép bạn trì hoãn việc thực thi mã đến tác vụ tiếp theo. Một liên quan đến việc sử dụng postMessage() để hết thời gian chờ nhanh hơn. Bạn cũng có thể chia công việc bằng requestIdleCallback(). Nhưng hãy lưu ý! requestIdleCallback() lên lịch các tác vụ ở mức ưu tiên thấp nhất có thể và chỉ trong thời gian trình duyệt ở trạng thái rảnh. Khi luồng chính bị quá tải, các tác vụ được lên lịch bằng requestIdleCallback() có thể không bao giờ chạy được.

Sử dụng async/await để tạo điểm lợi nhuận

Một cụm từ bạn sẽ thấy trong phần còn lại của hướng dẫn này là "nhường suất cho chuỗi chính" — nhưng điều đó có nghĩa là gì? Tại sao bạn nên làm việc này? Bạn nên thực hiện việc này khi nào?

Khi các tác vụ được chia nhỏ, các tác vụ khác có thể được ưu tiên tốt hơn theo lược đồ ưu tiên nội bộ của trình duyệt. Có một cách để tạo luồng chính là sử dụng kết hợp Promise để phân giải bằng lệnh gọi đến setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Trong hàm saveSettings(), bạn có thể mang lại luồng chính sau mỗi bit hoạt động nếu bạn await hàm yieldToMain() sau mỗi lệnh gọi hàm:

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à những nhiệm vụ từng khối này giờ đây được chia thành các nhiệm vụ riêng biệt.

Chức năng saveSettings tương tự được mô tả trong trình phân tích hiệu suất của Chrome, chỉ với lợi nhuận. Kết quả là những nhiệm vụ từng khối này giờ đây được chia thành 5 nhiệm vụ riêng biệt, mỗi nhiệm vụ tương ứng với một nhiệm vụ.
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.

Lợi ích của việc sử dụng phương pháp mang lại lợi nhuận dựa trên lời hứa thay vì sử dụng setTimeout() theo cách thủ công là mang lại hiệu quả công thái học tốt hơn. Điểm lợi nhuận trở nên mang tính khai báo, do đó, dễ viết, đọc và hiểu hơn.

Chỉ tạo báo cáo khi cần

Nếu bạn có rất nhiều nhiệm vụ nhưng bạn chỉ muốn hoàn thành nhiệm vụ nếu người dùng cố gắng tương tác với trang thì sao? Đó cũng chính là mục đích mà isInputPending() được tạo ra.

isInputPending() là một hàm bạn có thể chạy bất cứ lúc nào để xác định xem người dùng có đang cố gắng tương tác với một phần tử trang hay không: lệnh gọi đến isInputPending() sẽ trả về true. Nếu không, hàm này sẽ trả về false.

Giả sử bạn có một hàng đợi các tác vụ cần chạy nhưng không muốn cản trở các dữ liệu đầu vào. Mã này (sử dụng cả isInputPending() và hàm yieldToMain() tuỳ chỉnh) đảm bảo rằng dữ liệu đầu vào sẽ không bị trì hoãn trong khi người dùng đang cố gắng tương tác với trang:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Trong khi chạy, saveSettings() sẽ lặp lại các tác vụ trong hàng đợi. Nếu isInputPending() trả về true trong vòng lặp, thì saveSettings() sẽ gọi yieldToMain() để có thể xử lý dữ liệu đầu vào của người dùng. Nếu không, thao tác này sẽ chuyển tác vụ tiếp theo ra khỏi đầu hàng đợi và chạy tác vụ đó liên tục. Thao tác này sẽ thực hiện việc này cho đến khi không còn việc cần làm nào nữa.

Hình ảnh minh hoạ chức năng saveSettings đang chạy trong trình phân tích hiệu suất của Chrome. Tác vụ thu được sẽ chặn luồng chính cho đến khi isInputPending trả về true. Tại thời điểm đó, tác vụ sẽ chuyển thành luồng chính.
saveSettings() chạy một hàng đợi tác vụ cho 5 tác vụ, nhưng người dùng đã nhấp để mở một trình đơn trong khi mục công việc thứ hai đang chạy. isInputPending() chuyển sang luồng chính để xử lý hoạt động tương tác và tiếp tục chạy các tác vụ còn lại.

Sử dụng isInputPending() kết hợp với cơ chế lợi nhuận là một cách tuyệt vời để yêu cầu trình duyệt dừng bất kỳ nhiệm vụ nào mà trình duyệt đang xử lý để có thể phản hồi các tương tác quan trọng dành cho người dùng. Điều đó có thể giúp cải thiện khả năng phản hồi người dùng của trang trong nhiều trường hợp khi nhiều thao tác đang diễn ra.

Một cách khác để sử dụng isInputPending() (đặc biệt là trong trường hợp bạn lo ngại về việc cung cấp phương án dự phòng cho các trình duyệt không hỗ trợ) là dùng phương pháp dựa trên thời gian kết hợp với toán tử tạo chuỗi không bắt buộc:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Với phương pháp này, bạn sẽ có được phương án dự phòng cho các trình duyệt không hỗ trợ isInputPending(). Bằng cách sử dụng phương pháp dựa trên thời gian, trong đó sử dụng (và điều chỉnh) thời hạn để công việc sẽ được chia nhỏ khi cần thiết, cho dù theo hoạt động đầu vào của người dùng hay theo một thời điểm nhất định.

Khoảng trống trong các API hiện tại

Các API được đề cập từ trước đến nay có thể giúp bạn chia nhỏ tác vụ, nhưng chúng có một nhược điểm đáng kể: khi bạn tuân theo luồng chính bằng cách trì hoãn mã để chạy trong tác vụ tiếp theo, mã đó sẽ được thêm vào cuối hàng đợi tác vụ.

Nếu kiểm soát tất cả cá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ụ, 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. Trên thực tế, bạn không thể thực sự ưu tiên làm việc trong các môi trường như vậy. Bạn chỉ có thể chia nhỏ hoặc tận dụng các tương tác của người dùng một cách rõ ràng.

May mắn là có một API trình lập lịch biểu chuyên dụng hiện đang được phát triển để giải quyết các vấn đề này.

API trình lập lịch biểu chuyên dụng

API trình lập lịch biểu hiện cung cấp hàm postTask(). Hàm này có sẵn trong trình duyệt Chromium và trong Firefox sau cờ. postTask() cho phép lên lịch 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 lời hứa và chấp nhận chế độ cài đặt priority.

API postTask() có 3 mức độ ưu tiên mà bạn có thể sử dụng:

  • 'background' cho những nhiệm vụ có mức độ ưu tiên thấp nhất.
  • 'user-visible' cho các nhiệm 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'});
};

Ở đâ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ụ mà trình duyệt ưu tiên (chẳng hạn như hoạt động tương tác của người dùng) có thể hoạt động.

Chức năng saveSettings như được mô tả trong trình phân tích hiệu suất của Chrome, nhưng việc sử dụng postTask. postTask sẽ chia tách từng hàm saveSettings chạy và ưu tiên các chức năng này để một 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() 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 mà người dùng trực tiếp sử dụng được lên lịch ở mức độ ưu tiên cao, còn công việc mà người dùng không biết sẽ được lên lịch chạy trong 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ỏ mức độ ưu tiên hợp lý.

Đây là ví dụ đơn giản về cách sử dụng postTask(). Bạn có thể tạo thực thể cho nhiều đối tượng TaskController 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 nhiều thực thể TaskController khi cần.

Lợi nhuận tích hợp có tiếp tục qua scheduler.yield

Một phần được đề xuất cho API trình lập lịch biểu là scheduler.yield, một API được thiết kế riêng để tạo luồng chính trong trình duyệt hiện có sẵn để dùng thử dưới dạng bản dùng thử theo nguyên gốc. Cách sử dụng của hàm này giống với hàm yieldToMain() được minh hoạ trước đó trong bài viết 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();
  }
}

Bạn sẽ lưu ý rằng mã ở trên hầu như đã quen thuộc, nhưng thay vì sử dụng yieldToMain(), bạn gọi và await scheduler.yield().

Ba sơ đồ mô tả các nhiệm vụ không có lợi nhuận, không có lợi nhuận, có lợi nhuận và tiếp tục. Không có lợi nhuận, có những nhiệm vụ dài. Với năng suất làm việc, có nhiều nhiệm vụ ngắn hơn, nhưng có thể bị gián đoạn bởi các nhiệm vụ không liên quan khác. Với khả năng sinh lời và tiếp tục, có nhiều tác vụ ngắn hơn, nhưng thứ tự thực thi của chúng được giữ nguyên.
Hình ảnh trực quan về quá trình thực thi tác vụ không có lợi nhuận, có lợi nhuận, có tạo ra và tiếp tục. Khi được 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, ngay 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 mang lại ở giữa một nhóm các nhiệm vụ, thì các nhiệm vụ đã lên lịch khác sẽ tiếp tục theo cùng thứ tự sau điểm lợi nhuận. Việc này giúp tránh việc mã trong các tập lệnh của bên thứ ba chiếm dụng thứ tự thực thi mã của bạn.

Kết luận

Việc quản lý tác vụ rất khó, nhưng làm như vậy sẽ giúp trang của bạn phản ứng nhanh hơn với các 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 bạn quản lý và ưu tiên công việc. Thay vào đó, có một số kỹ thuật khác nhau. Xin nhắc lại, sau đây là những điểm chính mà bạn cần cân nhắc khi quản lý tác vụ:

  • Chuyển đến luồng chính cho các thao tác quan trọng mà người dùng thực hiện.
  • Sử dụng isInputPending() để nhường lại luồng chính khi người dùng đang cố gắng tương tác với trang.
  • Ưu tiên việc cần làm bằng postTask().
  • Cuối cùng, hãy làm ít thao tác nhất có thể trong hàm.

Với một hoặc nhiều công cụ trong số này, bạn sẽ có thể sắp xếp cấu trúc công việc trong ứng dụng để ưu tiên nhu cầu của người dùng, trong khi vẫn đảm bảo hoàn tất được những công việc ít quan trọng hơn. Điều này sẽ tạo ra trải nghiệm người dùng tốt hơn, tức là 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 vì đã rà soát kỹ thuật cho bài viết này.

Hình ảnh chính được lấy từ Unsplash, do Amirali Mirhashemian cung cấp.