rrweb 帶你還原問題現場

雲音樂大前端團隊發表於2022-01-06
本文作者:令姜

背景

雲音樂內部有許多內容管理系統 (Content Management System,CMS),用來支撐業務的運營配置等工作,運營同學在使用過程中遇到問題時,期望開發人員可以及時給予反饋並解決問題;痛點是開發人員沒有問題現場,很難去快速定位到問題,通常的場景是:

  • 運營同學 Watson:「Sherlock,我在配置 mlog 標籤的時候提示該標籤不存在,快幫我看下,急。」
  • 開發同學 Sherlock:「不慌,我看看。」(開啟測試環境的運營管理後臺,一頓操作,一切非常的正常…)
  • 開發同學 Sherlock:「我這兒正常的啊,你的工位在哪,我去你那看看」
  • 運營同學 Watson:「我在北京…」
  • 開發同學 Sherlock:「我在杭州…」

為了對運營同學在使用中遇到的相關問題及時給予反饋,儘快定位並解決 CMS 使用者遇到的使用問題,設計實現了問題一鍵上報外掛,用於還原問題現場,主要包括錄製和展示兩部分:

  • ThemisRecord 外掛:上報使用者基礎資訊、使用者許可權、API 請求 & 結果、錯誤堆疊、錄屏
  • 傾聽平臺承接展示:顯示錄屏回放、使用者、請求和錯誤堆疊資訊

上報流程

問題一鍵上報外掛設計的主要流程如下圖所示,在錄屏期間,外掛需要分別收集使用者基礎資訊、API 請求資料、錯誤堆疊資訊和錄屏資訊,並將資料上傳到 NOS 雲端和傾聽平臺。
外掛設計
在整個上報的流程中,如何實現操作錄屏和回放是一個難點,經過調研,發現 rrweb 開源庫可以很好的滿足我們的需求。rrweb 庫支援的場景有錄屏回放、自定義事件、console 錄製播放等多種場景,其中錄屏回放是最常用的使用場景,具體使用詳見場景示例

本文主要介紹的是 rrweb 庫的錄屏回放實現原理。

rrweb 庫

rrweb 主要由 rrwebrrweb-playerrrweb-snapshot 三個庫組成:

  • rrweb:提供了 record 和 replay 兩個方法;record 方法用來記錄頁面上 DOM 的變化,replay 方法支援根據時間戳去還原 DOM 的變化。
  • rrweb-player:基於 svelte 模板實現,為 rrweb 提供了回放的 GUI 工具,支援暫停、倍速播放、拖拽時間軸等功能。內部呼叫了 rrweb 的提供的 replay 等方法。
  • rrweb-snapshot:包括 snapshot 和 rebuilding 兩大特性,snapshot 用來序列化 DOM 為增量快照,rebuilding 負責將增量快照還原為 DOM。

瞭解 rrweb 庫的原理,可以從下面幾個關鍵問題入手:

  • 如何實現事件監聽
  • 如何序列化 DOM
  • 如何實現自定義計時器

如何實現事件監聽

基於 rrweb 去實現錄屏,通常會使用下面的方式去記錄 event,通過 emit 回撥方法可以拿到 DOM 變化對應所有 event。拿到 event 後,可以根據業務需求去做處理,例如我們的一鍵上報外掛會上傳到雲端,開發者可以在傾聽平臺拉取雲端的資料並回放。

let events = [];

rrweb.record({
  // emit option is required
  emit(event) {
    // push event into the events array
    events.push(event);
  },
});

record 方法內部會根據事件型別去初始化事件的監聽,例如 DOM 元素變化、滑鼠移動、滑鼠互動、滾動等都有各自專屬的事件監聽方法,本文主要關注的是 DOM 元素變化的監聽和處理流程。

要實現對 DOM 元素變化的監聽,離不開瀏覽器提供的 MutationObserver API,該 API 會在一系列 DOM 變化後,通過批量非同步的方式去觸發回撥,並將 DOM 變化通過 MutationRecord 陣列傳給回撥方法。詳細的 MutationObserver 介紹可以前往 MDN 檢視。

rrweb 內部也是基於該 API 去實現監聽,回撥方法為 MutationBuffer 類提供的 processMutations 方法:

  const observer = new MutationObserver(
    mutationBuffer.processMutations.bind(mutationBuffer),
  );

