目錄
點擊折疊/展開
簡介
rrweb
是'record and replay the web'
的簡寫,旨在利用現代瀏覽器所提供的強大 API 錄制並回放任意 web 界面中的用戶操作。
設計初衷是為了解決我們在客戶環境 debug 時遇到的⼀些問題。大多數產品通常部署在客戶的內⽹環境中,因此⼀旦出現問題只能通過各類遠程操作⼯具登⼊客戶環境中進⾏ debug,操作的空間和時間都⾮常有限。如果不幸遇到⼀些偶發性的問題,復現就變得難上加難,debug 更是⽆從談起。
在這種情況下,前端的異常監控及對應數據的收集顯得⾮常重要,但是傳統的收集錯誤棧信息的⽅式並不能給我們提供⾜夠的信息⽤於定位問題。
設計
序列化
如果僅僅需要在本地錄制和回放瀏覽器內的變化,那么我們可以簡單地通過深拷貝 DOM 來實現當前視圖的保存。例如通過以下的代碼實現(使用 jQuery 簡化示例,僅保存 body 部分):
// record
const snapshot = $("body").clone();
// replay
$("body").replaceWith(snapshot);
我們通過將 DOM 對象整體保存在內存中實現了快照。
但是這個對象本身並不是可序列化的,因此我們不能將其保存為特定的文本格式(例如 JSON)進行傳輸,也就無法做到遠程錄制,所以我們首先需要實現將 DOM 及其視圖狀態序列化的方法。在這里我們不使用一些開源方案例如 parse5 的原因包含兩個方面:
- 我們需要實現一個“非標准”的序列化方法,下文會詳細展開。
- 此部分代碼需要運行在被錄制的頁面中,要盡可能的控制代碼量,只保留必要功能。
序列化中的特殊處理
之所以說我們的序列化方法是非標准的是因為我們還需要做以下幾部分的處理:
- 去腳本化。被錄制頁面中的所有 JavaScript 都不應該被執行,例如我們會在重建快照時將
script
標簽改為noscript
標簽,此時 script 內部的內容就不再重要,錄制時可以簡單記錄一個標記值而不需要將可能存在的大量腳本內容全部記錄。 - 記錄沒有反映在 HTML 中的視圖狀態。例如
<input type="text" />
輸入后的值不會反映在其 HTML 中,而是通過value
屬性記錄,我們在序列化時就需要讀出該值並且以屬性的形式回放成<input type="text" value="recordValue" />
。 - 相對路徑轉換為絕對路徑。回放時我們會將被錄制的頁面放置在一個
<iframe>
中,此時的頁面 URL 為重放頁面的地址,如果被錄制頁面中有一些相對路徑就會產生錯誤,所以在錄制時就要將相對路徑進行轉換,同樣的 CSS 樣式表中的相對路徑也需要轉換。 - 盡量記錄 CSS 樣式表的內容。如果被錄制頁面加載了一些同源的 樣式表,我們則可以獲取到解析好的 CSS rules,錄制時將能獲取到的樣式都 inline 化,這樣可以讓一些內網環境(如 localhost)的錄制也有比較好的效果。
唯一標識
同時,我們的序列化還應該包含全量和增量兩種類型,全量序列化可以將一個 DOM 樹轉化為對應的樹狀數據結構。
例如以下的 DOM 樹:
<html>
<body>
<header></header>
</body>
</html>
會被序列化成類似這樣的數據結構:
{
"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
}
這個序列化的結果中有兩點需要注意:
- 我們遍歷 DOM 樹時是以 Node 為單位,因此除了場景的元素類型節點以為,還包括 Text Node、Comment Node 等所有 Node 的記錄。
- 我們給每一個 Node 都添加了唯一標識
id
,這是為之后的增量快照做准備。
想象一下如果我們在同頁面中記錄一次點擊按鈕的操作並回放,我們可以用以下格式記錄該操作(也就是我們所說的一次增量快照):
type clickSnapshot = {
source: "MouseInteraction",
type: "Click",
node: HTMLButtonElement,
};
再通過 snapshot.node.click()
就能將操作再執行一次。
但是在實際場景中,雖然我們已經重建出了完整的 DOM,但是卻沒有辦法將增量快照中被交互的 DOM 節點和已存在的 DOM 關聯在一起。
這就是唯一標識 id
的作用,我們在錄制端和回放端維護隨時間變化完全一致的 id -> Node
映射,並隨着 DOM 節點的創建和銷毀進行同樣的更新,保證我們在增量快照中只需要記錄 id
就可以在回放時找到對應的 DOM 節點。
上述示例中的數據結構相應的變為:
type clickSnapshot = {
source: "MouseInteraction";
type: "Click";
id: Number;
};
增量快照
在完成一次全量快照之后,我們就需要基於當前視圖狀態觀察所有可能對視圖造成改動的事件,在 rrweb 中我們已經觀察了以下事件(將不斷增加):
- DOM 變動
- 節點創建、銷毀
- 節點屬性變化
- 文本變化
- 鼠標移動
- 鼠標交互
- mouse up、mouse down
- click、double click、context menu
- focus、blur
- touch start、touch move、touch end
- 頁面或元素滾動
- 視窗大小改變
- 輸入
Mutation Observer
由於我們在回放時不會執行所有的 JavaScript 腳本,所以例如這樣的場景我們需要完整記錄才能夠實現回放:
點擊 button,出現 dropdown menu,選擇第一項,dropdown menu 消失
回放時,在“點擊 button”執行之后 dropdown menu 不會自動出現,因為已經沒有 JavaScript 腳本為我們執行這件事,所以我們需要記錄 dropdown menu 相關的 DOM 節點的創建以及后續的銷毀,這也是錄制中的最大難點。
好在現代瀏覽器已經給我們提供了非常強大的 API —— MutationObserver 用來完成這一功能。
此處我們不具體講解 MutationObserver 的基本使用方式,只專注於在 rrweb 中我們需要做哪些特殊處理。
首先要了解 MutationObserver 的觸發方式為批量異步回調,具體來說就是會在一系列 DOM 變化發生之后將這些變化一次性回調,傳出的是一個 mutation 記錄數組。
這一機制在常規使用時不會有問題,因為從 mutation 記錄中我們可以獲取到變更節點的 JS 對象,可以做很多等值比較以及訪問父子、兄弟節點等操作來保證我們可以精准回放一條 mutation 記錄。
但是在 rrweb 中由於我們有序列化的過程,我們就需要更多精細的判斷來應對各種場景。
新增節點
例如以下兩種操作會生成相同的 DOM 結構,但是產生不同的 mutation 記錄:
body
n1
n2
- 創建節點 n1 並 append 在 body 中,再創建節點 n2 並 append 在 n1 中。
- 創建節點 n1、n2,將 n2 append 在 n1 中,再將 n1 append 在 body 中。
第 1 種情況將產生兩條 mutation 記錄,分別為增加節點 n1 和增加節點 n2;第 2 種情況則只會產生一條 mutation 記錄,即增加節點 n1。
注意,在第一種情況下雖然 n1 append 時還沒有子節點,但是由於上述的批量異步回調機制,當我們處理 mutation 記錄時獲取到的 n1 是已經有子節點 n2 的狀態。
受第二種情況的限制,我們在處理新增節點時必須遍歷其所有子孫節點,才能保證所有新增節點都被記錄,但是這一策略應用在第一種情況中就會導致 n2 被作為新增節點記錄兩次,回放時就會產生與原頁面不一致的 DOM 結構。
因此,在處理一次回調中的多個 mutation 記錄時我們需要“惰性”處理新增節點,即在遍歷每條 mutation 記錄遇到新增節點時先收集,再在全部 mutation 遍歷完畢之后對收集的新增節點進行去重操作,保證不遺漏節點的同時每個節點只被記錄一次。
在序列化設計中已經介紹了我們需要維護一個 id -> Node
的映射,因此當出現新增節點時,我們需要將新節點序列化並加入映射中。但由於我們為了去重新增節點,選擇在所有 mutation 記錄遍歷完畢之后才進行序列化,在以下示例中就會出現問題:
- mutation 記錄 1,新增節點 n1。我們暫不處理,等待最終去重后序列化。
- mutation 記錄 2,n1 新增屬性 a1。我們試圖將它記錄成一次增量快照,但會發現無法從映射中找到 n1 對應的 id,因為此時它還未被序列化。
由此可見,由於我們對新增節點進行了延遲序列化的處理,所有 mutation 記錄也都需要先收集,再新增節點去重並序列化之后再做處理。
移除節點
在處理移除節點時,我們需要做以下處理:
- 移除的節點還未被序列化,則說明是在本次 callback 中新增的節點,無需記錄,並且從新增節點池中將其移除。
- 上步中在一次 callback 中被新增又移除的節點我們將其稱為 dropped node,用於最終處理新增節點時判斷節點的父節點是否已經 drop。
屬性變化覆蓋寫
盡管 MutationObserver 是異步批量回調,但是我們仍然可以認為在一次回調中發生的 mutations 之間時間間隔極短,因此在記錄 DOM 屬性變化時我們可以通過覆蓋寫的方式優化增量快照的體積。
例如對一個 <textarea>
進行 resize 操作,會觸發大量的 width 和 height 屬性變化的 mutation 記錄。雖然完整記錄會讓回放更加真實,但是也可能導致增量快照數量大大增加。進行取舍之后,我認為在同一次 mutation callback 中只記錄同一個節點某一屬性的最終值即可,也就是后續的 mutation 記錄會覆蓋寫之前已有的 mutation 記錄中的屬性變化部分。
鼠標移動
通過記錄鼠標移動位置,我們可以在回放時模擬鼠標移動軌跡。
盡量保證回放時鼠標移動流暢的同時也要盡量減少對應增量快照的數量,所以我們需要在監聽 mousemove 的同時進行兩層節流處理。第一層是每 20 ms 最多記錄一次鼠標坐標,第二層是每 500 ms 最多發送一次鼠標坐標集合,第二層的主要目的是避免一次請求內容過多而做的分段。
時間逆推
我們在每個增量快照生成時會記錄一個時間戳,用於在回放時按正確的時間差回放。但是由於節流處理的影響,鼠標移動對應增量快照的時間戳會比實際記錄時間要更晚,因此我們需要記錄一個用於校正的負時間差,在回放時將時間校准。
輸入
我們需要觀察 <input>
, <textarea>
, <select>
三種元素的輸入,包含人為交互和程序設置兩種途徑的輸入。
人為交互
對於人為交互的操作我們主要靠監聽 input 和 change 兩個事件觀察,需要注意的是對不同事件但值相同的情況進行去重。此外 <input type="radio" />
也是一類特殊的控件,如果多個 radio 元素的組件 name 屬性相同,那么當一個被選擇時其他都會被反選,但是不會觸發任何事件,因此我們需要單獨處理。
程序設置
通過代碼直接設置這些元素的屬性也不會觸發事件,我們可以通過劫持對應屬性的 setter 來達到監聽的目的,示例代碼如下:
function hookSetter<T>(
target: T,
key: string | number | symbol,
d: PropertyDescriptor
): hookResetter {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, {
set(value) {
// put hooked setter into event loop to avoid of set latency
setTimeout(() => {
d.set!.call(this, value);
}, 0);
if (original && original.set) {
original.set.call(this, value);
}
},
});
return () => hookSetter(target, key, original || {});
}
注意為了避免我們在 setter 中的邏輯阻塞被錄制頁面的正常交互,我們應該把邏輯放入 event loop 中異步執行。
回放
rrweb 的設計原則是盡量少的在錄制端進行處理,最大程度減少對被錄制頁面的影響,因此在回放端我們需要做一些特殊的處理。
高精度計時器
在回放時我們會一次性拿到完整的快照鏈,如果將所有快照依次同步執行我們可以直接獲取被錄制頁面最后的狀態,但是我們需要的是同步初始化第一個全量快照,再異步地按照正確的時間間隔依次重放每一個增量快照,這就需要一個高精度的計時器。
之所以強調高精度,是因為原生的 setTimeout
並不能保證在設置的延遲時間之后准確執行,例如主線程阻塞時就會被推遲。
對於我們的回放功能而言,這種不確定的推遲是不可接受的,可能會導致各種怪異現象的發生,因此我們通過 requestAnimationFrame
來實現一個不斷校准的定時器,確保絕大部分情況下增量快照的重放延遲不超過一幀。
同時自定義的計時器也是我們實現“快進”功能的基礎。
補全缺失節點
在增量快照設計中提到了 rrweb 使用 MutationObserver 時的延遲序列化策略,這一策略可能導致以下場景中我們不能記錄完整的增量快照:
parent
child2
child1
- parent 節點插入子節點 child1
- parent 節點在 child1 之前插入子節點 child2
按照實際執行順序 child1 會被 rrweb 先序列化,但是在序列化新增節點時我們除了記錄父節點之外還需要記錄相鄰節點,從而保證回放時可以把新增節點放置在正確的位置。但是此時 child 1 相鄰節點 child2 已經存在但是還未被序列化,我們會將其記錄為 id: -1
(不存在相鄰節點時 id 為 null)。
重放時當我們處理到新增 child1 的增量快照時,我們可以通過其相鄰節點 id 為 -1 這一特征知道幫助它定位的節點還未生成,然后將它臨時放入”缺失節點池“中暫不插入 DOM 樹中。
之后在處理到新增 child2 的增量快照時,我們正常處理並插入 child2,完成重放之后檢查 child2 的相鄰節點 id 是否指向缺失節點池中的某個待添加節點,如果吻合則將其從池中取出插入對應位置。
模擬 Hover
在許多前端頁面中都會存在 :hover
選擇器對應的 CSS 樣式,但是我們並不能通過 JavaScript 觸發 hover 事件。因此回放時我們需要模擬 hover 事件讓樣式正確顯示。
具體方式包括兩部分:
- 遍歷 CSS 樣式表,對於
:hover
選擇器相關 CSS 規則增加一條完全一致的規則,但是選擇器為一個特殊的 class,例如.:hover
。 - 當回放 mouse up 鼠標交互事件時,為事件目標及其所有祖先節點都添加
.:hover
類名,mouse down 時再對應移除。
從任意時間點開始播放
除了基礎的回放功能之外,我們還希望 rrweb-player
這樣的播放器可以提供和視頻播放器類似的功能,如拖拽到進度條至任意時間點播放。
實際實現時我們通過給定的起始時間點將快照鏈分為兩部分,分別是時間點之前和之后的部分。然后同步執行之前的快照鏈,再正常異步執行之后的快照鏈就可以做到從任意時間點開始播放的效果。
沙盒
在序列化設計中我們提到了“去腳本化”的處理,即在回放時我們不應該執行被錄制頁面中的 JavaScript,在重建快照的過程中我們將所有 script
標簽改寫為 noscript
標簽解決了部分問題。但仍有一些腳本化的行為是不包含在 script
標簽中的,例如 HTML 中的 inline script、表單提交等。
腳本化的行為多種多樣,如果僅過濾已知場景難免有所疏漏,而一旦有腳本被執行就可能造成不可逆的非預期結果。因此我們通過 HTML 提供的 iframe 沙盒功能進行瀏覽器層面的限制。
iframe sandbox
我們在重建快照時將被錄制的 DOM 重建在一個 iframe
元素中,通過設置它的 sandbox
屬性,我們可以禁止以下行為:
- 表單提交
window.open
等彈出窗- JS 腳本(包含 inline event handler 和
<URL>
)
這與我們的預期是相符的,尤其是對 JS 腳本的處理相比自行實現會更加安全、可靠。
避免鏈接跳轉
當點擊 a 元素鏈接時默認事件為跳轉至它的 href 屬性對應的 URL。在重放時我們會通過重建跳轉后頁面 DOM 的方式保證視覺上的正確重放,而原本的跳轉則應該被禁止執行。
通常我們會通過事件代理捕獲所有的 a 元素點擊事件,再通過 event.preventDefault()
禁用默認事件。但當我們將回放頁面放在沙盒內時,所有的 event handler 都將不被執行,我們也就無法實現事件代理。
重新查看我們回放交互事件增量快照的實現,我們會發現其實 click
事件是可以不被重放的。因為在禁用 JS 的情況下點擊行為並不會產生視覺上的影響,也無需被感知。
不過為了優化回放的效果,我們可以在點擊時給模擬的鼠標元素添加特殊的動畫效果,用來提示觀看者此處發生了一次點擊。
iframe 樣式設置
由於我們將 DOM 重建在 iframe 中,所以我們無法通過父頁面的 CSS 樣式表影響 iframe 中的元素。但是在不允許 JS 腳本執行的情況下 noscript
標簽會被顯示,而我們希望將其隱藏,就需要動態的向 iframe 中添加樣式,示例代碼如下:
const injectStyleRules: string[] = [
"iframe { background: ##f1f3f5 }",
"noscript { display: none !important; }",
];
const styleEl = document.createElement("style");
const { documentElement, head } = this.iframe.contentDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
需要注意的是這個插入的 style 元素在被錄制頁面中並不存在,所以我們不能將其序列化,否則 id -> Node
的映射將出現錯誤。
結構
rrweb 主要由 3 部分組成:
rrweb-snapshot
包含 snapshot 和 rebuild 兩個功能。snapshot 用於將 DOM 及其狀態轉化為可序列化的數據結構並添加唯一標識;rebuild 則是將 snapshot 記錄的數據結構重建為對應的 DOM。
rrweb
包含 record 和 replay 兩個功能。record 用於記錄 DOM 中的所有變更(mutation);
replay 則是將記錄的變更按照對應的時間一一重放。
rrweb-player
為 rrweb 提供一套 UI 控件,提供基於 GUI 的暫停、快進、拖拽至任意時間點播放等功能。
缺陷
rrweb 對dom
節點復制,然后通過序列化的快照數據在沙盒化回放過程中解析復原,並可以通過節點標志對必要的隱私內容進行屏蔽,很好的解決了傳統通過視頻錄屏方式的數據量大、無法保護隱私、分辨率低的問題。
但同時 rrweb 因為這樣的沙盒化設計,也會存在一些根本缺陷。
非常規節點錄制
比如對canvas
、iframe
、pdf
嵌入式的節點和其他通過 js 動態執行變化的非常規dom
節點,並不能完整保存。
為了解決這個問題,則需要在指定節點上設置一些標記,並關閉回放時容器的沙盒化屬性,讓相關 js 可以同步執行,但同時也會帶來更嚴重的問題,在進行直播時的雙方 js 操作的同步,記錄 js 的操作則會產生大量數據,從而引發性能問題,比如 echarts
數據圖的渲染。
無法進行局部錄制
當前的 rrweb 只能進行全頁面錄制,造成這種問題的原因是 dom 節點中存在的樣式,很大可能是通過父級節點的樣式表上繼承而來的,子級節點實際上並沒有設置任何樣式,如果此時想只錄制子節點的內容,則會丟失這些樣式,從而引發更多關聯節點的排版異常。
直播時對網絡穩定性的要求較高
rrweb 通過全量、增量快照的方式進行序列化傳輸,使用時間戳作為排列順序的依據,受網絡傳輸速度的影響,每次發送/接收的數據的耗時不同,就會在解析渲染時造成視覺上的卡頓和不連貫。我們可以為其設置一個緩沖值,讓每次傳輸都人為的按照大於最高傳輸耗時的固定延時進行渲染,這樣當每次渲染的時候,所有所需的數據都已經返回,雖然在雙方操作時會有延遲,但不會出現卡頓的現象。
但是我們需要對這樣的緩沖操作設置一個讓人可以接受的時間,如果網絡不穩定,那最低、最高傳輸耗時就會相差很大,如果把緩沖時間設置的過久,那么就會造成直播不同步,並阻塞主線程事件;如果設置的過短,那么就會出現卡頓現象,所以使用直播功能對網絡穩定性的要求較高。
在一定程度上使原網頁性能降低
由於 rrweb 在網頁操作過程中,會使用MutationObserver
對所有發生變化的節點進行異步監控,並相應地執行其他操作,需要把這些任務都放進event loop
中,
遠控
rrweb 當前並未提供遠控功能,因為 rrweb 的設計初衷是為了獲取網頁發生錯誤時操作日志回放提供支持的。不過我們仍然可以通過websocket
或webRTC
拓展 rrweb 以實現雙向遠程控制。
設計思路
屏幕共享
實現遠程控制之前我們首先要實現屏幕共享,rrweb 已經通過replayer
方法設計了直播功能,我們仿照直播功能借助websocket
或webRTC
可以實現兩個客戶端之間或者客戶端->服務端->客戶端之間的快照傳遞,通過repalyer
的addEvent
方法將獲取到的快照源源不斷的添加到播放器中,以實現直播功能,如果播放器在遠控端,那么我們只需要在客戶端把錄制好的快照通過服務端傳遞,設置一個合適的緩沖時間,然后在遠控端獲取響應之后,像回放一樣重建這些快照,就可以實現屏幕的共享。
遠程控制
在前面我們已經了解到,rrweb.replayer
時使用了沙盒化的iframe
容器進行重建快照,相當於會對所有的 js 腳本設置一個no-script
屬性,屏蔽了所有的 js 事件,這對於遠程控制來說剛好合適。
在遠控端我們需要打開iframe
的可輸入功能,然后錄制所有的 dom 交互動作,這些動作不需要觸發任何 js 事件,我們把他記錄下來同樣作為一個序列化的快照,然后再客戶端的 dom 上重建這些操作,客戶端是沒有屏蔽 js 事件的,待這些操作觸發事件, dom 就會隨之發生變化,我們再把這些變化就像之前的屏幕共享一樣,再在遠控端進行重建,這樣就可以始終保持兩邊的 dom 視圖是一致的,雙方都可以看到同樣的視圖界面。
在遠控端需要操作的時候,遠控端負責交互,客戶端負責讀取交互信息觸發事件;在客戶端需要操作的時候,遠控端就充當可輸入播放器的角色,這就實現了雙向的遠程控制功能,