rrweb 實現原理介紹

一、背景

rrweb 全稱'record and replay the web',是當下很流行的一個錄製屏幕的開源庫。與我們傳統認知的錄屏方式(如 WebRTC)不同的是,rrweb 錄製的不是真正的視頻流,而是一個記錄頁面 DOM 變化的 JSON 數組,因此不能錄製整個顯示器的屏幕,只能錄製瀏覽器的一個頁籤。

二、基本使用

https://github.com/rrweb-io/rrweb/blob/master/guide.zh_CN.md

import rrweb from 'rrweb';

let events = [];

let stopFn = rrweb.record({
  emit(event) {
    events.push(event); // 將 event 存入 events 數組中
    if (events.length > 100) { // 當事件數量大於 100 時停止錄製
      stopFn();
    }
  },
});

// rrweb 播放器回放
const replayer = new rrweb.Replayer(events);
replayer.play(); // 播放

Demo 地址:https://www.rrweb.io/demo/checkout-form

三、實現原理

3.1 包的組成

rrweb 主要由以下三個包構成:

3.1.1 rrweb

主要提供了 recordreplay 兩個方法,record 負責從一開始錄製 DOM 全量信息,到後面監聽頁面的變化(mutation),並將每次的變化 emit 出來傳給開發用戶。replay 負責將 record 錄製的一系列 JSON 數據重組再回放出當時的頁面內容。

3.1.2 rrweb-snapshot

主要提供了 record 中用的兩個方法:序列化 node 節點獲得用於傳遞變化信息的 serializeNodeWithId 和獲取頁面快照的 snapshot ;此外還提供了 replay 中用到的一個方法:還原頁面快照幫助構建回放 DOM 的 rebuild

3.1.3 rrweb-player

爲 rrweb 設計了一套全新 UI 的播放器,可以實現拖拽進度條、調整播放速度等功能。

3.2 錄製過程 record

整體思路:初始化時獲取當前頁面的全量快照,添加監聽器監聽頁面不同類型的變化(比如 DOM 的變化以及鼠標、滾動以及頁面 resize 等的變化),當以上這些變化(mutation)發生時,根據類型的不同分別進行不同的序列化處理,並將處理好的數據 emit 出來。序列化處理時,給每個序列化的 node 節點分配一個 ID,並維護一個從 ID 到 node 節點的映射以及一個 node 節點到序列化後 serializedNode 節點的映射。

Q:爲什麼需要序列化節點?直接用原生的 node 節點不行嗎?
A:由於需要經過網絡傳輸存儲在後端,如果直接用 node 節點對象首先是無法通過網絡傳輸(必須要序列化),其次後端也無法存儲。因此需要設計出一種合適的(能完整表達一個節點的所有信息,如位置、屬性等)數據結構來序列化節點。

3.2.1 前置知識

Node.nodeType(https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType): 代表 node 節點的不同類型,在 rrweb 中我們常用到的有 ELEMENT_NODETEXT_NODEDOCUMENT_NODE

雙向鏈表:https://juejin.cn/post/7078915940418748430

MutationObserver(https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver):可以監視 DOM 樹的變更,當發生變更時會調用傳入構建函數的 callback。它最重要的特點是會批量異步處理 DOM 的變化,比如對於多個 appendChildremoveChild 會批量處理調用一次 callback。

3.2.2 源碼閱讀

爲了解主流程原理,對源碼進行了大幅簡化。首先從我們調用的 rrweb.record 方法進入:

function wrapEvent(e) {
    return Object.assign(Object.assign({}, e), { timestamp : Date . now () } );
}

