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

Kiến 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 động. Tuy nhiên, có một điều hầu như không thay đổi: chúng ta chỉ có một luồng cho mỗi thẻ trình duyệt (với 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 đã bị quá tải nghiêm trọng. Khi ứng dụng web ngày càng phức tạp, luồng chính sẽ trở thành điểm tắc nghẽn đáng kể về 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ư 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ể đoán trước đó sẽ chỉ tăng lên khi người dùng truy cập vào web từ một nhóm thiết bị ngày càng đa dạng, từ điện thoại phổ thông có nhiều hạn chế cho đến các thiết bị cao cấp có tốc độ làm mới cao.

Nếu muốn các ứng dụng web phức tạp đáp ứng một cách đáng tin cậy các nguyên tắc về hiệu suất như Core Web Vitals (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ã bên ngoài luồng chính (OMT).

Tại sao nên sử dụng trình chạy web?

Theo mặc định, JavaScript là một ngôn ngữ đơn luồng chạy 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 trình chạy web bị giới hạn 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 rất lớn nếu có nhiều công việc cần thực hiện, nếu không sẽ làm quá tải luồng chính.

Đối với Core Web Vitals, việc chạy công việc 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 trình chạy web có thể làm giảm sự tranh chấp cho luồng chính, từ đó có thể cải thiện chỉ số về khả năng 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 công việc cần xử lý hơn, luồng này có thể phản hồi nhanh hơn với các hoạt động tương tác của người dùng.

Việc giảm bớt công việc của 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 Nội dung lớn nhất hiển thị (LCP) bằng cách giảm bớt các tác vụ cần nhiều thời gian. Việc kết xuấ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, vốn là các phần tử LCP thường gặp và phổ biến). Bằng cách giảm tổng khối 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 công việc tốn kém mà một trình chạy web có thể xử lý.

Tạo luồng bằng trình chạy 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 cho một luồng một hàm, 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, đồng thời có thể đồng bộ hoá quyền truy cập vào các tài nguyên dùng chung này bằng mutex và semaphore để ngăn chặn tình huống tương tranh.

Trong JavaScript, chúng ta có thể nhận được chức năng tương tự từ trình chạy web, vốn đã 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. Trình chạy 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, trình chạy 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 đến hàm khởi tạo trình chạy. 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 trình chạy web bằng cách gửi thông báo bằng postMessage API. 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 trình chạy 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, trình chạy 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 trình chạy web duy nhất 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 phải ghi sổ để khớp các phản hồi với yêu cầu. Sự phức tạp đó có thể là lý do khiến trình chạy web chưa được áp dụng rộng rãi hơn.

Tuy nhiên, nếu chúng ta có thể loại bỏ một số khó khăn trong việc giao tiếp giữa luồng chính và trình chạy web, thì mô hình này có thể rất 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 giúp bạn sử dụng trình chạy web mà không cần phải bận tâm đến các chi tiết của postMessage. Comlink cho phép bạn chia sẻ các biến giữa trình chạy web và luồng chính gần giống như các ngôn ngữ lập trình khác hỗ trợ phân luồng.

Bạn thiết lập Comlink bằng cách nhập Comlink vào một trình chạy 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à 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 trình chạy 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 một trình chạy web?

Trình chạy web 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ể đưa các phần của ứng dụng dựa vào quyền truy cập đó vào trình chạy. Tuy nhiên, mỗi đoạn mã nhỏ được chuyển sang một worker sẽ giúp giải phóng thêm không gian trên luồng chính cho những nội dung 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ĩ gắn liền với DOM. Điều đó có vẻ khiến việc di chuyển sang kiến 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 tách biệt các vấn đề về giao diện người dùng khỏi các vấn đề khác (chẳng hạn như quản lý trạng thái), thì trình chạy 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 được áp dụng với PROXX.

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 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 tiên của trò chơi hoạt động kém hiệu quả trên các thiết bị có cấu hình thấp 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à điểm tắc nghẽn.

Nhóm đã quyết định sử dụng trình chạy web để 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 trình thực thi web xử lý logic trò chơi, vốn hoàn toàn là 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 phổ thông. Trong phiên bản không có 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ó ý kiến phản hồi và người dùng phải đợi đủ 6 giây mới 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 có 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 một lần cập nhật giao diện người dùng. Mặc dù điều này có vẻ như làm giảm hiệu suất, nhưng thực tế lại giúp tăng cườ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 hoàn toàn 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 mượt mà 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ó cấu hình thấp một trải nghiệm tốt hơn mà không ảnh hưởng đến 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ỉ đang 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 trình chạy web 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/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ố khung hình bị rớt sẽ ít hơn, mặc dù tổng thời gian chờ có thể lâu hơn một chút. Việc khiến người dùng phải chờ một chút sẽ tốt hơn là bỏ qua khung hình vì biên độ sai số nhỏ hơn đối với khung hình bị bỏ qua: việc bỏ qua khung hình xảy ra trong 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 trên các thiết bị không thể dự đoán được, nên mục tiêu của cấu trúc OMT thực sự là giảm thiểu rủi ro (giúp ứng dụng của bạn hoạt động mạnh mẽ hơn trong các điều kiện thời gian chạy có nhiều biến động) chứ không phải là lợi ích về hiệu suất của việc xử lý song song. Sự gia tăng khả năng phục hồi và những cải tiến đối với trải nghiệm người dùng là hoàn toàn xứng đáng với 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ư webpack và Rollup) đều không hỗ trợ sẵn web worker. (Tuy nhiên, Parcel thì có!) May mắn là có các trình bổ trợ giúp trình chạy web hoạt động với webpack và Rollup:

Tóm tắt

Để đảm bảo ứng dụng của chúng ta đá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 ta cần hỗ trợ các thiết bị có giới hạn. Đâ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 là một cách 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 có các lợi ích phụ:

  • API này chuyển chi phí thực thi JavaScript sang một luồng riêng biệt.
  • Việc này giúp giảm 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 Nội dung đầu tiên hiển thị hoặc thậm chí là Thời gian tương tác, từ đó có thể làm tăng điểm Lighthouse của bạn.

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