mutationBuffer.processMutations 方法會根據 MutationRecord.type 值做不同的處理:

  • type === 'attributes': 代表 DOM 屬性變化,所有屬性變化的節點會記錄在 this.attributes 陣列中,結構為 { node: Node, attributes: {} },attributes 中僅記錄本次變化涉及到的屬性;
  • type === 'characterData': 代表 characterData 節點變化,會記錄在 this.texts 陣列中,結構為 { node: Node, value: string },value 為 characterData 節點的最新值;
  • type === 'childList': 代表子節點樹 childList 變化,比起前面兩種型別,處理會較為複雜。

childList 增量快照

childList 發生變化時,若每次都完整記錄整個 DOM 樹,資料會非常龐大,顯然不是一個可行的方案,所以,rrweb 採用了增量快照的處理方式。

有三個關鍵的 Set:addedSetmovedSetdroppedSet,對應三種節點操作:新增、移動、刪除,這點和 React diff 機制相似。此處使用 Set 結構,實現了對 DOM 節點的去重處理。

節點新增

遍歷 MutationRecord.addedNodes 節點,將未被序列化的節點新增到 addedSet 中,並且若該節點存在於被刪除集合 droppedSet 中,則從 droppedSet 中移除。

示例:建立節點 n1、n2,將 n2 append 到 n1 中,再將 n1 append 到 body 中。

body
  n1
    n2

上述節點操作只會生成一條 MutationRecord 記錄,即增加 n1,「n2 append 到 n1」的過程不會生成MutationRecord 記錄,所以在遍歷 MutationRecord.addedNodes 節點,需要去遍歷其子節點,不然 n2 節點就會被遺漏。

遍歷完所有 MutationRecord 記錄陣列,會統一對 addedSet 中的節點做序列化處理,每個節點序列化處理的結果是:

export type addedNodeMutation = {
  parentId: number;
  nextId: number | null;
  node: serializedNodeWithId;
}

DOM 的關聯關係是通過 parentIdnextId 建立起來的,若該 DOM 節點的父節點、或下一個兄弟節點尚未被序列化,則該節點無法被準確定位,所以需要先將其儲存下來,最後處理。
雙向連結串列

rrweb 使用了一個雙向連結串列 addList 用來儲存父節點尚未被新增的節點,向 addList 中插入節點時:

  1. 若 DOM 節點的 previousSibling 已存在於連結串列中,則插入在 node.previousSibling 節點後
  2. 若 DOM 節點的 nextSibling 已存在於連結串列中,則插入在 node.nextSibling 節點前
  3. 都不在,則插入連結串列的頭部

通過這種新增方式,可以保證兄弟節點的順序,DOM 節點的 nextSibling 一定會在該節點的後面,previousSibling 一定在該節點的前面;addedSet 序列化處理完成後,會對 addList 連結串列進行倒序遍歷,這樣可以保證 DOM 節點的 nextSibling 一定是在 DOM 節點之前被序列化,下次序列化 DOM 節點的時候,就可以拿到 nextId

節點移動

遍歷 MutationRecord.addedNodes 節點,若記錄的節點有 __sn 屬性,則新增到 movedSet 中。有 __sn 屬性代表是已經被序列化處理過的 DOM 節點,即意味著是對節點的移動。

在對 movedSet 中的節點序列化處理之前,會判斷其父節點是否已被移除:

  1. 父節點被移除,則無需處理,跳過;
  2. 父節點未被移除,對該節點進行序列化。

節點刪除

遍歷 MutationRecord.removedNodes 節點:

  1. 若該節點是本次新增節點,則忽略該節點,並且從 addedSet 中移除該節點,同時記錄到 droppedSet 中,在處理新增節點的時候需要用到:雖然我們移除了該節點,但其子節點可能還存在於 addedSet 中,在處理 addedSet 節點時,會判斷其祖先節點是否已被移除;
  2. 需要刪除的節點記錄在 this.removes 中,記錄了 parentId 和節點 id。

如何序列化 DOM