function record(options = {}) {
    let incrementalSnapshotCount = 0;
    wrappedEmit = ( e, isCheckout ) => {
        emit ( eventProcessor (e), isCheckout);
        if (exceedCount || exceedTime) {
 takeFullSnapshot ( true );
}
    };
    takeFullSnapshot = (isCheckout = false) => {
        wrappedEmit(wrapEvent({
            type: EventType.Meta,
            data: {
                href: window.location.href,
                width: getWindowWidth(),
                height: getWindowHeight(),
            },
        }), isCheckout);
        // 獲取了文檔的全量快照,同時維護了一個節點和 ID 的映射 mirror
        const node = snapshot ( document , {
            mirror,
            // ...
        });
        wrappedEmit ( wrapEvent ({
 type : EventType . FullSnapshot ,
            data: {
                node,
                initialOffset: {
                    // left: ,
                    // top: ,
                },
            },
        }));
    };
    const handlers = [];
    const  observe = ( doc ) => {
        return  initObservers ({
 mutationCb : ( m ) =>  wrappedEmit ( wrapEvent ({  type : EventType . IncrementalSnapshot ,  data : Object . assign ({ source : IncrementalSource . Mutation }, m), })),
 mousemoveCb : ( positions, source ) =>  wrappedEmit ( wrapEvent ({
 type : EventType . IncrementalSnapshot ,
                data : {
source,
positions,
},
})),
            // 其他監聽器...
        }, hooks);
    };
    const  init = () => {
 takeFullSnapshot ();
handlers. push ( observe ( document ));
recording = true ;
};
 init ();
    return () => {
        handlers.forEach((h) => h());
        recording = false;
    };
}

record 中定義了多個關鍵的函數。init 中執行了 takeFullSnapshotobserve(document)

這些 payload 根據變化類型的不同會有各自的屬性,來幫助播放時還原錄製的現場,比如鼠標的移動就需要鼠標的位置信息:

function initObservers(o, hooks = {}) {
    const mutationObserver = initMutationObserver(o, o.doc);
    const mousemoveHandler = initMoveObserver(o);
    // 其他監聽器...
}

function initMoveObserver({ mousemoveCb, sampling, doc, mirror, }) {
    const threshold = typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
    const callbackThreshold = typeof sampling.mousemoveCallback === 'number'
        ? sampling.mousemoveCallback
        : 500;
    let positions = [];
    const wrappedCb = throttle((source) => {
        const totalOffset = Date.now() - timeBaseline;
        mousemoveCb (positions. map ( ( p ) => {
p. timeOffset -= totalOffset;
 return p;
}), source);
        positions = [];
    }, callbackThreshold);
    const updatePosition = throttle((evt) => {
        const target = getEventTarget(evt);
        const { clientX, clientY } = isTouchEvent(evt)
            ? evt.changedTouches[0]
            : evt;
        positions. push ({
 x : clientX,
 y : clientY,
 id : mirror. getId (target),
 timeOffset : Date . now () - timeBaseline,
});
        wrappedCb(typeof DragEvent !== 'undefined' && evt instanceof DragEvent
            ? IncrementalSource.Drag
            : evt instanceof MouseEvent
                ? IncrementalSource.MouseMove
                : IncrementalSource.TouchMove);
    }, threshold, {
        trailing: false,
    });

    // 使用 addEventListener 就可以實現
    const handlers = [
 on ( 'mousemove' , updatePosition, doc),
 on ( 'touchmove' , updatePosition, doc),
 on ( 'drag' , updatePosition, doc),
];
    return () => {
        handlers.forEach((h) => h());
    };
}

首先對幾種鼠標變化添加 addEventListener 監聽器,當發生變化時執行 updatePosition 函數把獲得的 position 作爲 mousemoveCb 函數的入參傳出來,最終給到上文的 emit 方法。注意其中做了節流的處理,rrweb 支持用 sampling 屬性來配置抽樣的頻率。

再來看看我們最關注的 DOM 變化是如何轉換成增量快照的。和鼠標移動的處理方式一樣,在 initObservers 函數中調用處理 mutation 的 initMutationObserver 函數,其中我們創造了一個 MutationBuffer 對象 mutationBuffer 並 init 用來存放每次的 DOM 變化,然後利用創造一個 MutationObserver 對象 observerobserver 觀察文檔所有內容的變化。

function initMutationObserver(options, rootEl) {
    const mutationBuffer = new MutationBuffer(); // 存放本次變化有關的信息
    mutationBuffers.push(mutationBuffer);
    mutationBuffer.init(options);
    const observer = new MutationObserver(mutationBuffer.processMutations.bind(mutationBuffer));
    observer.observe(rootEl, {
        attributes: true,
        attributeOldValue: true,
        characterData: true,
        characterDataOldValue: true,
        childList: true,
        subtree: true,
    });
    return observer;
}

