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 số công ty đạt được chức năng giống như chia sẻ màn hình chỉ bằng công nghệ trình duyệt. Theo kinh nghiệm của tôi, việc triển khai VNC chỉ bằng 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 cân nhắc và nhiều thách thức cần vượt qua. Chuyển tiếp vị trí con trỏ chuột, chuyển tiếp thao tác gõ phím và đạt được khả năng vẽ lại màu 24 bit đầy đủ ở tốc độ 60 khung hình/giây chỉ là một vài trong số các vấn đề.

Đang ghi lại nội dung thẻ

Nếu loại bỏ được những điểm phức tạp của tính năng 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 một thẻ trình duyệt, thì vấn đề sẽ được đơ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.) gửi "khung" đó qua mạng. Về cơ bản, chúng ta cần một cách để chụp nhanh DOM và chia sẻ DOM đó.

Việc chia sẻ rất dễ dàng. Websocket 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ó khăn hơn nhiều. Các dự án như html2canvas đã giải quyết vấn đề 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 cực kỳ chậm. Bạn sẽ may mắn nếu đạt được thông lượng 1 khung hình/giây, chứ chưa nói đến mức 60 khung hình/giây mà bạn mong muốn.

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

Phương pháp 1: Mutation Observer + 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 ấy 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 các thay đổi đối với trang và gửi các điểm khác biệt đến người xem bằng cách sử dụng websocket. Khi người dùng di chuyển hoặc tương tác với trang, trình quan sát sẽ nhận thấy 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 đột biến của Rafael. Điều này giúp duy trì hiệu suất. Toàn bộ trang không được gửi cho mọi khung hình.

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

Phương thức 2: Blob từ HTMLDocument + WebSocket nhị phân

Phương pháp tiếp theo là phương pháp mà gần đây tôi mới nhận ra. Cách 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 pháp này tạo một bản sao Blob của toàn bộ HTMLDocument và gửi bản sao đó qua một websocket nhị phân. Sau đây là từng bước thiết lập:

  1. Viết lại tất cả URL trên trang thành URL tuyệt đối. Điều này giúp ngăn các thành phần hình ảnh tĩnh và CSS chứa đườ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, không thể chọn và ngăn thao tác cuộn bằng CSS pointer-events: 'none';user-select:'none';overflow:hidden;
  4. Ghi lại vị trí cuộn hiện tại của trang và thêm các vị trí đó làm thuộc tính data-* trên trang trùng lặp.
  5. Tạo new Blob() từ .outerHTML của bản sao.

Mã sẽ có dạng như sau (Tôi đã đơn giản hoá mã 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ó giao thứ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ị hỏng khi xem trong ngữ cảnh của URL blob (ví dụ: từ một nguồn gốc khác).

Một điểm điều chỉnh cuối cùng mà 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 cũng sẽ thấy nội dung tương ứng. Để làm việc đó, tôi lưu trữ các vị trí scrollX và scrollY 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 sẽ được chèn 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 giả lập thao tác cuộn tạo cảm giác rằng chúng ta đã chụp ảnh màn hình một phần của trang gốc, trong khi thực tế, chúng ta đã sao chép toàn bộ trang và chỉ định vị lại trang đó. #clever

Bản minh hoạ

Nhưng để chia sẻ thẻ, chúng ta cần liên tục chụp thẻ và gửi cho người xem. Để làm được điều đó, tôi đã viết một máy chủ websocket Node nhỏ, ứng dụng và bookmarklet minh hoạ quy trình. Nếu bạn không quan tâm đến mã, thì đây là một video ngắn về những nội dung trong thực tế:

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 này gây lãng phí và ví dụ về Mutation Observer làm rất tốt. 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 xem xét.

Phương thức 3: API tiện ích Chrome + WebSocket nhị phân

Tại Google I/O 2012, tôi đã minh hoạ 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 thủ đoạn lừa đảo. Tính năng này yêu cầu API tiện ích của Chrome, chứ không chỉ là HTML5.

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

  1. Chụp thẻ hiện tại dưới dạng dataURL .png. Tiện ích Chrome có một API cho việc đó 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) đến trình xem bằng cách sử dụng websocket nhị phân bằng cách đặt 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 png và gửi khung hình thông 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 của trò chơi này tốt đến bất ngờ, nhưng vẫn có thể cải thiện hơn nữa. Một điểm cải tiến là loại bỏ 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 hàm này trả về một Blob hoặc Typed Array, chúng ta có thể gửi trực tiếp thông qua websocket thay vì tự chuyển đổi thành Blob. Vui lòng gắn dấu sao cho crbug.com/32498 để điều đó xảy ra!

Phương pháp 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 một API WebRTC Tab Content Capture để chia sẻ nội dung thẻ:

Cho đến khi phương thức này sẵn sàng, chúng ta chỉ có thể dùng các phương thức từ 1 đến 3.

Kết luận

Vậy là công nghệ web ngày nay có thể chia sẻ thẻ trình duyệt!

Nhưng… bạn nên cân nhắc kỹ về tuyên bố đó. Mặc dù gọn gàng, nhưng các kỹ thuật trong bài viết này không mang lại 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ả những điều đó sẽ thay đổi nhờ nỗ lực Chụp nội dung thẻ WebRTC, nhưng cho đến khi điều đó trở thành hiện thực, chúng ta vẫn phải sử dụng trình bổ trợ 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!