Chia sẻ màn hình một thẻ trình duyệt trong HTML5?

Trong vài năm qua, tôi đã giúp một vài công ty có được chức năng giống như chia sẻ màn hình chỉ bằng các công nghệ trình duyệt. Theo kinh nghiệm của tôi, việc triển khai VNC chỉ riêng trong các công nghệ nền tảng web (tức là không có trình bổ trợ) là một vấn đề khó khăn. Có rất nhiều điều cần xem xét và rất nhiều thử thách cần vượt qua. Một số vấn đề nằm ở việc chuyển tiếp vị trí con trỏ chuột, chuyển tiếp thao tác nhấn phím và đạt được màu 24 bit đầy đủ ở tốc độ 60 khung hình/giây.

Ghi lại nội dung trên thẻ

Nếu chúng ta loại bỏ sự phức tạp của việc chia sẻ màn hình truyền thống và tập trung vào việc chia sẻ nội dung của thẻ trình duyệt, vấn đề sẽ đơn giản hoá đáng kể thành a.) chụp thẻ hiển thị ở trạng thái hiện tại và b.) truyền "khung" đó qua đường truyền. Về cơ bản, chúng ta cần một cách để tổng quan nhanh và chia sẻ DOM.

Phần chia sẻ này rất dễ chia sẻ. Websockets rất có khả năng gửi dữ liệu ở nhiều định dạng (chuỗi, JSON, nhị phân). Phần chụp nhanh là một vấn đề khó hơn nhiều. Các dự án như html2canvas đã xử lý việc chụp ảnh màn hình HTML bằng cách triển khai lại công cụ kết xuất của trình duyệt...trong JavaScript! Một ví dụ khác là Google Feedback, mặc dù đây không phải là nguồn mở. Những loại dự án này rất thú vị, nhưng cũng rất chậm. Bạn sẽ may mắn có được thông lượng 1 khung hình/giây, ít hơn nhiều so với mức 60 khung hình/giây như mong muốn.

Bài viết này thảo luận một số giải pháp bằng chứng về khái niệm mà tôi yêu thích cho tính năng "chia sẻ màn hình" trên một thẻ.

Phương pháp 1: Trình quan sát đột biến + WebSocket

Một phương pháp phản chiếu thẻ đã được +Rafael Weinstein minh hoạ vào đầu năm nay. Kỹ thuật của anh sử dụng Mutation Observers và WebSocket.

Về cơ bản, thẻ mà người trình bày đang chia sẻ sẽ theo dõi những thay đổi đối với trang và gửi điểm khác biệt cho người xem bằng một websocket. Khi người dùng cuộn hoặc tương tác với trang, đối tượng tiếp nhận dữ liệu sẽ nhận biết những thay đổi này và báo cáo lại cho người xem bằng thư viện tóm tắt về đột biến của Rafael. Việc này giúp đảm bảo mọi thứ hoạt động hiệu quả. Không phải tất cả các khung đều được gửi toàn bộ trang.

Như Rafael chỉ ra trong video, đây chỉ là một bằng chứng về khái niệm. Tuy nhiên, tôi nghĩ đây là một cách hợp lý để kết hợp một tính năng mới của nền tảng như Mutation Observers với một tính năng cũ hơn như Websockets.

Phương pháp 2: Blob từ một HTMLDocument + Binary WebSocket

Phương pháp tiếp theo này là một phương pháp mà tôi mới thấy gần đây. Phương pháp này tương tự như phương pháp Mutation Observers, nhưng thay vì gửi các điểm khác biệt tóm tắt, phương thức này tạo một bản sao Blob của toàn bộ HTMLDocument và gửi qua một websocket nhị phân. Dưới đây là cách thiết lập:

  1. Viết lại tất cả URL trên trang thành giá trị tuyệt đối. Điều này giúp ngăn các thành phần CSS và hình ảnh tĩnh chứa các đường liên kết bị hỏng.
  2. Sao chép phần tử tài liệu của trang: document.documentElement.cloneNode(true);
  3. Đặt bản sao ở chế độ chỉ có thể đọc, không thể chọn và ngăn cuộn bằng CSS pointer-events: 'none';user-select:'none';overflow:hidden;
  4. Chụp vị trí cuộn hiện tại của trang và thêm chúng dưới dạng thuộc tính data-* trên bản sao.
  5. Tạo new Blob() từ .outerHTML của bản sao.