當變化發生時,執行 mutationBuffer 的一個方法 processMutations,mutation 的類型有三種:

我們最關注第三類節點的變化:

class MutationBuffer {
    constructor() {
        this.frozen = false;
        this.locked = false;
        this.removes = [];
        this.mapRemoves = [];
        this.addedSet = new Set();
        this.movedSet = new Set();
        this . processMutations = ( mutations ) => {
mutations. forEach ( this . processMutation );
 this . emit ();
};
        this.emit = () => {
            // ...
        };
        this.processMutation = (m) => {
        switch (m. type ) { // 判斷mutation的類型
            case 'characterData': {
                // ...
            }
            case 'attributes': {
                // ...
            }
            case  'childList' : {
                m. addedNodes . forEach ( ( n ) =>  this . genAdds (n, m. target ));
m. removedNodes . forEach ( ( n ) => {
                    const nodeId = this.mirror.getId(n);
                    const parentId = isShadowRoot(m.target)
                        ? this.mirror.getId(m.target.host)
                        : this.mirror.getId(m.target);
                    if (isBlocked(m.target, this.blockClass, this.blockSelector, false) ||
                        isIgnored(n, this.mirror) ||
                        !isSerialized(n, this.mirror)) {
                        return;
                    }
                    else if (this.addedSet.has(m.target) && nodeId === -1) ;
                    else if (isAncestorRemoved(m.target, this.mirror)) ;
                    else if (this.movedSet.has(n) &&
                        this.movedMap[moveKey(nodeId, parentId)]) {
                        deepDelete(this.movedSet, n);
                    }
                    else {
                        this . removes . push ({
parentId,
 id : nodeId,
 isShadow : isShadowRoot (m. target ) && isNativeShadowDom (m. target )
? true
: undefined ,
});
                    }
                });
            }
        }
        };
        this . genAdds = ( n, target ) => {
            if ( this . mirror . hasNode (n)) {
 this . movedSet . add (n);
}
            else {
 this . addedSet . add (n);
}
 if (! isBlocked (n, this . blockClass , this . blockSelector , false ))
n. childNodes . forEach ( ( childN ) =>  this . genAdds (childN));
        };
    }
}

mutationBuffer 對象維護了兩個集合:addedSetmovedSet,還有一個 removes 數組,用於處理三種節點的變化:

添加節點時使用集合 Set 的原因:

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

那麼如果對於第一種情況,處理 n1 時遍歷它的子節點添加了一次 n2,再處理第二條 mutation 記錄 n2 節點時又會添加一遍,因此爲了去重需要使用集合 Set。而刪除節點則無需用集合,因爲在回放 removeChild 時自然會把所有子節點都刪掉。

processMutations 中,以上工作將本次回調的所有變動都收集好了,接下來繼續執行 emit 方法:

共識:序列化節點的順序應當是從位置能確定的節點(父節點和兄弟節點已經過序列化)開始。對於不確定的節點,需要先存儲起來( rrweb 就是利用了雙向鏈表存儲),待能確定後再序列化。