MutationBuffer 例項會呼叫 snapshotserializeNodeWithId 方法對 DOM 節點進行序列化處理。
serializeNodeWithId 內部呼叫 serializeNode 方法,根據 nodeType 對 Document、Doctype、Element、Text、CDATASection、Comment 等不同型別的 node 進行序列化處理,其中的關鍵是對 Element 的序列化處理:

  • 遍歷元素的 attributes 屬性,並且呼叫 transformAttribute 方法將資源路徑處理為絕對路徑;
    for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
        attributes[name] = transformAttribute(doc, tagName, name, value);
    }
  • 通過檢查元素是否包含 blockClass 類名,或是否匹配 blockSelector 選擇器,去判斷元素是否需要被隱藏;為了保證元素隱藏不會影響頁面佈局,會給返回一個同等寬高的空元素;
    const needBlock = _isBlockedElement(
        n as HTMLElement,
        blockClass,
        blockSelector,
    );
  • 區分外鏈 style 檔案和內聯 style,對 CSS 樣式序列化,並對 css 樣式中引用資源的相對路徑轉換為絕對路徑;對於外鏈檔案,通過 CSSStyleSheet 例項的 cssRules 讀取所有的樣式,拼接成一個字串,放到 _cssText 屬性中;
    if (tagName === 'link' && inlineStylesheet) {
        // document.styleSheets 獲取所有的外鏈style
        const stylesheet = Array.from(doc.styleSheets).find((s) => {
            return s.href === (n as HTMLLinkElement).href;
        });
        // 獲取該條css檔案對應的所有rule的字串
        const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
        if (cssText) {
            delete attributes.rel;
            delete attributes.href;
            // 將css檔案中資源路徑轉換為絕對路徑
            attributes._cssText = absoluteToStylesheet( 
                cssText,
                stylesheet!.href!,
            );
        }
    }
  • 對使用者輸入資料呼叫 maskInputValue 方法進行加密處理;
  • 將 canvas 轉換為 base64 圖片儲存,記錄 media 當前播放的時間、元素的滾動位置等;
  • 返回一個序列化後的物件 serializedNode,其中包含前面處理過的 attributes 屬性,序列化的關鍵是每個節點都會有唯一的 id,其中 rootId 代表所屬 document 的 id,幫助我們在回放的時候識別根節點。
    return {
        type: NodeType.Element,
        tagName,
        attributes,
        childNodes: [],
        isSVG,
        needBlock,
        rootId,
    };

Event 時間戳

拿到序列化後的 DOM 節點,會統一呼叫wrapEvent方法給事件新增上時間戳,在回放的時候需要用到。

function wrapEvent(e: event): eventWithTime {
  return {
    ...e,
    timestamp: Date.now(),
  };
}

序列化 id

serializeNodeWithId 方法在序列化的時候會從 DOM 節點的 __sn.id 屬性中讀取 id,若不存在,就呼叫 genId 生成新的 id,並賦值給 __sn.id 屬性,該 id 是用來唯一標識 DOM 節點,通過 id 建立起 id -> DOM 的對映關係,幫助我們在回放的時候找到對應的 DOM 節點。

function genId(): number {
  return _id++;
}

const serializedNode = Object.assign(_serializedNode, { id });

若 DOM 節點存在子節點,則會遞迴呼叫 serializeNodeWithId 方法,最後會返回一個下面這樣的 tree 資料結構:

{
    type: NodeType.Document,
    childNodes: [{
        {
            type: NodeType.Element,
            tagName,
            attributes,
            childNodes: [{
                //...
            }],
            isSVG,
            needBlock,
            rootId,
        }
    }],
    rootId,
};

如何實現自定義計時器

replay
回放的過程中為了支援進度條的隨意拖拽,以及回放速度的設定(如上圖所示),自定義實現了高精度計時器 Timer ,關鍵屬性和方法為:

export declare class Timer {
    // 回放初始位置,對應進度條拖拽到的任意時間點
    timeOffset: number;
    // 回放的速度
    speed: number;
    // 回放Action佇列
    private actions;
    // 新增回放Action佇列
    addActions(actions: actionWithDelay[]): void;
    // 開始回放
    start(): void;
    // 設定回放速度
    setSpeed(speed: number): void;
}

回放入口

通過 Replayer 提供的 play 方法可以將上文記錄的事件在 iframe 中進行回放。

const replayer = new rrweb.Replayer(events);
replayer.play();

第一步,初始化 rrweb.Replayer 例項時,會建立一個 iframe 作為承載事件回放的容器,再分別呼叫建立兩個 service: createPlayerService 用於處理事件回放的邏輯,createSpeedService 用於控制回放的速度。

第二步,會呼叫 replayer.play() 方法,去觸發 PLAY 事件型別,開始事件回放的處理流程。

// this.service 為 createPlayerService 建立的回放控制service例項
// timeOffset 值為滑鼠拖拽後的時間偏移量
this.service.send({ type: 'PLAY', payload: { timeOffset } });

