淺析 Web 錄屏技術(shù)方案與實(shí)現(xiàn)
錄屏技術(shù)方案與實(shí)現(xiàn)
https://www.zoo.team/article/webrtc-screen

前言
隨著互聯(lián)網(wǎng)技術(shù)飛速發(fā)展,網(wǎng)頁(yè)錄屏技術(shù)已趨于成熟。例如可將錄屏技術(shù)運(yùn)用到在線考試中,實(shí)現(xiàn)遠(yuǎn)程監(jiān)考、屏幕共享以及錄屏等;而在我們開(kāi)發(fā)人員研發(fā)過(guò)程中,對(duì)于部分偶發(fā)事件,異常監(jiān)控系統(tǒng)僅僅只能告知程序出錯(cuò),而不能清晰的告知錯(cuò)誤的復(fù)現(xiàn)路徑,而錄屏技術(shù)或許能幫我們定位并復(fù)現(xiàn)問(wèn)題。那么本文將從有感錄屏和無(wú)感錄屏兩方面給讀者分享一下錄屏這項(xiàng)技術(shù),希望可以幫助你對(duì)網(wǎng)頁(yè)錄屏有一個(gè)初步認(rèn)識(shí)。
什么是有感錄屏?
有感錄屏一般指通過(guò)獲得用戶的授權(quán)或者通知用戶接下來(lái)的操作將會(huì)被錄制成視頻,并且在錄制過(guò)程中,用戶有權(quán)關(guān)閉中斷錄屏。即無(wú)論在錄屏前還是錄屏的過(guò)程中,用戶都始終能夠決定錄屏能否進(jìn)行。
基于 WebRTC 的有感錄屏
常見(jiàn)的有感錄屏方案主要是通過(guò)?WebRTC?(https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API) 錄制。WebRTC?是一套基于音視軌的實(shí)時(shí)數(shù)據(jù)流傳播的技術(shù)方案。由瀏覽器提供的原生 API navigator.mediaDevices.getDisplayMedia?方法實(shí)現(xiàn)提示用戶選擇和授權(quán)捕獲展示的內(nèi)容或窗口,進(jìn)而將獲取 stream (錄制的屏幕音視流)。我們可以對(duì) stream 進(jìn)行轉(zhuǎn)化處理,轉(zhuǎn)成相對(duì)應(yīng)的媒體數(shù)據(jù),并將其數(shù)據(jù)存儲(chǔ)。后續(xù)需要回溯該次錄制內(nèi)容時(shí),則取出媒體數(shù)據(jù)進(jìn)行播放。
具體的有感錄屏流程如下:

實(shí)現(xiàn)初始化錄屏和數(shù)據(jù)存儲(chǔ)
使用?navigator.mediaDevices.getDisplayMedia?初始化錄屏,觸發(fā)彈窗獲取用戶授權(quán),效果圖如下所示:

實(shí)現(xiàn) WebRTC 初始化錄屏核心代碼如下:
const?tracks?=?[];?//?媒體數(shù)據(jù)
const?options?=?{
??mimeType?:?"video/webm;?codecs?=?vp8",?//?媒體格式
};
let?mediaRecorder;
//?初始化請(qǐng)求用戶授權(quán)監(jiān)控
navigator.mediaDevices.getDisplayMedia(constraints).then((stream)?=>?{
??//?對(duì)音視流進(jìn)行操作
??startFunc(stream);
});
//?開(kāi)始錄制方法
function?start(stream)?{
??//?創(chuàng)建?MediaRecorder?的實(shí)例對(duì)象,對(duì)指定的媒體流進(jìn)行錄制
??mediaRecorder?=?new?MediaRecorder(stream,?options);
??//?當(dāng)生成媒體流數(shù)據(jù)時(shí)觸發(fā)該事件,回調(diào)傳參?event?指本次生成處理的媒體數(shù)據(jù)
??mediaRecorder.ondataavailable?=?event?=>?{
?????if(event?.data?.size?>?0){
??????tracks.push(event.data);?//?存儲(chǔ)媒體數(shù)據(jù)
????}
??};
??mediaRecorder.start();
??console.log("************開(kāi)始錄制************")
};
//?結(jié)束錄制方法
function?stop()?{
??mediaRecorder.stop();
??console.log("************錄制結(jié)束************")
}
//?定義constraints數(shù)據(jù)類型
interface?constraints?{
??audio:?boolean?|?MediaTrackConstraints,?//?指定是否請(qǐng)求音軌或者約束軌道屬性值的對(duì)象
??video:?boolean?|?MediaTrackConstraints,?//?指定是否請(qǐng)求視頻軌道或者約束軌道屬性值的對(duì)象
}
實(shí)現(xiàn)錄屏回溯
獲取該次錄屏的媒體數(shù)據(jù),可以將其轉(zhuǎn)成 blob 對(duì)象,并且生成 blob對(duì)象的 url 字符串,再賦值 video.src 中,便可以回放到錄制結(jié)果,回溯的視頻效果如下:

錄屏回溯方法的核心代碼如下所示:
//?回放錄制內(nèi)容
function?replay()?{
??const?video?=?document.getElementById("video");
??const?blob?=?new?Blob(tracks,?{type?:?"video/webm"});
??video.src?=?window.URL.createObjectURL(blob);
??video.srcObject?=?null;
??video.controls?=?true;
??video.play();
}
實(shí)現(xiàn)實(shí)時(shí)直播功能
由于存儲(chǔ)的媒體數(shù)據(jù)是實(shí)時(shí)的,因此可以利用該數(shù)據(jù)實(shí)現(xiàn)直播功能。通過(guò)給 video.srcObject 賦值媒體流可以實(shí)現(xiàn)直播功能。
實(shí)現(xiàn)實(shí)時(shí)直播核心代碼如下:
//?直播
function?live()?{
??const?video?=?document.getElementById("video");
??video.srcObject?=?window.stream;
??video.controls?=?true;
??video.play();
}
瀏覽器兼容性

什么是無(wú)感錄屏?
無(wú)感錄屏指在用戶無(wú)感知的情況,對(duì)用戶在頁(yè)面上的操作進(jìn)行錄制。實(shí)現(xiàn)上與有感錄制區(qū)別在于,無(wú)感錄制通常是利用記錄頁(yè)面的 DOM 來(lái)進(jìn)行錄制。常見(jiàn)的有 canvas 截圖繪制視頻和 rrweb 錄制等方案。
canvas 截圖繪制視頻
用戶在瀏覽頁(yè)面時(shí),可以通過(guò) canvas 繪制多個(gè) DOM 快照截圖,再將多個(gè)截圖合并成一段錄屏視頻。但是考慮到假設(shè)視頻幀數(shù)為 30 幀,幀數(shù)代表著每秒所需的截圖數(shù)量,為了視頻的流暢和清晰,每張截圖為 400 KB ,那么當(dāng)視頻長(zhǎng)度為 1 分鐘,則需要上傳 703.125 MB 的資源,這么大的帶寬浪費(fèi)無(wú)疑會(huì)造成性能,甚至影響用戶體驗(yàn),不推薦使用,也不在此詳細(xì)介紹本方案實(shí)現(xiàn)。
rrweb 錄制
rrweb?(record and replay the web) 是一個(gè)對(duì)于 DOM 錄制的支持性非常好,利用現(xiàn)代瀏覽器所提供的強(qiáng)大 API 錄制并回放任意 web 界面中的用戶操作,能夠?qū)㈨?yè)面 DOM 結(jié)構(gòu)通過(guò)相應(yīng)算法高效轉(zhuǎn)換 JSON 數(shù)據(jù)的開(kāi)源庫(kù)。相比較于使用 canvas 繪制錄屏,rrweb 在保證錄制不掉幀的基礎(chǔ)上,讓網(wǎng)絡(luò)傳輸數(shù)據(jù)更加快速和輕量化,極大地優(yōu)化了網(wǎng)絡(luò)性能。
rrweb?開(kāi)源庫(kù)主要由?rrweb-snapshot、rrweb?和?rrweb-play?三部分組成,并且提供了動(dòng)作篩選,數(shù)據(jù)加密、數(shù)據(jù)壓縮、數(shù)據(jù)切片、屏蔽元素等功能。