Mã trông giống như sau (Tôi đã đơn giản hóa từ nguồn đầy đủ):

function screenshotPage() {
    // 1. Rewrite current doc's imgs, css, and script URLs to be absolute before
    // we duplicate. This ensures no broken links when viewing the duplicate.
    urlsToAbsolute(document.images);
    urlsToAbsolute(document.querySelectorAll("link[rel='stylesheet']"));
    urlsToAbsolute(document.scripts);

    // 2. Duplicate entire document tree.
    var screenshot = document.documentElement.cloneNode(true);

    // 3. Screenshot should be readyonly, no scrolling, and no selections.
    screenshot.style.pointerEvents = 'none';
    screenshot.style.overflow = 'hidden';
    screenshot.style.userSelect = 'none'; // Note: need vendor prefixes

    // 4. … read on …

    // 5. Create a new .html file from the cloned content.
    var blob = new Blob([screenshot.outerHTML], {type: 'text/html'});

    // Open a popup to new file by creating a blob URL.
    window.open(window.URL.createObjectURL(blob));
}

urlsToAbsolute() chứa các biểu thức chính quy đơn giản để viết lại URL tương đối/không có lược đồ thành URL tuyệt đối. Điều này là cần thiết để hình ảnh, css, phông chữ và tập lệnh không bị lỗi khi được xem trong ngữ cảnh URL blob (ví dụ: từ một nguồn gốc khác).

Một lần điều chỉnh cuối cùng tôi thực hiện là thêm tính năng hỗ trợ cuộn. Khi người trình bày cuộn trang, người xem sẽ theo dõi nội dung đó. Để làm việc đó, tôi lưu trữ các vị trí scrollXscrollY hiện tại dưới dạng thuộc tính data-* trên HTMLDocument trùng lặp. Trước khi Blob cuối cùng được tạo, một đoạn mã JS được đưa vào để kích hoạt khi tải trang:

// 4. Preserve current x,y scroll position of this page. See addOnPageLoad().
screenshot.dataset.scrollX = window.scrollX;
screenshot.dataset.scrollY = window.scrollY;

// 4.5. When screenshot loads (e.g. in blob URL), scroll it to the same location
// of this page. Do this by appending a window.onDOMContentLoaded listener
// which pulls out the screenshot (dupe's) saved scrollX/Y state on the DOM.
var script = document.createElement('script');
script.textContent = '(' + addOnPageLoad_.toString() + ')();'; // self calling.
screenshot.querySelector('body').appendChild(script);

