本篇文章來自團隊小夥伴 @陳小信 的一次學習分享,但願跟你們分享與探討。javascript
求積硅步以至千里,敢於探享生活之美。css
顧名思義,就是錄製用戶在網頁中的各類操做,而且支持能隨時回放操做。html
說到須要就不得不說一個經典的場景,通常前端作異常監控和錯誤上報,會採用自研或接入第三方 SDK
的形式,來收集和上報網站交互過程當中 JavaScript
的報錯信息和其它相關數據,也就是埋點。前端
在傳統的埋點方案中,根據 SourceMap
能定位到具體報錯代碼文件和行列信息等。基本能定位大部分場景問題,但有一些狀況下是很難復現錯誤,可能是在測試扯皮的時候,程序員口頭禪之一(我這裏沒有報錯呀,是否是你電腦有問題)。java
要是能把出錯的操做過程錄製下來就行了,這樣就能方便咱們復現場景了,且留存證據,好像是本身給本身挖了個坑。node
前端能實現錄視頻?我第一反應就是質疑,接着我就是一波 Google
,發現確實有可行方案。git
在 Google
以前,我想到了經過設定定時器,對視圖窗口進行截圖,截圖可用 canvas2html
的方式來實現,但這種方式無疑會形成性能問題,立馬否決。程序員
下面介紹我所「知道」的 Google
的方案,若有問題,歡迎指正。github
網頁本質上是一個 DOM
節點形式存在,經過瀏覽器渲染出來。咱們是否能夠把 DOM
以某種方式保存起來,而且在不一樣時間節點持續記錄 DOM
數據狀態。再將數據還原成 DOM
節點渲染出來完成回放呢?web
經過 document.documentElement.cloneNode()
克隆到 DOM
的數據對象,此時這個數據不能直接經過接口傳輸給後端,須要進行一些格式化預處理,處理成方便傳輸及存儲的數據格式。最簡單的方式就是進行序列化,也就是轉換成 JSON
數據格式。
// 序列化後
let docJSON = {
"type": "Document",
"childNodes": [
{
"type": "Element",
"tagName": "html",
"attributes": {},
"childNodes": [
{
"type": "Element",
"tagName": "head",
"attributes": {},
"childNodes": []
}
]
}
]
}
複製代碼
有完整的 DOM
數據以後,還須要在 DOM
變化時進行監聽,記錄每次變化的 DOM
節點信息。對數據進行監聽可用 MutationObserver
,它是一個能夠監聽 DOM
變化的 API
。
const observer = new MutationObserver(mutationsList => {
console.log(mutationsList); // 發生變化的數據
});
// 以上述配置開始觀察目標節點
observer.observe(document, {});
複製代碼
除了對 DOM
變化進行監聽之外,還有一個就是事件監聽,用戶與網頁的交互可能是經過鼠標,鍵盤等輸入設備來進行。而這些交互的背後就是 JavaScript
的事件監聽。事件監聽能夠經過綁定系統事件來完成,一樣是須要記錄下來,以鼠標移動爲例:
// 鼠標移動
document.addEventListener('mousemove', e => {
// 僞代碼 獲取鼠標移動的信息並記錄下來
positions.push({
x: clientX,
y: clientY,
timeOffset: Date.now() - timeBaseline,
});
});
複製代碼
addEventListener能夠綁定多個相同事件,不影響開發者的事件綁定
數據已經有了,接着就是回放,回放本質上是將 JSON
數據還原成 DOM
節點渲染出來。那就將快照數據還原就能夠啊「嘴強王者」,數據還原並不是那麼容易啊!
首先爲了確保回放過程代碼隔離,須要沙箱環境, iframe
標籤能夠作到,而且 iframe
提供了 sandbox
屬性可配置沙箱。沙箱環境的做用是確保代碼安全而且不被幹擾。
<iframe sandbox srcdoc></iframe>
複製代碼
sanbox
屬性能夠作到沙箱做用,點擊查看文檔
srcdoc
能夠直接設置成一段html
代碼
快照重組主要是 DOM
節點的重組,有點像虛擬 DOM
轉成真實文檔節點的過程,可是事件類型快照是不須要重組。
有了數據和環境,還須要定時器。經過定時器不停渲染 DOM
,實質上就是一個播放視頻的效果, requestAnimationFrame
是最合適的。
requestAnimationFrame 執行機制在瀏覽器下一次 repain(重繪)以前執行,執行頻率取決於瀏覽器刷新頻率,更適合製做動畫效果
至此有一個大概的想法,距離落地仍是有段距離。得益於開源,咱們可上 Github 看看有沒有合適的輪子可複製(借鑑),恰好有現成的一框架 「rrweb」,不妨一塊兒看看。
rrweb
是一個前端錄製和回放的框架。全稱 record and replay the web
,顧名思義就是能夠錄製和回放 web
界面中的操做,其核心原理就是上面介紹的方案。
rrweb
包含三個部分:
rrweb-snapshot
主要處理 DOM
結構序列化和重組;rrweb
主要功能是錄製和回放;rrweb-player
一個視頻播放器 UI 空間npm 安裝習覺得常,import/require 引入問題不大
經過 rrweb.record
方法來錄製頁面,emit
回調可接受到錄製的數據。
// 1.錄製
let events = []; // 記錄快照
rrweb.record({
emit(event) {
// 將 event 存入 events 數組中
events.push(event);
},
});
複製代碼
經過 rrweb.Replayer
可回放視頻,須要傳遞錄製好的數據。
// 2.回放
const replayer = new rrweb.Replayer(events);
replayer.play();
複製代碼
按照以上所說的思路,接下來會解析其中一些關鍵代碼,固然只是在我我的理解上作的一些分析,實際上 rrweb
源碼遠不止這些。
核心部分爲三大塊: record
(錄製)、 replay
回放、 snapshot
快照。
在 DOM
加載完成後,record
會作一次完整的 DOM
序列化,咱們把它叫作全量快照,全量快照記錄了整個 HTML
數據結構。
在 record.ts
中找到關鍵的入口函數的定義 init
,入口函數是會在 document
加載完成或(可交互,完成)時調用了 takeFullSnapshot
以及 observe(document)
函數。
if (
document.readyState === 'interactive' ||
document.readyState === 'complete'
) {
init();
} else {
//...
on('load',() => { init(); },),
}
const init = () => {
takeFullSnapshot(); // 生成全量快照
handlers.push(observe(document)); //監聽器
};
複製代碼
document.readyState
包含三種狀態:
- 可交互
interactive
;- 正在加載中
loading
;- 完成
complete
takeFullSnapshot
從字面意思能看出其做用是生成「完整」的快照,也就是會將 document
序列化出一個完整的數據,稱之爲 「全量快照」。
全部序列化相關操做都是使用 snapshot
完成,snapshot
接受一個 dom
對象和一個配置對象傳遞 document
將整個頁面序列化獲得完成的快照數據。
// 生成全量快照
takeFullSnapshot = (isCheckout = false) => {
//...
const [node, idNodeMap] = snapshot(document, {
//...一些配置項
});
//...
}
複製代碼
idNodeMap 是一個
id
爲key
,DOM
對象爲value
的key-value
鍵值對對象
observe(document)
是一些監聽器的初始化,一樣是將整個 document
對象傳過去進行監聽,經過調用 initObservers
來初始化一些監聽器。
const observe = (doc: Document) => {
return initObservers(//...)
}
複製代碼
在 observer.ts
文件中能夠找到 initObservers
函數定義,該函數初始化了 11 個監聽器,能夠分爲 DOM
類型 / Event
事件類型 / Media
媒體三大類:
export function initObservers( // dom const mutationObserver = initMutationObserver(); const mousemoveHandler = initMoveObserver(); const mouseInteractionHandler = initMouseInteractionObserver(); const scrollHandler = initScrollObserver(); const viewportResizeHandler = initViewportResizeObserver(); // ... ) 複製代碼
DOM
變化監聽器,主要有 DOM
變化(增刪改), 樣式變化,核心是經過 MutationObserver
來實現
let mutationObserverCtor = window.MutationObserver;
const observer = new mutationObserverCtor(
// 處理變化的數據
mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(doc, {});
return observer;
複製代碼
交互監聽-以鼠標移動 initMoveObserver
爲例
// 鼠標移動記錄
function initMoveObserver() {
const updatePosition = throttle<MouseEvent | TouchEvent>(
(evt) => {
positions.push({
x: clientX,
y: clientY,
});
});
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
];
}
複製代碼
canvas
/ video
/ audio
,以 video
爲例,本質上記錄播放和暫停狀態,mediaInteractionCb
將 play
/ pause
狀態回調出來。function initMediaInteractionObserver(): listenerHandler {
mediaInteractionCb({
type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause,
id: mirror.getId(target as INode),
});
}
複製代碼
snapshot
負責序列化和重組的功能,主要經過 serializeNodeWithId
處理 DOM
序列化和 rebuildWithSN
函數處理 DOM
重組。
serializeNodeWithId
函數負責序列化,主要作了三件事:
serializeNode
序列化 Node
;genId()
生成惟一ID 並綁定到 Node
中;ID
的對象// 序列化一個帶有ID的DOM
export function serializeNodeWithId(n) {
// 1. 序列化 核心函數 serializeNode
const _serializedNode = serializeNode(n);
// 2. 生成惟一ID
let id = genId();
// 綁定ID
const serializedNode = Object.assign(_serializedNode, { id });
// 3.子節點序列化-遞歸
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
}
}
}
複製代碼
serializeNodeWithId
核心是經過 serializeNode
序列化 DOM
,針對不一樣的節點分別作了一些特殊處理。
節點屬性的處理:
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
attributes[name] = transformAttribute(doc, tagName, name, value);
}
複製代碼
處理外聯 css
樣式,經過 getCssRulesString
獲取到具體樣式代碼,而且儲存到 attributes
中。
const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
if (cssText) {
attributes._cssText = absoluteToStylesheet(
cssText,
stylesheet!.href!,
);
}
複製代碼
處理 form
表單,邏輯是保存選中狀態,而且作了一些安全處理,例如密碼框內容替換成 *
。
if (
attributes.type !== 'radio' &&
attributes.type !== 'checkbox' &&
// ...
) {
attributes.value = maskInputOptions[tagName]
? '*'.repeat(value.length)
: value;
} else if (n.checked) {
attributes.checked = n.checked;
}
複製代碼
canvas
狀態保存經過 toDataURL
保存 canvas
數據:
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
複製代碼
rebuild 負責重建 DOM
:
buildNodeWithSN
函數重組 Node
export function buildNodeWithSN(n) {
// DOM 重組核心函數 buildNode
let node = buildNode(n, { doc, hackCss });
// 子節點重建而且appendChild
for (const childN of n.childNodes) {
const childNode = buildNodeWithSN(childN);
if (afterAppend) {
afterAppend(childNode);
}
}
}
複製代碼
回放部分在 replay.ts
文件中,先建立沙箱環境,接着或進行重建 document
全量快照,在經過 requestAnimationFrame
模擬定時器的方式來播放增量快照。
replay
的構造函數接收兩個參數,快照數據 events
和 配置項 config
export class Replayer {
constructor(events, config) {
// 1.建立沙箱環境
this.setupDom();
// 2.定時器
const timer = new Timer();
// 3.播放服務
this.service = new createPlayerService(events, timer);
this.service.start();
}
}
複製代碼
構造函數中最核心三步,建立沙箱環境,定時器,和初始化播放器而且啓動。播放器建立依賴 events
和 timer
,本質上仍是使用 timer
來實現播放。
首先,在 replay.ts
的構造函數中能夠找打 this.setupDom
的調用,setupDom
核心是經過 iframe
來建立出一個沙箱環境。
private setupDom() {
// 建立iframe
this.iframe = document.createElement('iframe');
this.iframe.style.display = 'none';
this.iframe.setAttribute('sandbox', attributes.join(' '));
}
複製代碼
一樣在 replay.ts
構造函數中,調用 createPlayerService
函數來建立播放器服務器,該函數在同級目錄下的 machine.ts
中定義了,核心思路是經過給定時器 timer
加入須要執行的快照動做 actions
, 在調用 timer.start()
開始回放快照。
export function createPlayerService() {
//...
play(ctx) {
// 獲取每一個 event 執行的 doAction 函數
for (const event of needEvents) {
//..
const castFn = getCastFn(event);
actions.push({
doAction: () => {
castFn();
}
})
//..
}
// 添加到定時器隊列中
timer.addActions(actions);
// 啓動定時器播放 視頻
timer.start();
},
//...
}
複製代碼
播放服務使用到第三方庫
@xstate/fsm
狀態機來控制各類狀態(播放,暫停,直播)
定時器 timer.ts
也是在同級目錄下,核心是經過 requestAnimationFrame
實現了定時器功能, 並對快照回放,以隊列的形式存儲須要播放的快照 actions
,接着在 start
中遞歸調用 action.doAction
來實現對應時間節點的快照還原。
export class Timer {
// 添加隊列
public addActions(actions: actionWithDelay[]) {
this.actions = this.actions.concat(actions);
}
// 播放隊列
public start() {
function check() {
// ...
// 循環調用actions中的doAction 也就是 castFn 函數
while (actions.length) {
const action = actions[0];
actions.shift();
// doAction 會對快照進行回放動做,針對不一樣快照會執行不一樣動做
action.doAction();
}
if (actions.length > 0 || self.liveMode) {
self.raf = requestAnimationFrame(check);
}
}
this.raf = requestAnimationFrame(check);
}
}
複製代碼
doAction
在不一樣類型快照會執行不一樣動做,在播放服務中 doAction
最終會調用 getCastFn
函數來作了一些 case
:
private getCastFn(event: eventWithTime, isSync = false) {
switch (event.type) {
case EventType.DomContentLoaded: //dom 加載解析完成
case EventType.FullSnapshot: // 全量快照
case EventType.IncrementalSnapshot: //增量
castFn = () => {
this.applyIncremental(event, isSync);
}
}
}
複製代碼
applyIncremental
函數會增對不一樣的增量快照作不一樣處理,包含 DOM
增量, 鼠標交互,頁面滾動等,以DOM
增量快照的 case
爲例,最終會走到 applyMutation
中:
private applyIncremental(){
switch (d.source) {
case IncrementalSource.Mutation: {
this.applyMutation(d, isSync); // DOM變化
break;
}
case IncrementalSource.MouseMove: //鼠標移動
case IncrementalSource.MouseInteraction: //鼠標點擊事件
//...
}
複製代碼
applyMutation
纔是最終執行 DOM
還原操做的地方,包含 DOM
的增刪改步驟:
private applyMutation(d: mutationData, useVirtualParent: boolean) {
d.removes.forEach((mutation) => {
//.. 移除dom
});
const appendNode = (mutation: addedNodeMutation) => {
// 添加dom到具體節點下
};
d.adds.forEach((mutation) => {
// 添加
appendNode(mutation);
});
d.texts.forEach((mutation) => {
//...文本處理
});
d.attributes.forEach((mutation) => {
//...屬性處理
});
}
複製代碼
以上就是回放的關鍵流程實現代碼,rrweb
中不只僅是作了這些,還包含數據壓縮,移動端處理,隱私問題等等細節處理,有興趣可自行查看源碼。
這種實現錄製回放思路確實值得學習,讀 rrweb
源碼的過程也受益頗多,源碼中對數據結構的一些使用,例如雙鏈表,隊列,樹等也值得一覽。
以上即是本次分享的所有內容,但願對你有所幫助 ^_^
喜歡的話別忘了動動手指,點贊、收藏、關注三連一波帶走。
咱們是萬拓科創前端團隊,左手組件庫,右手工具庫,各類技術野蠻生長。
一我的跑得快,不如一羣人跑得遠。歡迎加入咱們的小分隊,牛年牛氣轟轟往前衝。