rrweb-snapshot
rrweb-snapshot?提供?snapshot?和?rebuild?兩個(gè) API,分別實(shí)現(xiàn)生成可序列化虛擬 DOM 快照的數(shù)據(jù)結(jié)構(gòu)和將其數(shù)據(jù)結(jié)構(gòu)重建為對(duì)應(yīng) DOM 節(jié)點(diǎn)的兩個(gè)功能。
snapshot?將 DOM 及其狀態(tài)轉(zhuǎn)化為可序列化的數(shù)據(jù)結(jié)構(gòu)并添加唯一標(biāo)識(shí) id,使得一個(gè) id 映射對(duì)應(yīng)的一個(gè) DOM 節(jié)點(diǎn),方便后續(xù)以增量的方式來(lái)操作。
首先需要通過(guò)深拷貝 document 生成初始化 DOM 快照。
//?深拷貝?document?節(jié)點(diǎn)
const?docEl?=?document.documentElement.cloneNode(true);
//?回放時(shí)再將深拷貝的節(jié)點(diǎn)掛在回去即可
document.replaceChild(docEl,?document.documentElement);
由于獲取到的 DOM 對(duì)象并不是可序列化的,因此仍需要將其轉(zhuǎn)成特定的文本格式(如 JSON)進(jìn)行傳輸,否則無(wú)法做到遠(yuǎn)程錄制。在實(shí)現(xiàn) DOM 快照可序列化的過(guò)程中,還需對(duì)數(shù)據(jù)進(jìn)行特殊處理:
將相對(duì)路徑改成絕對(duì)路徑; 將頁(yè)面引用的樣式改成內(nèi)聯(lián)樣式; 禁止腳本運(yùn)行,被錄制頁(yè)面中的所有 JavaScript 都不應(yīng)該被執(zhí)行。把? ?轉(zhuǎn)成??;由于部分表單(如? ?)不會(huì)把值暴露在 html 中,故需讀取表單的 value 值。
雖然已經(jīng)能夠獲取到全量的 DOM 對(duì)象,但是無(wú)法將增量快照中被交互的 DOM 節(jié)點(diǎn)和現(xiàn)已有的 DOM 節(jié)點(diǎn)關(guān)聯(lián)上,所以還需要給 DOM 添加一層映射關(guān)系(id => Node),后續(xù) DOM 節(jié)點(diǎn)的更新都通過(guò)該 id 來(lái)記錄并對(duì)應(yīng)到完整的 DOM 節(jié)點(diǎn)中。
如下是初始時(shí)獲取到的 DOM 節(jié)點(diǎn):
<html>
??<body>
????<header>
????header>
??body>
html>
通過(guò)遍歷整個(gè) DOM 樹(shù),以 Node 節(jié)點(diǎn)為單位,給每個(gè)遍歷到的 Node 都添加了唯一標(biāo)識(shí) id ,生成全量序列化的 DOM 對(duì)象快照 。以下是序列化后的數(shù)據(jù)結(jié)構(gòu)示意:
{
??"type":?"Document",
??"childNodes":?[
????{
??????"type":?"Element",
??????"tagName":?"html",
??????"attributes":?{},
??????"childNodes":?[
????????{
??????????"type":?"Element",
??????????"tagName":?"head",
??????????"attributes":?{},
??????????"childNodes":?[],
??????????"id":?3
????????},
????????{
??????????"type":?"Element",
??????????"tagName":?"body",
??????????"attributes":?{},
??????????"childNodes":?[
????????????{
??????????????"type":?"Text",
??????????????"textContent":?"\n????",
??????????????"id":?5
????????????},
????????????{
??????????????"type":?"Element",
??????????????"tagName":?"header",
??????????????"attributes":?{},
??????????????"childNodes":?[
????????????????{
??????????????????"type":?"Text",
??????????????????"textContent":?"\n????",
??????????????????"id":?7
????????????????}
??????????????],
??????????????"id":?6
????????????}
??????????],
??????????"id":?4
????????}
??????],
??????"id":?2
????}
??],
??"id":?1
}
rebuild
將?snapshot?記錄的初始化快照的數(shù)據(jù)結(jié)構(gòu),繼而通過(guò)遞歸給每個(gè)節(jié)點(diǎn)添加屬性來(lái)重建 DOM ,生成可序列化的 DOM 節(jié)點(diǎn)快照。
rrweb
rrweb?提供?record?和?replay?兩個(gè) API,分別實(shí)現(xiàn)記錄所有增量數(shù)據(jù)和將記錄的數(shù)據(jù)按照時(shí)間戳回放的兩個(gè)功能。
record
通過(guò)觸發(fā)視圖的變化和 DOM 結(jié)構(gòu)的改變(如 DOM 節(jié)點(diǎn)的刪減和屬性值的變化)來(lái)劫持增量變化數(shù)據(jù)存入 JSON 對(duì)象中,每個(gè)增量數(shù)據(jù)對(duì)應(yīng)一個(gè)時(shí)間戳,這些數(shù)據(jù)稱之為 Oplog(operations log)。