this.emit = () => {
    if (this.frozen || this.locked) {
        return;
    }
    const adds = [];
    const addList = new  DoubleLinkedList ();
    const getNextId = (n) => {
        // 獲取nextSibling的ID
    };
    const pushAdd = (n) => {
        if (!n.parentNode) {
            return;
        }
        const parentId = this.mirror.getId(n.parentNode);
        const nextId = getNextId(n);
        if (parentId === - 1 || nextId === - 1 ) {
 return addList. addNode (n);
}
        const sn = serializeNodeWithId (n, {
            // options...
        });
        if (sn) {
            adds. push ({
parentId,
nextId,
 node : sn,
});
        }
    };
    for (const n of Array.from(this.movedSet.values())) {
        if (isParentRemoved(this.removes, n, this.mirror) && !this.movedSet.has(n.parentNode)) {
            continue;
        }
        pushAdd(n);
    }
    for (const n of Array.from(this.addedSet.values())) {
        if (!isAncestorInSet(this.droppedSet, n) && !isParentRemoved(this.removes, n, this.mirror)) {
            pushAdd(n);
        }
        else if (isAncestorInSet(this.movedSet, n)) {
            pushAdd(n);
        }
        else {
            this.droppedSet.add(n);
        }
    }
    let candidate = null;
    while (addList.length) {
        let node = null;
        if (candidate) {
            const parentId = this.mirror.getId(candidate.value.parentNode);
            const nextId = getNextId(candidate.value);
            if (parentId !== -1 && nextId !== -1) {
                node = candidate;
            }
        }
        if (!node) {
            for (let index = addList. length - 1 ; index >= 0 ; index--) {
                const _node = addList.get(index);
                if (_node) {
                    const parentId = this.mirror.getId(_node.value.parentNode);
                    const nextId = getNextId(_node.value);
                    if (nextId === -1)
                        continue;
                    else if (parentId !== -1) {
                        node = _node;
                        break;
                    }
                }
            }
        }
        if (!node) {
            while (addList.head) {
                addList.removeNode(addList.head.value);
            }
            break;
        }
        candidate = node.previous;
        addList.removeNode(node.value);
        pushAdd(node.value);
    }
    const payload = {
 // 省略文本和屬性部分代碼
 removes : this . removes ,
adds,
};
 this . mutationCb (payload);
};

emit 方法最終會組合出一個代表本次 DOM 變化的 payload 傳給 mutationCb(在 mutationBuffer init 時傳入)執行,最終一路向上追溯到執行 rrweb 使用方所寫的 emit 函數。

我們分析下是如何拿到這個 payload 的:

對於刪除的節點,直接使用 removes 數組;對於新增(或移動)的節點,我們在回放時需要用到它的父節點、兄弟節點和它本身,定義 adds 數組存放新增的節點信息。首先遍歷 movedSet,如果節點的父節點在本次回調中被刪除了則不處理,否則執行 pushAdd 函數,然後遍歷 addedSet,與 movedSet 處理相同。

pushAdd 函數中,首先去獲取當前被添加節點的父節點 ID 和下一相鄰的兄弟節點 ID,如果發現父節點或者下一相鄰節點尚未序列化(即尚未來得及維護 ID 加入 mirror 映射),將這個節點加入雙向鏈表 addList 中(雙向鏈表的 addNode 方法是按照 DOM 節點順序來添加節點的,根據節點的 previousSiblingnextSibling 屬性能找到前一兄弟節點的放到它後面,能找到後一兄弟節點的放到它前面,都找不到放到 head。也就是層級越深越靠前、同一層級按 DOM 順序排位)先存儲起來。 如果能找到父節點 ID 和 下一相鄰節點 ID 則對這個節點序列化 serializeNodeWithId,將序列化的節點和 parentId 以及 nextId 作爲當前被添加節點的全部信息存到 adds 數組中。

處理完 movedSetaddedSet 後,遍歷 addList,由於需要用到 parentIdnextId ,所以需要先序列化層級淺、同層級 DOM 順序靠後的節點,也就是我們 addList 存儲的相反順序。所以從最後一個節點開始遍歷 addList 雙向鏈表,對每個節點執行 pushAdd 函數序列化(由於鏈表的最後一個節點 N 一定是沒有下一兄弟節點的,所以在它執行 pushAdd 函數時可以走到序列化的步驟並添加它的有關信息到 adds 數組中,這樣前一節點 N - 1 也可以拿到 N 的 ID)。

到這裏所有被添加的節點也都處理完成了,adds 數組就是我們 payload 需要的,也就完成了從一次 mutationObserver 回調的多條記錄到一個 payload 中的文本、屬性、添加節點信息、移除節點信息的轉變

3.2.3 舉例

舉一個稍微複雜的例子,按 1234 的順序添加節點到 DOM 中:

function App() {
  useEffect(() => {
    record({
      emit(event) {
        if (event.data.source === 0) {
          console.log('events', event)
        }
      }
    });
  }, []);

  return (
    <div class>
      <div id='parent' />
      <button
        onClick={() => {
          const p = document.querySelector('#parent');
          const n1 = document.createElement('div');
          n1.id = '1';
          const n2 = document.createElement('div');
          n2.id = '2';
          const n3 = document.createElement('div');
          n3.id = '3';
          const n4 = document.createElement('div');
          n4.id = '4';
          p.appendChild(n1);
          p.appendChild(n2);
          n1.appendChild(n3);
          n1.appendChild(n4);
        }}
      >
        test
      </button>
    </div>
  );
}

observer 返回了四條 mutation 變化記錄:

由於 MutationObserver 的批量異步處理方式,第一條新增的 n1 節點的 childNodes 已經有 n3 和 n4 節點了:

對於第一條 mutation 執行 processMutation,由於是新增節點會執行 m.addedNodes.forEach((n) => this.genAdds(n, m.target));genAdds 函數會對 n1 節點的子節點遞歸,所以第一次執行完 n1 節點時,addedSet 中已經存在了 n1 和它的兩個子節點 n3、n4:

接下來執行第二條 mutation 即新增 n2 節點,執行完成後 addedSet 中就有全部四個新節點了:

最後執行第三、四條 mutation,但是 addedSet 不會有變化。

此時轉化的第一步 processMutation 就完成了,繼續第二步 this.emit() 轉換成我們需要的 payload:

遍歷 addedSet,先將 n1 節點取出執行 pushAdd(n1),由於 n1 的 nextSibling n2 節點尚未序列化,需要先存儲 n1 到雙向鏈表 addList 的 head 位置待 n2 序列化後再處理。接着取 n3 節點執行 pushAdd(n3),和 n1 一樣,n3 的 nextSibling n4 節點尚未序列化,需要先存到 addList 中,按雙向鏈表添加節點的規則,n3 的前一兄弟節點和後一兄弟節點都沒有在雙向鏈表中,所以需要將 n3 添加到 head 位置上,此時雙向鏈表的結構是:

接下來處理 n4 節點,雖然 n4 的 nextSiblingnull ( nextId 也是 null ),但是它的父節點 n1 依然沒有序列化(也暫存在雙向鏈表中等待稍後序列化),所以 n4 也命中了 if (parentId === -1|| nextId === -1) 的判斷需要存到鏈表中,由於 n4 的前一兄弟節點 n3 在鏈表頭部,所以按照雙向鏈表添加節點的規則需要將 n4 存到 n3 的後面,此時雙向鏈表的結構是:

最後處理 n2 節點,由於 n2 的父節點是已經在 mirror 映射中的,所以能取到 parentId,它沒有下一兄弟節點,所以 nextIdnull,無需添加到鏈表中,可以直接序列化 serializeNodeWithId(n2, {...}),把序列化的結果以及 parentIdnextId 一起存到 adds 數組中。

addedSet 的四個節點遍歷完成後,最後一步是倒序處理雙向鏈表暫存的那些節點。最後一個節點是 n1,n1 的

nextSibling n2 已經序列化了,執行 pushAdd(n1),能拿到 n1 的 parentIdnextId,直接序列化 serializeNodeWithId(n1, {...}),將拿到的序列化節點以及 parentIdnextId 一起存放到 adds 數組中。此時 candidate 指向 n1 的 previous 節點也就是 n4,和 n1 同樣的處理方式,將序列化的 n4 節點以及 parentId(也就是剛剛序列化的 n1 節點的 ID)和 nextId(null)一起存到 adds 數組中。最後是 head 節點即 n3 節點,將序列化的 n3 以及parentId(n1 的 ID)和 nextId(null)一起存到 adds 數組中。

到這裏雙向鏈表中暫存的三個節點也處理完了,此時 adds 數組中保存了全部處理後的四個節點:

依次是 n2、n1、n4 和 n3。組裝好的 payload 如下:

最後經過一系列包裝處理這個 payload 傳遞給使用者寫的 emit 方法去執行,看到瀏覽器打印的信息如下:

payload 基礎上加一個 source 屬性構成 data 字段,source 表示增量快照的類型,0 代表是 DOM 類的 Mutation,另外 timestamptype 是所有 payload 都會包裝的兩個屬性,timestamp 用於表示開始錄屏到現在過了多久用於播放器回放,type 表示這個 payload 的類型,3 代表是增量快照,2 代表是全量快照。

