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 phức tạp, linh hoạt. Tuy nhiên, có một điều vẫn không thay đổi: chúng ta chỉ có một luồng trên mỗi thẻ trình duyệt (ngoại trừ một số trường hợp) để thực hiện việc kết xuất các trang web và chạy JavaScript.

Do đó, luồng chính đã trở nên quá tải. Khi các ứng dụng web ngày càng phức tạp, luồng chính sẽ trở thành một điểm tắc nghẽn đáng kể đối với hiệu suất. Tệ hơn nữa, thời gian cần thiết để chạy mã trên luồng chính cho một người dùng nhất định là gần như hoàn toàn không thể dự đoán được vì khả năng của thiết bị có ảnh hưởng rất lớn đến hiệu suất. Tính không thể dự đoán đó sẽ chỉ tăng lên khi người dùng truy cập vào web từ một loạt thiết bị ngày càng đa dạng, từ điện thoại phổ thông siêu hạn chế đến các thiết bị hàng đầu có tốc độ làm mới cao và hiệu suất 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ư Chỉ số quan trọng chính của 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 dùng web worker?

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, web worker 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 web worker bị hạn chế và không cung cấp quyền truy cập trực tiếp vào DOM, nhưng chúng có thể mang lại lợi ích to lớn nếu có nhiều việc cần làm mà nếu không thì sẽ làm quá tải luồng chính.

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

Giảm bớt công việc trên luồng chính (đặc biệt là trong quá trình khởi động) cũng mang lại lợi ích tiềm năng 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 đòi hỏi thời gian của luồng chính (để kết xuất văn bản hoặc hình ảnh, đây là những phần tử LCP thường xuyên và phổ biến). Bằng cách giảm tổng thể lượ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 những công việc tốn kém mà một web worker có thể xử lý.

Tạo luồng bằng web worker

Các nền tảng khác thường hỗ trợ hoạt động song song bằng cách cho phép bạn cung cấp một hàm cho một luồng, hàm này sẽ 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 xung đột.

Trong JavaScript, chúng ta có thể nhận được chức năng tương tự từ các worker trên web. Các worker này đã 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 từ năm 2012. Web worker chạy song song với luồng chính, nhưng không giống như luồng hệ điều hành, chúng không thể chia sẻ các biến.

Để tạo một web worker, hãy truyền một tệp đến hàm khởi tạo worker. Hàm này sẽ bắt đầu chạy tệp đó trong một luồng riêng biệt:

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

Giao tiếp với web worker bằng cách gửi thông báo bằng API postMessage. Truyền giá trị thông báo làm tham số trong lệnh gọi postMessage, sau đó thêm một 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 web worker và thiết lập một 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, các worker trên web chủ yếu được dùng để di chuyển một phần 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 web worker duy nhất sẽ nhanh chóng trở nên khó quản lý: 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 phải ghi lại để so khớp các phản hồi với yêu cầu. Độ phức tạp đó có thể là lý do khiến web worker chưa được áp dụng rộng rãi hơn.

Nhưng nếu chúng ta có thể giảm bớt một số khó khăn trong việc giao tiếp giữa luồng chính và các worker trên web, thì mô hình này có thể phù hợp với nhiều trường hợp sử dụng. May mắn thay, có một thư viện làm được điều đó!

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

Bạn thiết lập Comlink bằng cách nhập Comlink vào một web worker 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 vào luồng chính, bao bọc worker và có quyền truy cập vào các hàm được 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 trên 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 web worker?

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

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

Tuy nhiên, nếu chúng ta chuyển sang một mô hình trong đó các mối lo ngại về giao diện người dùng tách biệt với các mối lo ngại khác, chẳng hạn như quản lý trạng thái, thì các worker trên 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à cách tiếp cận được áp dụng cho PROXX.

PROXX: một 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 trò chơi Dò mìn đáp ứng các yêu cầu của Ứng dụng web tiến bộ, bao gồm cả việc hoạt động khi không có mạng và mang lại trải nghiệm hấp dẫn cho người dùng. Rất tiếc, các phiên bản đầu của trò chơi hoạt động kém hiệu quả trên các thiết bị bị hạn chế như điện thoại cơ bản, điều này khiến nhóm nhận ra rằng luồng chính là một điểm nghẽn.

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

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

OMT có những tác động thú vị đến hiệu suất của PROXX trên điện thoại cơ bản. Trong phiên bản không phải OMT, giao diện người dùng sẽ 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 và người dùng phải đợi đủ 6 giây trước khi có thể làm việc khác.

Thời gian phản hồi của 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ư hiệu suất bị giảm, nhưng điều này thực sự giúp tăng phản hồi cho người dùng. Tình trạng chậm xảy ra do ứng dụng đang gửi nhiều khung hình 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 hình nào). 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, giúp trò chơi trở nên hấp dẫn 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 thiết bị có tài nguyên hạn chế một trải nghiệm tốt hơn mà không làm ảnh hưởng đến người dùng thiết bị cao cấp.

Hàm ý 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 loại 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 khỏi luồng chính chứ không giảm bớt công việc.
  • Mức hao tổn giao tiếp bổ sung giữa web worker và luồng chính đôi khi có thể khiến mọi thứ chậm hơn một chút.

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

Vì luồng chính có thể xử lý các hoạt động tương tác của người dùng (chẳng hạn như thao tác cuộn) trong khi JavaScript đang chạy, nên sẽ có ít khung hình bị bỏ qua hơn mặc dù tổng thời gian chờ có thể dài hơn một chút. Việc khiến người dùng chờ đợi một chút sẽ tốt hơn là làm rơi khung hình vì sai số của khung hình bị rơi nhỏ hơn: khung hình bị rơi xảy ra trong vài mili giây, trong khi bạn có hàng trăm mili giây trước khi người dùng nhận thấy thời gian chờ.

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

Lưu ý về công cụ

Web worker 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ợ chúng ngay khi xuất xưởng. (Parcel thì có!) May mắn thay, có những trình bổ trợ giúp các web worker hoạt động với webpack và Rollup:

Tóm tắt

Để đảm bảo các ứ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ị có tài nguyên hạn chế. Đây là cách mà hầu hết người dùng truy cập vào web trên toàn cầu. OMT là một cách đầy hứa hẹn để tăng hiệu suất trên những thiết bị như vậy mà không ảnh hưởng tiêu cực đến người dùng thiết bị cao cấp.

Ngoài ra, OMT còn có những lợi ích thứ cấp:

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

Trình chạy web không đáng sợ như bạn nghĩ. Các công cụ như Comlink đang giúp các trình chạy hoạt động hiệu quả hơn và trở thành lựa chọn phù hợp cho nhiều ứng dụng web.