Sử dụng web worker để chạy JavaScript ngoài luồng chính của trình duyệt

Cấu trúc ngoài luồng chính có thể cải thiện đáng kể độ tin cậy và trải nghiệm người dùng của ứng dụng.

Trong 20 năm qua, web đã phát triển đáng kể từ các tài liệu tĩnh với một vài kiểu và hình ảnh thành các ứng dụng động và phức tạp. Tuy nhiên, có một điều hầu như vẫn không thay đổi: chúng tôi chỉ có một luồng trên mỗi thẻ trình duyệt (trừ một số ngoại lệ) để thực hiện công việc hiển thị trang web và chạy JavaScript.

Do đó, luồng chính đã trở nên quá tải. Khi các ứng dụng web trở nên phức tạp hơn, luồng chính sẽ trở thành nút thắt cổ chai đáng kể đối với hiệu suất. Tệ hơn nữa, thời gian chạy mã trên luồng chính cho một người dùng nhất định gần như không thể dự đoán được vì các tính năng của thiết bị có tác động rất lớn đến hiệu suất. Tình trạng khó dự đoán này sẽ ngày càng gia tăng khi người dùng truy cập web từ ngày càng đa dạng các thiết bị, từ điện thoại phổ thông bị hạn chế về mặt giới hạn cho đến các máy tính mạnh mẽ có tốc độ làm mới cao.

Nếu muốn các ứng dụng web phức tạp đáp ứng đáng tin cậy các nguyên tắc về hiệu suất như Các chỉ số quan trọng về trang web (dựa trên dữ liệu thực nghiệm về nhận thức và tâm lý của con người), chúng ta cần có cách để thực thi mã ngoài luồng chính (OMT).

Tại sao nên sử dụng nhân viên web?

Theo mặc định, JavaScript là một ngôn ngữ đơn luồng chạy các tác vụ trên luồng chính. Tuy nhiên, trình chạy web cung cấp một loại lối thoát khỏi luồng chính bằng cách cho phép nhà phát triển tạo các luồng riêng biệt để xử lý công việc ngoài luồng chính. Mặc dù phạm vi của worker web bị hạn chế và không cung cấp quyền truy cập trực tiếp vào DOM, nhưng worker web có thể mang lại lợi ích to lớn nếu có công việc đáng kể cần thực hiện và nếu không thì luồng chính sẽ bị quá tải.

Trong trường hợp có liên quan đến Các chỉ số quan trọng chính của trang web, việc chạy tác vụ ngoài luồng chính có thể mang lại lợi ích. Cụ thể, việc giảm tải công việc từ luồng chính sang worker web có thể làm giảm tình trạng tranh chấp cho luồng chính, từ đó cải thiện chỉ số phản hồi Số lượt tương tác đến lượt vẽ tiếp theo (INP) của trang. Khi luồng chính có ít công việc cần xử lý hơn, luồng này có thể phản hồi nhanh hơn các hoạt động tương tác của người dùng.

Việc giảm công việc của luồng chính (đặc biệt là trong quá trình khởi động) cũng có thể mang lại lợi ích cho Thời gian hiển thị nội dung lớn nhất (LCP) bằng cách giảm các tác vụ dài. Việc kết xuất một phần tử LCP cần có thời gian của luồng chính, cho việc kết xuất văn bản hoặc hình ảnh (đây là các phần tử LCP thường xuyên và phổ biến). Bằng cách giảm tổng công việc của luồng chính, bạn có thể đảm bảo rằng phần tử LCP của trang ít có khả năng bị chặn bởi công việc tốn kém mà worker web có thể xử lý.

Tạo luồng bằng worker web

Các nền tảng khác thường hỗ trợ công việc song song bằng cách cho phép bạn cung cấp một hàm cho luồng, hàm này chạy song song với phần còn lại của chương trình. Bạn có thể truy cập vào cùng một biến từ cả hai luồng và quyền truy cập vào các tài nguyên dùng chung này có thể được đồng bộ hoá bằng mutex và semaphore để ngăn chặn tình trạng tranh chấp.

Trong JavaScript, chúng ta có thể nhận được chức năng tương tự từ worker web. Worker web đã xuất hiện từ năm 2007 và được hỗ trợ trên tất cả các trình duyệt chính kể từ năm 2012. Worker web chạy song song với luồng chính, nhưng không giống như luồng hệ điều hành, worker web không thể chia sẻ biến.

Để tạo một trình chạy web, hãy truyền một tệp vào hàm khởi tạo worker để bắt đầu chạy tệp đó trong một luồng riêng:

const worker = new Worker("./worker.js");

Giao tiếp với nhân viên web bằng cách gửi thông báo qua API postMessage. Truyền giá trị thông báo dưới dạng tham số trong lệnh gọi postMessage, sau đó thêm trình nghe sự kiện thông báo vào worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Để gửi thông báo trở lại luồng chính, hãy sử dụng cùng một API postMessage trong worker web và thiết lập trình nghe sự kiện trên luồng chính:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Phải thừa nhận rằng phương pháp này có phần hạn chế. Trước đây, worker web chủ yếu được dùng để di chuyển một công việc nặng ra khỏi luồng chính. Việc cố gắng xử lý nhiều thao tác bằng một worker web sẽ nhanh chóng trở nên khó khăn: bạn không chỉ phải mã hoá các tham số mà còn phải mã hoá thao tác trong thông báo, đồng thời bạn phải ghi chép để so khớp phản hồi với yêu cầu. Có thể đó là lý do khiến worker web chưa được sử dụng rộng rãi hơn.

Nhưng nếu chúng ta có thể loại bỏ một số khó khăn khi giao tiếp giữa luồng chính và trình thực thi web, thì mô hình này có thể phù hợp cho nhiều trường hợp sử dụng. Và thật may là có một thư viện chỉ giúp bạn làm việc đó!

Comlink là một thư viện có mục tiêu cho phép bạn sử dụng worker web mà không cần phải suy nghĩ về thông tin chi tiết của postMessage. Comlink cho phép bạn chia sẻ các biến giữa worker web và luồng chính gần giống như các ngôn ngữ lập trình khác hỗ trợ tạo luồng.

Bạn thiết lập Comlink bằng cách nhập Comlink vào một worker web và xác định một tập hợp các hàm để hiển thị cho luồng chính. Sau đó, bạn nhập Comlink trên luồng chính, gói worker và truy cập vào các hàm đã hiển thị:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

Biến api trên luồng chính hoạt động giống như biến trong worker web, ngoại trừ việc mọi hàm đều trả về một lời hứa cho một giá trị thay vì chính giá trị đó.

Bạn nên chuyển mã nào sang worker web?

Nhân viên web không có quyền truy cập vào DOM và nhiều API như WebUSB, WebRTC hoặc Âm thanh web, vì vậy, bạn không thể đưa các phần của ứng dụng dựa vào quyền truy cập như vậy vào một worker. Tuy nhiên, mỗi đoạn mã nhỏ được chuyển sang một worker sẽ giúp tăng khoảng đệm trên luồng chính cho những nội dung phải có trong đó, chẳng hạn như cập nhật giao diện người dùng.

Một vấn đề đối với các nhà phát triển web là hầu hết các ứng dụng web đều dựa vào khung giao diện người dùng như Vue hoặc React để sắp xếp mọi thứ trong ứng dụng; mọi thứ đều là một thành phần của khung này và vốn được gắn liền với DOM. Điều đó có vẻ như sẽ khiến việc di chuyển sang cấu trúc OMT trở nên khó khăn.

Tuy nhiên, nếu chúng ta chuyển sang một mô hình trong đó các vấn đề về giao diện người dùng được tách biệt với các vấn đề khác, chẳng hạn như quản lý trạng thái, thì worker web có thể khá hữu ích ngay cả với các ứng dụng dựa trên khung. Đó chính xác là phương pháp mà PROXX thực hiện.

PROXX: nghiên cứu điển hình về OMT

Nhóm Google Chrome đã phát triển PROXX dưới dạng một bản sao của Minesweeper, đáp ứng các yêu cầu của Ứng dụng web tiến bộ, bao gồm cả khả năng hoạt động ngoại tuyến và mang đến trải nghiệm hấp dẫn cho người dùng. Đáng tiếc là các phiên bản đầu của trò chơi hoạt động kém trên các thiết bị bị hạn chế như điện thoại phổ thông. Điều này khiến nhóm nhận ra rằng luồng chính là nút thắt cổ chai.

Nhóm đã quyết định sử dụng nhân viên web để tách trạng thái hình ảnh của trò chơi khỏi logic của nó:

  • Luồng chính xử lý việc kết xuất ảnh động và hiệu ứng chuyển đổi.
  • Worker web xử lý logic trò chơi, chỉ mang tính toán.

OMT có những hiệu ứng thú vị đối với hiệu suất của điện thoại di động có tính năng của PROXX. Trong phiên bản không phải OMT, giao diện người dùng bị treo trong 6 giây sau khi người dùng tương tác với giao diện. Không có phản hồi nào và người dùng phải đợi đủ 6 giây thì mới có thể làm việc khác.