export enum EventType {
  DomContentLoaded,
  Load,
  FullSnapshot,
  IncrementalSnapshot,
  Meta,
  Custom,
  Plugin,
}

3.3 回放過程 replay

3.3.1 前置知識

3.3.2 重建 DOM 流程

在回放過程中,播放器是用 XState 做狀態管理的,有兩個狀態:播放 playing 和暫停 paused,初始狀態是暫停。創建 Replayer 播放器實例時,會創建兩個 service:createPlayerService 用於處理事件回放的邏輯,createSpeedService 用於控制播放速度。然後會用事件中的第一個全量快照來還原一個初始的 DOM 樹作爲後續添加增量快照變更的基礎。與錄屏時相同,對每個節點也要做序列化 buildNodeWithSN 並維護同樣的 mirror 映射。在構建全量 DOM 樹和後面處理增量快照時,都是結合目標節點本身、父節點和兄弟節點的信息來定位位置和屬性,再調用 appendChildinsertBeforeremoveChild 這幾個 Node 節點的方法(或者其他處理節點屬性的方法)。調用 replayer 實例上的 play 方法就開始按時間順序還原增量快照了,會向 playerService 派發 'PLAY' 事件,此時狀態機就從初始的 paused 轉變爲 playing。當調用 replayer 實例上的 pause 方法時,會向 playerService 派發 'PAUSE' 事件,此時狀態由 playing 轉變爲 paused。

回放重建 DOM 與錄屏時的區別是:錄屏時先對 DOM 做改動再產出序列化節點,回放重建是先根據 event 序列化節點,再改動 DOM 結構。兩者各自都隨時維護着一個 mirror 映射。

3.3.3 播放器

rrweb 的播放器是在一個 iframe 上回放錄屏的,爲了阻斷 iframe 上的用戶交互需要做一些特殊處理,比如在 iframe 標籤上設置 CSS 屬性:

pointer-events: none;

爲了去腳本化,將 <script> 標籤替換爲 <noscript> 標籤,另外將 iframesandbox 屬性設置爲 “allow-same-origin”,可以防止任何腳本的執行。

播放器的進度條是如何控制與每個增量快照發生的時間對應上呢?

比如在播放時用戶點擊進度條上的某一點,這一點距離初始時間點是 timeOffset 長度,點擊的這個點可以叫做基線時間點 baselineTime,rrweb 會根據這個點將所有的事件分成兩部分:前一部分是在基線時間點前已經發生的事件隊列,後一部分是待回放的事件隊列。把前一部分事件同步還原構建完成,作爲後面隊列的全量基準 DOM 樹,再繼續異步地按照正確的時間間隔構建後面的增量快照。

rrweb 藉助 requestAnimationFrame 實現了一個高精度的計時器 Timer。上面介紹待回放的事件隊列會被加到定時器的 actions 中,當每次requestAnimationFrame 調用回調函數 check 時,會判斷當前時間與下一個待回放事件的時間先後順序,如果發現當前時間大於等於下一事件的播放時間了,就去 doAction 執行它,確保絕大部分情況下增量快照的重放延遲不超過一幀。

public start() {
  this.timeOffset = 0;
  let lastTimestamp = performance.now();
  const  check = () => {
    const time = performance.now();
    this.timeOffset += (time - lastTimestamp) * this.speed;
    lastTimestamp = time;
    while (this.actions.length) {
      const action = this.actions[0];
      if ( this . timeOffset >= action. delay ) {
 this . actions . shift ();
action. doAction ();
      } else {
        break;
      }
    }
    if ( this . actions . length > 0 || this . liveMode ) {
 this . raf = requestAnimationFrame (check);
}
  };
  this . raf = requestAnimationFrame (check);
}

四、與 WebRTC 對比

RuUxZq

參考資料:

狀態機系列 (一) : 令人頭疼的狀態管理:https://zhuanlan.zhihu.com/p/406551473

rrweb 錄屏原理淺析:https://segmentfault.com/a/1190000041657578

rrweb 帶你還原問題現場:https://musicfe.com/rrweb/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5RUjvKOBjqX5Btrse-C3Jg