// NOTE: Not to be invoked directly. When the screenshot loads, scroll it
// to the same x,y location of original page.
function addOnPageLoad() {
    window.addEventListener('DOMContentLoaded', function(e) {
    var scrollX = document.documentElement.dataset.scrollX || 0;
    var scrollY = document.documentElement.dataset.scrollY || 0;
    window.scrollTo(scrollX, scrollY);
    });

Việc làm giả thao tác cuộn mang lại ấn tượng rằng chúng tôi đã chụp ảnh màn hình một phần của trang gốc, trong khi thực tế, chúng tôi đã sao chép toàn bộ trang và chỉ đặt lại vị trí trang đó. #clever

Bản minh hoạ

Tuy nhiên, đối với việc chia sẻ thẻ, chúng tôi cần liên tục thu thập và gửi thẻ đó cho người xem. Để làm được điều đó, tôi đã viết một máy chủ, ứng dụng và dấu trang web Node nhỏ để minh hoạ quy trình. Nếu bạn không quan tâm đến mã nguồn, thì sau đây là một đoạn video ngắn về cách hoạt động của đoạn mã này:

Các điểm cải tiến trong tương lai

Một cách tối ưu hoá là không sao chép toàn bộ tài liệu trên mọi khung hình. Điều đó thật lãng phí và là một việc mà ví dụ Mutation Observer rất hiệu quả. Một điểm cải tiến khác là xử lý hình nền CSS tương đối trong urlsToAbsolute(). Đó là điều mà tập lệnh hiện tại không cân nhắc.

Phương pháp 3: API Tiện ích của Chrome + Binary WebSocket

Tại Google I/O 2012, tôi đã trình bày một phương pháp khác để chia sẻ màn hình nội dung của một thẻ trình duyệt. Tuy nhiên, đây là một trò gian lận. Tính năng này yêu cầu API Tiện ích của Chrome: không phải HTML5 thuần tuý.

Nguồn cho nội dung này cũng nằm trên GitHub, nhưng nội dung chính là:

  1. Chụp thẻ hiện tại dưới dạng tệp .png dataURL. Tiện ích của Chrome có API cho chrome.tabs.captureVisibleTab() đó.
  2. Chuyển đổi dataURL thành Blob. Xem trình trợ giúp convertDataURIToBlob().
  3. Gửi từng Blob (khung hình) cho người xem qua websocket nhị phân bằng cách thiết lập socket.responseType='blob'.

Ví dụ:

Dưới đây là mã để chụp ảnh màn hình thẻ hiện tại dưới dạng tệp png và gửi khung hình qua websocket:

var IMG_MIMETYPE = 'images/jpeg'; // Update to image/webp when crbug.com/112957 is fixed.
var IMG_QUALITY = 80; // [0-100]
var SEND_INTERVAL = 250; // ms

var ws = new WebSocket('ws://…', 'dumby-protocol');
ws.binaryType = 'blob';

function captureAndSendTab() {
    var opts = {format: IMG_MIMETYPE, quality: IMG_QUALITY};
    chrome.tabs.captureVisibleTab(null, opts, function(dataUrl) {
    // captureVisibleTab returns a dataURL. Decode it -> convert to blob -> send.
    ws.send(convertDataURIToBlob(dataUrl, IMG_MIMETYPE));
    });
}

var intervalId = setInterval(function() {
    if (ws.bufferedAmount == 0) {
    captureAndSendTab();
    }
}, SEND_INTERVAL);

Các điểm cải tiến trong tương lai

Tốc độ khung hình trong video này rất tốt, thậm chí còn tốt hơn nữa. Một cải tiến là loại bỏ mức hao tổn khi chuyển đổi dataURL thành Blob. Rất tiếc, chrome.tabs.captureVisibleTab() chỉ cung cấp cho chúng ta một dataURL. Nếu nó trả về một Blob hoặc Typed Array, thì chúng ta có thể gửi trực tiếp thông qua websocket thay vì tự chuyển đổi sang một Blob. Vui lòng gắn dấu sao crbug.com/32498 để thực hiện việc này!

Phương thức 4: WebRTC – tương lai thực sự

Cuối cùng nhưng không kém phần quan trọng!

Tương lai của tính năng chia sẻ màn hình trong trình duyệt sẽ được hiện thực hoá bằng WebRTC. Vào ngày 14 tháng 8 năm 2012, nhóm đã đề xuất API WebRTC Tab Content Capture (Thu thập nội dung thẻ WebP) để chia sẻ nội dung thẻ:

Cho đến khi người này sẵn sàng, chúng ta sẽ chỉ còn phương pháp 1-3.

Kết luận

Do đó, việc chia sẻ thẻ trình duyệt là hoàn toàn khả thi với công nghệ web ngày nay!

Nhưng...bạn chỉ cần nhận định vậy thôi. Mặc dù ngắn gọn, nhưng các kỹ thuật trong bài viết này vẫn chưa đạt được trải nghiệm chia sẻ tuyệt vời cho người dùng, theo cách này hay cách khác. Tất cả sẽ thay đổi nhờ nỗ lực Ghi lại nội dung thẻ WebRTC, nhưng cho đến khi điều đó thành hiện thực, chúng tôi sẽ chỉ còn lại các plugin trình duyệt hoặc các giải pháp hạn chế như những giải pháp được đề cập ở đây.

Bạn có kỹ thuật khác? Đăng bình luận!