Thời gian phản hồi giao diện người dùng trong phiên bản không phải OMT của PROXX.

Tuy nhiên, trong phiên bản OMT, trò chơi mất 12 giây để hoàn tất quá trình cập nhật giao diện người dùng. Mặc dù có vẻ như việc này làm giảm hiệu suất, nhưng thực tế là nó giúp tăng phản hồi cho người dùng. Tình trạng chậm xảy ra vì ứng dụng đang gửi nhiều khung hơn so với phiên bản không phải OMT, phiên bản này không gửi khung nào cả. Do đó, người dùng biết rằng có điều gì đó đang xảy ra và có thể tiếp tục chơi khi giao diện người dùng cập nhật, làm cho trò chơi có cảm giác tốt hơn đáng kể.

Thời gian phản hồi của giao diện người dùng trong phiên bản OMT của PROXX.

Đây là một sự đánh đổi có chủ ý: chúng tôi mang đến cho người dùng những thiết bị bị hạn chế trải nghiệm cảm thấy tốt hơn mà không gây bất lợi cho người dùng thiết bị cao cấp.

Ý nghĩa của cấu trúc OMT

Như ví dụ về PROXX cho thấy, OMT giúp ứng dụng của bạn chạy ổn định trên nhiều thiết bị hơn, nhưng không giúp ứng dụng chạy nhanh hơn:

  • Bạn chỉ di chuyển công việc từ luồng chính chứ không giảm công việc.
  • Mức hao tổn giao tiếp bổ sung giữa trình chạy web và luồng chính đôi khi có thể làm cho mọi thứ chậm hơn một chút.

Cân nhắc các ưu/nhược điểm

Vì luồng chính có thể tự do xử lý các hoạt động tương tác của người dùng như cuộn trong khi JavaScript đang chạy, nên sẽ có ít khung hình bị bỏ lỡ hơn mặc dù tổng thời gian chờ có thể lâu hơn một chút. Để người dùng chờ một chút thì họ nên bỏ khung hình vì biên độ sai số sẽ nhỏ hơn đối với những khung hình bị rớt: tình trạng bỏ khung hình xảy ra tính bằng mili giây, trong khi bạn có hàng trăm mili giây trước khi người dùng thấy được thời gian chờ.

Do không thể dự đoán hiệu suất trên các thiết bị, mục tiêu của kiến trúc OMT thực sự là về giảm rủi ro — giúp ứng dụng của bạn hoạt động hiệu quả hơn trước những điều kiện thời gian chạy có thể thay đổi nhiều — không phải về lợi ích về hiệu suất của việc song song hoá. Sự gia tăng khả năng thích ứng và cải tiến cho trải nghiệm người dùng sẽ xứng đáng với sự đánh đổi nhỏ về tốc độ.

Lưu ý về công cụ

Worker web chưa phổ biến, vì vậy, hầu hết các công cụ mô-đun (như webpackRollup) đều không hỗ trợ worker web ngay từ đầu. (Tuy nhiên, Parcel thì có!) May mắn thay, có các trình bổ trợ giúp nhân viên web hoạt động với webpack và Rollup:

Tóm tắt

Để đảm bảo ứng dụng của chúng tôi đáng tin cậy và dễ tiếp cận nhất có thể, đặc biệt là trong một thị trường ngày càng toàn cầu hoá, chúng tôi cần hỗ trợ các thiết bị bị hạn chế. Đây là cách hầu hết người dùng truy cập vào web trên toàn cầu. OMT đưa ra một cách đầy hứa hẹn để tăng hiệu suất trên các thiết bị như vậy mà không ảnh hưởng xấu đến người dùng thiết bị cao cấp.

Ngoài ra, OMT còn mang lại các lợi ích phụ:

  • Phương thức này sẽ chuyển chi phí thực thi JavaScript sang một luồng riêng.
  • Nó di chuyển chi phí phân tích cú pháp, nghĩa là giao diện người dùng có thể khởi động nhanh hơn. Điều đó có thể làm giảm Nội dung đầu tiên Paint hoặc thậm chí là Thời gian tương tác, từ đó có thể giúp bạn tăng điểm Lighthouse.

Nhân viên web không nhất thiết phải sợ hãi. Các công cụ như Comlink đang giúp giảm bớt công việc cho worker và biến worker trở thành một lựa chọn khả thi cho nhiều ứng dụng web.