Hi 程序員,Web 開源神器瞭解一下?

“用戶體驗也應該包含用戶遇到問題時我們如何快速 debug 和修復,而這對於內網部署並且邏輯非常複雜的應用而言並非易事。”

針對該難題,GitHub 上恰好有個頁面錄製與回放的開源神器——rrweb,這是 SmartX 前端團隊在不斷嘗試解決這一問題後衍生出的技術工具。要說 rrweb 究竟怎麼樣,本文將帶你一探究竟!

Hi 程序員,Web 開源神器瞭解一下? | 程序員硬核評測

作者 | SmartX前端團隊

前段時間開源了我們的 Web 錄製、回放基礎庫 rrweb,它可以將頁面中的 DOM 以及用戶操作保存為可序列化的數據,以實現遠程回放。

研發這一工具起初是為了解決我們在客戶環境 debug 時遇到的一些問題。

我們的產品通常部署在客戶的內網環境中,因此一旦出現問題只能通過各類遠程操作工具登入客戶環境中進行 debug,操作的空間和時間都非常有限。如果不幸遇到一些偶發性問題,復現就變得難上加難,debug 更是無從談起。

在這種情況下,前端的異常監控及對應數據的收集就顯得尤為重要,但是傳統的收集錯誤棧信息的方式並不能給我們提供足夠的信息用於定位問題。