視圖的變化可通過(guò)全局事件監(jiān)聽(tīng)和事件代理方法收集增量數(shù)據(jù),而這些事件大多是和用戶的操作行為相關(guān),能夠觸發(fā)這類事件的動(dòng)作如 DOM 節(jié)點(diǎn)或內(nèi)容的變動(dòng)、鼠標(biāo)移動(dòng)或交互、頁(yè)面或元素滾動(dòng)、鍵盤交互和窗口大小變動(dòng)。
DOM 結(jié)構(gòu)的改變可以通過(guò)瀏覽器提供的 MutationObserver (https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver) 接口能監(jiān)視,觸發(fā)參數(shù)回調(diào),獲取到本次 DOM 的變動(dòng)的節(jié)點(diǎn)信息,進(jìn)而對(duì)數(shù)據(jù)進(jìn)行篩選重組等處理?;卣{(diào)參數(shù)的數(shù)據(jù)結(jié)構(gòu)如下:
let?MutationRecord1:?MutationRecordObject[];
interface?MutationRecordObject?{
??/**
???*?如果是屬性變化,則返回?"attributes";
???*?如果是 characterData 節(jié)點(diǎn)變化,則返回?"characterData";
???*?如果是子節(jié)點(diǎn)樹(shù) childList 變化,則返回?"childList"。
??*/
??type:?String,
??//?返回被添加的節(jié)點(diǎn)。如果沒(méi)有節(jié)點(diǎn)被添加,則該屬性將是一個(gè)空的 NodeList。
??addedNodes:?NodeList,
??//?返回被移除的節(jié)點(diǎn)。如果沒(méi)有節(jié)點(diǎn)被移除,則該屬性將是一個(gè)空的 NodeList。
??removedNodes:?NodeList,
??//?返回被修改的屬性的屬性名,或者 null。
??attributeName:?String?|?null,
??//?返回被修改屬性的命名空間,或者 null。
??attributeNamespace:?String?|?null,
??//?返回被添加或移除的節(jié)點(diǎn)之前的兄弟節(jié)點(diǎn),或者 null。
??previousSibling:?Node?|?null,
??//?返回被添加或移除的節(jié)點(diǎn)之后的兄弟節(jié)點(diǎn),或者 null。
??nextSibling:?Node?|?null,
??/**?返回值取決于 MutationRecord.type。
???*?對(duì)于屬性 attributes 變化,返回變化之前的屬性值。
???*?對(duì)于 characterData 變化,返回變化之前的數(shù)據(jù)。
???*?對(duì)于子節(jié)點(diǎn)樹(shù) childList 變化,返回 null。
??*/
??oldValue:?String?|?null,
}
record 收集的 Oplog 數(shù)據(jù)結(jié)構(gòu)如下圖所示:
let?Oplog:?OplogObject[];
interface?OplogObject?{
??/**?返回值取決于收集的事件類型
???*?DomContentLoaded:?0,?Load:?1,
???*?FullSnapshot:?2,?IncrementalSnapshot:?3,
???*?Meta:?4,?Custom:?5,?Plugin:?6
??*/
??type:?Number,
??data:?{
????//?返回添加的節(jié)點(diǎn)數(shù)據(jù)
????adds:?[],
????//?返回修改的節(jié)點(diǎn)屬性數(shù)據(jù)
????attributes:?[],
????//?返回移除的節(jié)點(diǎn)屬性數(shù)據(jù)
????removes:?[],
????/**?返回值取決于增量數(shù)據(jù)的增量類型
?????*?Mutation:?0,?MouseMove:?1,
?????*?MouseInteraction:?2,?Scroll:?3,
?????*?ViewportResize:?4,?Input:?5,
?????*?TouchMove:?6,?MediaInteraction:?7,
?????*?StyleSheetRule:?8,?CanvasMutation:?9,
?????*?Font:?10,?Log:?11,
?????*?Drag:?12,?StyleDeclaration:?13
????**/
????source:?Number,
????//?返回當(dāng)前修改的值,無(wú)則不返回
????text:?String?|?undefined,
??},
??//?當(dāng)前時(shí)間戳
??timestamp:?Number,
}
replay
基于初始化的快照數(shù)據(jù)和增量數(shù)據(jù),將其按照對(duì)應(yīng)的時(shí)間戳一一回放。由于一開(kāi)始創(chuàng)建快照時(shí)已經(jīng)禁止了腳本運(yùn)行,所以可以通過(guò) iframe 作為容器來(lái)重建 DOM 全量快照 ,并且通過(guò) sanbox 屬性禁止了腳本執(zhí)行、彈出窗和表單提交之類的操作。把 Oplog 放入操作隊(duì)列中,按照每個(gè)的時(shí)間戳先后進(jìn)行排序,再使用定時(shí)器 requestAnimationFrame 回放 Oplog 快照。
rrweb-player
為 rrweb 提供一套 UI 控件,提供基于 GUI 的暫停、快進(jìn)、拖拽至任意時(shí)間點(diǎn)播放等功能。
總結(jié)
文章從有感和無(wú)感兩個(gè)角度來(lái)淺析錄屏方案的實(shí)現(xiàn)。頁(yè)面錄屏的應(yīng)用場(chǎng)景場(chǎng)景比較豐富,有感錄制常見(jiàn)用于網(wǎng)頁(yè)線上考試、直播和語(yǔ)音視頻通話等實(shí)時(shí)交互場(chǎng)景,而無(wú)感錄制則更多應(yīng)用在重要操作記錄、bug 重現(xiàn)場(chǎng)景和產(chǎn)品運(yùn)營(yíng)分析用戶習(xí)慣等場(chǎng)景,二者各有千秋。基于用戶數(shù)據(jù)的安全和敏感,目前政采云傾向采用有感錄制進(jìn)行試點(diǎn)試用,避免引起安全糾紛。在錄屏技術(shù)方案不斷地完善和趨向成熟的同時(shí),我們也應(yīng)尊重用戶的數(shù)據(jù)安全和隱私,選擇更合適自身場(chǎng)景的方案使用
