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, phức tạp. Tuy nhiên, có một điều vẫn không thay đổi nhiều: chúng ta chỉ có một luồng cho mỗi thẻ trình duyệt (có một số trường hợp ngoại lệ) để thực hiện 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 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 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 chức năng của thiết bị có tác động rất lớn đến hiệu suất. Sự không thể dự đoán đó sẽ chỉ tăng lên khi người dùng truy cập vào web từ nhiều thiết bị ngày càng đa dạng, từ điện thoại phổ thông bị hạn chế tối đa đến các thiết bị hàng đầu có công suất cao, 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 worker trê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.
Đối với Chỉ số quan trọng chính của trang web, 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 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 – để 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à 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 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 worker web, 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:
const worker = new Worker("./worker.js");
Giao tiếp với worker web bằng cách gửi thông báo bằng 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.
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à worker web, thì mô hình này có thể phù hợp với nhiều trường hợp sử dụng. Và may mắn thay, có một thư viện thực hiện chính xác việc đó!
Comlink: giúp worker web làm việc ít hơn
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 đượ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 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?
Worker 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ể đặt các phần của ứng dụng dựa vào quyền truy cập đó vào 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 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 đã 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 đượ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 Minesweeper đá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. Đá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 worker 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.
- Worker web xử lý logic trò chơi, chỉ mang tính toán.
OMT đã có những tác động thú vị đến 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.
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 diễn 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 tốt hơn đáng kể.
Đây là một sự đánh đổi có chủ ý: chúng tôi mang đến cho người dùng thiết bị bị hạn chế một trải nghiệm cảm thấy tốt hơn mà không trừng phạt 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.
- Đôi khi, mức hao tổn giao tiếp bổ sung giữa worker web và luồng chính có thể làm mọi thứ chậm hơn một chút.
Cân nhắc các yếu tố đánh đổi
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. Bạn nên để người dùng chờ một chút thay vì bỏ khung hình vì biên độ sai số nhỏ hơn đối với các khung hình bị bỏ: việc bỏ khung hình 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 hoạt động mạnh mẽ hơn trước các điều kiện thời gian chạy thay đổi nhiều – chứ không phải về lợi ích về hiệu suất của việc chạy song song. 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ụ
Worker web 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ợ worker web ngay từ đầu. (Tuy nhiên, Parcel thì có!) May mắn là có các trình bổ trợ để giúp worker web hoạt động với webpack và Rollup:
- worker-plugin cho webpack
- rollup-plugin-off-main-thread cho 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 cung cấp 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 bất lợi đế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.
- Việc này sẽ 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 Thời gian hiển thị nội dung đầu tiên hoặc thậm chí là Thời gian phản hồi, từ đó có thể làm tăng điểm Lighthouse.
Trình chạy web không phải là một khái niệm khó hiểu. Các công cụ như Comlink đang giúp giảm bớt công việc cho worker và giúp worker trở thành một lựa chọn khả thi cho nhiều ứng dụng web.