在進一步調研的過程中我們發現 LogRocket(https://logrocket.com/)這樣的工具能夠提供像素級的錄製與回放,非常適用於我們的場景。但該類產品通常為 SAAS 服務,客戶的內網環境很可能無法連接,因此也無法使用。

最終我們決定自行實現 Web 錄製與回放這一套功能,在開發的過程中我們發現它還可以被應用於很多場景,例如:

  • 記錄用戶使用產品的方式並加以分析,進一步優化產品;
  • 採集用戶遇到 bug 的操作路徑,予以復現;
  • 記錄 CI 環境中的 E2E 測試的執行情況;
  • 錄製體積更小、清晰度無損的產品演示。

所以我們把其中最通用的部分作為獨立的代碼倉庫開源,方便其他開發者使用。

下文中將具體說說 rrweb 設計的演進過程以及其中的關鍵技術細節。

回放的基礎:DOM 快照

頁面中的視圖狀態可以通過 DOM 樹的形式描述,所以當我們嘗試錄製一個頁面時,我們實際上是在記錄 DOM 樹在各個時間點上的狀態,在 rrweb 中我們稱一次這樣的狀態記錄為一個快照。

序列化

如果僅僅需要在本地錄製和回放,那麼我們可以簡單地深拷貝 DOM。例如以下的代碼:

javascript
// deep clone document element
const docEl = document.documentElement.cloneNode(true);
// replay later
document.replaceChild(docEl, document.documentElement);

我們通過將 DOM 對象深克隆在內存中就實現了快照。

但是這個快照對象本身並不是可序列化的,因此我們不能將其保存為特定的文本格式(例如 JSON)進行傳輸,也就無法做到遠程錄製。所謂不可序列化是指雖然我們可以通過 innerHTML 等方式獲取到描述 DOM 的文本格式,但其中會丟失一些視圖狀態,例如 元素的 value 就不一定會記錄在 HTML 中。

所以我們首先需要實現將 DOM 及其視圖狀態序列化的方法。在這裡我們不使用一些開源方案例如 parse5(https://github.com/inikulin/parse5)的原因包含兩個方面:

1. 我們需要實現一個“非標準”的序列化方法。

2. 此部分代碼需要運行在被錄製的頁面中,要儘可能控制代碼量,只保留必要功能。

之所以說我們的序列化方法是非標準的是因為我們還需要做以下幾部分的處理:

1. 去腳本化,被錄製頁面中的所有 JavaScript 都不應該被執行。

2. 記錄沒有反映在 HTML 中的視圖狀態。例如 輸入後的值不會反映在其 HTML 中,我們需要讀取其 value 值並加以記錄。

3. 相對路徑轉換為絕對路徑。回放時頁面 URL 為重放頁面的地址,如果被錄製頁面中有一些相對路徑就會產生錯誤。

4. 儘量記錄 CSS 樣式表的內容。如果被錄製頁面加載了一些同源的樣式表,我們則可以獲取到解析好的 CSS rules,錄製時將能獲取到的樣式都 inline 化,這樣可以讓一些內網環境(如 localhost)的錄製也有比較好的回放效果。

初次嘗試:定時快照

當我們完成了可序列化的 DOM 快照實現之後,映入腦海的第一個思路就是定時對頁面製作快照完成錄製,回放時只需按照時間間隔依次重建快照即可。

但稍加思考之後我們會發現這個方案有兩大弊端。

首先是兩次快照之間的時間間隔難以平衡,如果間隔過短那麼可能產生大量無區別的快照,最終的總體積也會非常大,甚至大於同樣時長的視頻文件;而如果間隔過長那麼就會遺漏兩次間隔之間的視圖變化,可能導致一些關鍵性操作沒有被錄製。

其次是我們無法感知視圖變化的原因,也就無法從中解析出用戶的行為加以分析。

雖然定時快照的方案並不可行,但是指明瞭我們需要解決的兩個核心問題:

1. 應該基於導致視圖的變更製作快照。

2. 要控制錄製結果的體積。

再次嘗試:基於變更製作快照

第一個優化的方向是明確製作快照的時機,應該在每次視圖變更時製作一次快照。這樣既不會有不必要的快照,也不會遺漏視圖變化。

在實際的 Web 應用中視圖的變更非常頻繁,而且絕大部分都是局部的變更,因此每一次變更對應一個完整快照的思路雖然保證了快照數量上沒有浪費,但在每個快照的內容中依然有大量重複的部分,全部記錄下來還是一種不必要的冗餘。

基於快照 diff 的優化思路

為了消除上述快照中的冗餘數據,最直觀的思路就是將每一個快照與其前一個快照進行 diff,找出變更的部分加以記錄。

由於我們的快照數據結構是和 DOM 樹相類似的樹狀結構,因此在 DOM 樹較為複雜時 diff 的開銷將會非常高,甚至阻塞被錄製頁面的正常交互,進而影響用戶體驗。

這樣的高侵入性顯然與我們的預期是不相符的,所以我們還需要追溯視圖變更的根本原因——引發變更的操作。

最終錄製方案:快照 + Oplog

我們可以把引發視圖變更的操作歸為以下幾類:

  • DOM 變動
  • 節點創建、銷燬
  • 節點屬性變化
  • 文本變化
  • 鼠標交互
  • 頁面或元素滾動
  • 視窗大小改變
  • 輸入
  • 鼠標移動(特指鼠標的視覺位置)

對於每個操作我們只需要記錄其操作類型和相關的數據,就可以在回放時重現對應的操作,也就回放了該操作對視圖的改變。

這樣我們只需要在開始錄製時製作一個完整的 DOM 快照,之後則記錄所有的操作數據,這些操作數據我們稱之為 Oplog(operations log),這一思路和 log-structured file system 是類似的。

Hi 程序員,Web 開源神器瞭解一下? | 程序員硬核評測

唯一標識

在分析各類操作需要採集的對應數據之前,我們首先要對之前的序列化快照進行一個拓展:為每一個 DOM 節點添加唯一標識。

想象一下如果我們在本地記錄一次點擊按鈕的操作並回放,我們可以用以下格式記錄該操作:

javascript
type clickOp = {
source: 'MouseInteraction';
type: 'Click';
node: HTMLButtonElement;
}

再通過 clickOp.node.click() 就能將操作再執行一次。

但是在遠程場景中,雖然我們已經重建出了完整的 DOM,但是卻沒有辦法將 Oplog 中被交互的 DOM 節點和已存在的 DOM 關聯在一起。

這就是唯一標識 id 的作用,我們在錄製端和回放端維護一致的 id -> Node 映射,上述示例中的數據結構相應的變為:

typescript
type clickSnapshot = {
source: 'MouseInteraction';
type: 'Click';
id: Number;
}

DOM 變動

以下場景在 Web 應用中隨處可見:

點擊 button,出現 dropdown menu,選擇第一項,dropdown menu 消失

因為回放時不會有 JavaScript 腳本執行這一動態變化,所以對於這一操作需要記錄 DOM 節點的創建以及後續的銷燬,這也是錄製中的最大難點。

好在現代瀏覽器已經給我們提供了非常強大的 API ——MutationObserver(https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)用來完成這一功能。

我們不會具體講解 MutationObserver 的基本使用方式,只專注於在 rrweb 中我們需要做哪些特殊處理。

首先要了解 MutationObserver 的觸發方式為批量異步回調,具體來說就是會在一系列 DOM 變化發生之後將這些變化一次性回調,傳出的是一個 mutation 記錄數組。

例如以下兩種操作會生成相同的 DOM 結構,但是產生不同的 mutation 記錄:

body
n1
n2

1. 創建節點 n1 並 append 在 body 中,再創建節點 n2 並 append 在 n1 中。

2. 創建節點 n1、n2,將 n2 append 在 n1 中,再將 n1 append 在 body 中。

第 1 種情況將產生兩條 mutation 記錄,分別為增加節點 n1 和增加節點 n2;第 2 種情況則只會產生一條 mutation 記錄,即增加節點 n1。

想要同時正確地處理這兩種情況,所有 mutation 記錄都需要先收集,在新增節點去重並序列化之後再做處理。

鼠標移動

通過記錄鼠標移動位置,我們可以在回放時模擬鼠標移動軌跡。

保證回放時鼠標移動流暢的同時也要儘量減少對應 Oplog 的數量,所以我們會做兩層節流處理。第一層是每 50ms 最多記錄一次鼠標座標,第二層是每 500ms 最多發送一次鼠標座標集合,第二層的主要目的是避免一次請求內容過多而做的分段。

輸入

我們需要觀察 、 <textarea>、 <select> 三種元素的輸入,包含人為交互和程序設置兩種途徑的輸入。/<select>/<textarea>

  • 人為交互

對於人為交互的操作我們主要靠監聽 input 和 change 兩個事件觀察,需要注意的是對不同事件但值相同的情況進行去重。此外 也是一類特殊的控件,如果多個 radio 元素的組件 name 屬性相同,那麼當一個被選擇時其他都會被反選,但是不會觸發任何事件,因此我們需要單獨處理。

  • 程序設置

通過代碼直接設置這些元素的屬性也不會觸發事件,我們可以通過劫持對應屬性的 setter 來達到監聽的目的。

為了避免我們在 setter 中的邏輯阻塞被錄製頁面的正常交互,我們應該把邏輯放入 event loop 中異步執行。

特定場景優化:多個快照

快照 + Oplog 的設計也有其弊端,比較明顯的缺陷在於長時間的錄製 Oplog 會記錄很多操作,並且由於以增量的形式記錄數據,所以必須用完整的 Oplog 才能夠進行回放。

一類常見的需求是當異常發生時,收集異常之前一段時間的行為數據。為了更好地處理這類需求,我們實現了按時間和按次數重新制作快照的配置。

可以設置每 n 次操作後製作一次快照或每 n 毫秒後製作一次快照,從而將一個長的 Oplog 拆分為多個短的 Oplog。

回放

在確定了最終錄製方案之後,我們就可以實現對應的回放功能。相對來說回放的思路更為明確,可以分為以下 3 個主要步驟:

1. 在一個沙盒環境中將快照重建為對應的 DOM 樹;

2. 將 Oplog 中的操作按照時間戳排列,放入一個操作隊列中;

3. 啟動一個計時器,不斷檢查操作隊列,將到時間的操作取出重現。

沙盒

在序列化設計中我們提到了“去腳本化”的處理,即在回放時我們不應該執行被錄製頁面中的 JavaScript,在重建快照的過程中我們將所有>

因此我們通過 HTML 提供的 iframe 沙盒功能進行瀏覽器層面的限制。

我們在重建快照時將被錄製的 DOM 重建在一個 iframe 元素中,通過設置它的 sandbox 屬性,我們可以禁止以下行為:

  • 表單提交
  • window.open 等彈出窗
  • JS 腳本(包含 inline event handler 和

這與我們的預期是相符的,尤其是對 JS 腳本的處理相比自行實現會更加安全、可靠。

高精度計時器

之所以強調回放所用的計時器是高精度的,是因為原生的 setTimeout 並不能保證在設置的延遲時間之後準確執行,例如主線程阻塞時就會被推遲。

對於我們的回放功能而言,這種不確定的推遲是不可接受的,可能會導致各種怪異現象的發生,因此我們通過 requestAnimationFrame 來實現一個不斷校準的定時器,確保絕大部分情況下操作的重放延遲不超過一幀。

同時自定義的計時器也是我們實現“快進”功能的基礎。

最後,另附項目地址:

官網鏈接:https://www.rrweb.io

GitHub 鏈接:https://github.com/rrweb-io/rrweb

作者:SmartX前端團隊。SmartX 是國內超融合基礎架構領域的技術領導者,其前端團隊專注於開發高質量的企業級 Web 應用開發,持續不斷地探索和創新,最終將技術落地,提升用戶體驗。


分享到:


相關文章: