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ể giúp cải thiện đáng kể độ tin cậy và trải nghiệm người dùng của ứng dụng.

Tiếng Surma
Tiếng Surma

Trong 20 năm qua, web đã phát triển mạnh mẽ từ tài liệu tĩnh với một vài kiểu và hình ảnh sang các ứng dụng động, phức tạp. Tuy nhiên, có một điều vẫn phần lớn không thay đổi: chúng tôi chỉ có một chuỗi trên 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 của chúng tôi.

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 trở thành nút thắt cổ chai đáng kể đối với hiệu suất. Tệ hơn là 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 dự đoán được vì các chức năng của thiết bị có ảnh hưởng rất lớn đến hiệu suất. Tình trạng khó dự đoán đó sẽ chỉ gia tăng khi người dùng truy cập web từ một số lượng thiết bị ngày càng đa dạng, từ điện thoại phổ thông có giới hạn cực cao cho đến các máy chủ chốt sở hữu công suất và tốc độ làm mới cao.

Nếu muốn các ứng dụng web tinh vi đá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ã khỏi chuỗi chính (OMT).

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

Theo mặc định, JavaScript là ngôn ngữ đơn luồng chạy các tác vụ trên chuỗi chính. Tuy nhiên, web worker cung cấp một giải pháp thoát khỏi luồng chính bằng cách cho phép nhà phát triển xoay vòng 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 nhân viên 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 họ có thể cực kỳ có lợi nếu có công việc đáng kể cần được thực hiện mà có thể sẽ làm ngập luồng chính.

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

Tác vụ giảm bớt hoạt động của luồng chính (đặc biệt là trong giai đoạn khởi động) cũng có thể mang lại lợi ích đối với 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. Khi hiển thị phần tử LCP, thời gian của luồng chính là thời gian của luồng chính (để hiển thị 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), đồng thời bằng cách giảm toàn bộ công việc của luồng chính, bạn có thể đảm bảo phần tử LCP của trang ít bị chặn bởi công việc tốn kém mà nhân viên web có thể xử lý.

Tạo luồng với trình thực thi web

Các nền tảng khác thường hỗ trợ tính năng làm việc song song bằng cách cho phép bạn cung cấp cho một luồng 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, quyền truy cập vào các tài nguyên dùng chung này có thể được đồng bộ hoá với mutex và semaphores để ngăn điều kiện tranh đấu.

Trong JavaScript, chúng ta có thể nhận được chức năng gần tương tự từ web worker, đã 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 này không thể dùng chung các biến.

Để tạo một trình thực thi web, hãy truyền một tệp vào hàm khởi tạo của trình thực thi. Thao tác này sẽ 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 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 công 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 nhọc ra khỏi chuỗi 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 gặp khó khăn: bạn không chỉ mã hoá các thông số mà còn cả thao tác trong thông báo và phải ghi chép để so 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 nhân viên 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 khi 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 cho nhiều trường hợp sử dụng. Thật may là có một thư viện thực hiện việc đó!

Comlink là một thư viện có mục tiêu cho phép bạn sử dụng web worker mà không phải suy nghĩ về chi tiết của postMessage. Comlink cho phép bạn chia sẻ biến giữa các 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ợ 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à 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 web worker, 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 cho trình chạy web?

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ể đưa các phần của ứng dụng phụ thuộc vào quyền truy cập đó vào một worker. Tuy nhiên, mỗi đoạn mã nhỏ được chuyển đến một worker sẽ mua thêm khoảng trống trên luồng chính cho những nội dung ở đó, 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 một 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 và do đó vốn có mối liên kết 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 trong đó các vấn đề về giao diện người dùng 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ì 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à cách mà PROXX đã áp dụng.

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 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 làm việc khi không có mạng và mang đến trải nghiệm hấp dẫn cho người dùng. Thật không may, 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ị hạn chế như điện thoại phổ thông, khiến nhóm nhận ra rằng chuỗi chính là nút thắt cổ chai.

Nhóm phát triển đã 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:

  • Luồng chính xử lý quá trình kết xuất ảnh động và hiệu ứng chuyển tiếp.
  • Trình chạy web xử lý logic trò chơi, vốn hoàn toàn chỉ là tính toán.

OMT có những tác động thú vị đến hiệu suất của điện thoại phổ thô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. 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 PROXX không phải OMT.

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ù việc này nghe có vẻ giảm hiệu suất, nhưng trên thực tế, việc này khiến người dùng nhận được nhiều ý kiến phản hồi hơn. Tình trạng chậm trễ xảy ra do ứng dụng đang vận chuyển nhiều khung hình hơn so với phiên bản không phải OMT, và ứng dụng không vận chuyển bất kỳ khung hình nào. Do đó, người dùng biết rằng đ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, khiến trò chơi cảm thấy 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à sự đánh đổi có ý thức: chúng tôi mang đến cho người dùng các thiết bị bị hạn chế trải nghiệm cảm thấy tốt hơn mà không phạt người dùng thiết bị cao cấp.

Hệ quả của kiến 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 nhanh hơn:

  • Bạn chỉ di chuyển công việc khỏi luồng chính chứ không giảm công việc.
  • Chi phí 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 đôi chút.

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

Vì luồng chính xử lý miễn phí các hoạt động tương tác của người dùng, chẳng hạn như cuộn trong khi JavaScript đang chạy, nên số khung hình bị bỏ qua ít hơn mặc dù tổng thời gian chờ có thể lâu hơn một chút. Khiến người dùng chờ một chút thì nên bỏ khung hình vì biên độ lỗi sẽ nhỏ hơn đối với những khung hình bị rớt: việc bỏ một khung hình diễn 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 biết được thời gian chờ.

Do tính không thể dự đoán về hiệu suất 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 hoạt động mạnh mẽ hơn khi đối mặt với những điều kiện thời gian chạy có nhiều biến động, chứ không phải về lợi ích hiệu suất của tính năng song song. Sự gia tăng về khả năng thích ứng và những cải tiến về trải nghiệm người dùng có giá trị hơn bất kỳ sự đánh đổi nhỏ nào về tốc độ.

Lưu ý về công cụ

Nhân viên làm việc trên web vẫn chưa phải là công cụ phổ biến, do đó, hầu hết các công cụ mô-đun (như webpackRollup) đều không hỗ trợ chúng ngay từ đầu. (Tuy nhiên, Parcel vẫn dùng được!) Thật may là có các trình bổ trợ giúp trình thực thi web hoạt động với webpack và Rollup:

Tổng hợp

Để đảm bảo các ứng dụng của chúng tôi đáng tin cậy và dễ truy cập nhất có thể, đặc biệt là trong một thị trường ngày càng toàn cầu, chúng tôi cần phải hỗ trợ các thiết bị bị hạn chế. Đây là cách mà hầu hết người dùng truy cập web trên toàn cầu. OMT mang đến 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 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ụ:

Nhân viên web không phải là điều đáng sợ. Các công cụ như Comlink giúp công nhân hoạt động và giúp chúng trở thành lựa chọn khả thi cho nhiều ứng dụng web.

Hình ảnh chính từ Unsplash của James Peacock.