基線時間戳生成

時間軸

回放支援隨意拖拽的關鍵在於傳入時間偏移量 timeOffset 引數:

  • 回放的總時長 = events[n].timestamp - events[0].timestamp,n 為事件佇列總長度減一;
  • 時間軸的總時長為回放的總時長,滑鼠拖拽的起始位置對應時間軸上的座標為timeOffset
  • 根據初始事件的 timestamptimeOffset 計算出拖拽後的 基線時間戳(baselineTime)
  • 再從所有的事件佇列中根據事件的 timestamp 擷取 基線時間戳(baselineTime) 後的事件佇列,即需要回放的事件佇列。

回放 Action 佇列轉換

拿到事件佇列後,需要遍歷事件佇列,根據事件型別轉換為對應的回放 Action,並且新增到自定義計時器 Timer 的 Action 佇列中。

actions.push({
    doAction: () => {
        castFn();
    },
    delay: event.delay!,
});
  • doAction 為回放的時候要呼叫的方法,會根據不同的 EventType 去做回放處理,例如 DOM 元素的變化對應增量事件 EventType.IncrementalSnapshot。若是增量事件型別,回放 Action 會呼叫 applyIncremental 方法去應用增量快照,根據序列化後的節點資料構建出實際的 DOM 節點,為前面序列化 DOM 的反過程,並且新增到iframe容器中。
  • delay = event.timestamp - baselineTime,為當前事件的時間戳相對於基線時間戳的差值

requestAnimationFrame 定時回放

Timer 自定義計時器是一個高精度計時器,主要是因為 start 方法內部使用了 requestAnimationFrame 去非同步處理佇列的定時回放;與瀏覽器原生的 setTimeoutsetInterval 相比,requestAnimationFrame 不會被主執行緒任務阻塞,而執行 setTimeoutsetInterval 都有可能會有被阻塞。

其次,使用了 performance.now() 時間函式去計算當前已播放時長;performance.now()會返回一個用浮點數表示的、精度高達微秒級的時間戳,精度高於其他可用的時間類函式,例如 Date.now()只能返回毫秒級別。

 public start() {
    this.timeOffset = 0;
    // performance.timing.navigationStart + performance.now() 約等於 Date.now()
    let lastTimestamp = performance.now();
    // Action 佇列
    const { actions } = this;
    const self = this;
    function check() {
      const time = performance.now();
      // self.timeOffset為當前播放時長:已播放時長 * 播放速度(speed) 累加而來
      // 之所以是累加,因為在播放的過程中,速度可能會更改多次
      self.timeOffset += (time - lastTimestamp) * self.speed;
      lastTimestamp = time;
      // 遍歷 Action 佇列
      while (actions.length) {
        const action = actions[0];
        // 差值是相對於`基線時間戳`的,當前已播放 {timeOffset}ms
        // 所以需要播放所有「差值 <= 當前播放時長」的 action
        if (self.timeOffset >= action.delay) {
          actions.shift();
          action.doAction();
        } else {
          break;
        }
      }
      if (actions.length > 0 || self.liveMode) {
        self.raf = requestAnimationFrame(check);
      }
    }
    this.raf = requestAnimationFrame(check);
  }

完成回放 Action 佇列轉換後,會呼叫 timer.start() 方法去按照正確的時間間隔依次執行回放。在每次 requestAnimationFrame 回撥中,會正序遍歷 Action 佇列,若當前 Action 相對於基線時間戳的差值小於當前的播放時長,則說明該 Action 在本次非同步回撥中需要被觸發,會呼叫 action.doAction 方法去實現本次增量快照的回放。回放過的 Action 會從佇列中刪除,保證下次 requestAnimationFrame 回撥不會重新執行。

總結

在瞭解了「如何實現事件監聽」、「如何序列化 DOM」、「如何實現自定義計時器」這幾個關鍵問題後,我們基本掌握了 rrweb 的工作流程,除此之外,rrweb 在回放的時候還使用的 iframe 的沙盒模式,去實現對一些 JS 行為的限制,感興趣的同學可以進一步去了解。

總之,基於 rrweb 可以方便地幫助我們實現錄屏回放功能,例如現在在 CMS 業務中落地使用的一鍵上報功能,通過結合 API 請求、錯誤堆疊資訊和錄屏回放功能,可以幫助開發對問題進行定位並解決,讓你也成為一個 Sherlock。

本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe (at) corp.netease.com!

相關文章