1. rrweb 實(shí)現(xiàn)原理介紹

        共 37482字,需瀏覽 75分鐘

         ·

        2023-08-22 11:52

        一、背景

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

        二、基本使用

        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 數(shù)組中
            if (events.length > 100) { // 當(dāng)事件數(shù)量大于 100 時(shí)停止錄制
              stopFn();
            }
          },
        });

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

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

        三、實(shí)現(xiàn)原理

        3.1 包的組成

        rrweb 主要由以下三個(gè)包構(gòu)成:

        3.1.1 rrweb

        主要提供了 recordreplay 兩個(gè)方法,record 負(fù)責(zé)從一開始錄制 DOM 全量信息,到后面監(jiān)聽頁面的變化(mutation),并將每次的變化 emit 出來傳給開發(fā)用戶。replay 負(fù)責(zé)將 record 錄制的一系列 JSON 數(shù)據(jù)重組再回放出當(dāng)時(shí)的頁面內(nèi)容。

        3.1.2 rrweb-snapshot

        主要提供了 record 中用的兩個(gè)方法:序列化 node 節(jié)點(diǎn)獲得用于傳遞變化信息的 serializeNodeWithId 和獲取頁面快照的 snapshot ;此外還提供了 replay 中用到的一個(gè)方法:還原頁面快照幫助構(gòu)建回放 DOM 的 rebuild。

        3.1.3 rrweb-player

        為 rrweb 設(shè)計(jì)了一套全新 UI 的播放器,可以實(shí)現(xiàn)拖拽進(jìn)度條、調(diào)整播放速度等功能。

        3.2 錄制過程 record

        整體思路:初始化時(shí)獲取當(dāng)前頁面的全量快照,添加監(jiān)聽器監(jiān)聽頁面不同類型的變化(比如 DOM 的變化以及鼠標(biāo)、滾動以及頁面 resize 等的變化),當(dāng)以上這些變化(mutation)發(fā)生時(shí),根據(jù)類型的不同分別進(jìn)行不同的序列化處理,并將處理好的數(shù)據(jù) emit 出來。序列化處理時(shí),給每個(gè)序列化的 node 節(jié)點(diǎn)分配一個(gè) ID,并維護(hù)一個(gè)從 ID 到 node 節(jié)點(diǎn)的映射以及一個(gè) node 節(jié)點(diǎn)到序列化后 serializedNode 節(jié)點(diǎn)的映射。

        Q:為什么需要序列化節(jié)點(diǎn)?直接用原生的 node 節(jié)點(diǎn)不行嗎?
        A:由于需要經(jīng)過網(wǎng)絡(luò)傳輸存儲在后端,如果直接用 node 節(jié)點(diǎn)對象首先是無法通過網(wǎng)絡(luò)傳輸(必須要序列化),其次后端也無法存儲。因此需要設(shè)計(jì)出一種合適的(能完整表達(dá)一個(gè)節(jié)點(diǎn)的所有信息,如位置、屬性等)數(shù)據(jù)結(jié)構(gòu)來序列化節(jié)點(diǎn)。

        3.2.1 前置知識

        Node.nodeType(https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType: 代表 node 節(jié)點(diǎn)的不同類型,在 rrweb 中我們常用到的有 ELEMENT_NODE、TEXT_NODEDOCUMENT_NODE 。

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

        MutationObserver(https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver:可以監(jiān)視 DOM 樹的變更,當(dāng)發(fā)生變更時(shí)會調(diào)用傳入構(gòu)建函數(shù)的 callback。它最重要的特點(diǎn)是會批量異步處理 DOM 的變化,比如對于多個(gè) appendChildremoveChild 會批量處理調(diào)用一次 callback。

        3.2.2 源碼閱讀

        為了解主流程原理,對源碼進(jìn)行了大幅簡化。首先從我們調(diào)用的 rrweb.record 方法進(jìn)入:

        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: {
                        hrefwindow.location.href,
                        width: getWindowWidth(),
                        height: getWindowHeight(),
                    },
                }), isCheckout);
                // 獲取了文檔的全量快照,同時(shí)維護(hù)了一個(gè)節(jié)點(diǎn)和 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,
        },
        })),
                    // 其他監(jiān)聽器...
                }, hooks);
            };
            const  init = () => {
         takeFullSnapshot ();
        handlers. push ( observe ( document ));
        recording = true ;
        };
         init ();
            return () => {
                handlers.forEach((h) => h());
                recording = false;
            };
        }

        record 中定義了多個(gè)關(guān)鍵的函數(shù)。init 中執(zhí)行了 takeFullSnapshotobserve(document)

        • takeFullSnapshot :獲取文檔的全量快照,作為后面增量快照的基準(zhǔn)。首先 emit 了一個(gè) meta 信息,然后執(zhí)行 snapshot(document, {...}),會遍歷整個(gè)文檔樹,為每個(gè)節(jié)點(diǎn)創(chuàng)建一個(gè)唯一的 ID 并序列化,維護(hù)在 mirror 對象的映射中。mirror 中維護(hù)了一個(gè) ID 到原生 node 節(jié)點(diǎn)的映射和一個(gè)原生 node 節(jié)點(diǎn)到序列化后的 serializedNode 的映射,后面所有對 DOM 的操作變化都會實(shí)時(shí)維護(hù)在這兩個(gè)映射中。這個(gè)映射主要用在回放中,可以試想如果只在本地構(gòu)建重組 DOM 樹,可以直接用原生的 node 節(jié)點(diǎn)組裝起來(直接利用原生 node 的自帶屬性,如parentNode、nextSiblingpreviousSibling等);但是如果需要傳遞一系列增量快照到遠(yuǎn)端存儲并試圖重建時(shí),就必須傳遞可以序列化的信息,必須要有 ID 和序列化后的節(jié)點(diǎn)信息,這樣用每個(gè)節(jié)點(diǎn)的 ID 加上這個(gè)節(jié)點(diǎn)本身的一些信息(比如節(jié)點(diǎn)類型,屬性等)就可以重新構(gòu)建。最后 emit 一個(gè) fullSnapshot 信息,將序列化好的整個(gè) DOM 樹當(dāng)作參數(shù)。
        • observe:初始化各種監(jiān)聽器,以兩種主要的變化舉例:鼠標(biāo)的移動和 DOM 的變化。它們都包了兩層,第一層先通過 wrapEvent 封裝一個(gè)帶 timestamp 時(shí)間戳(用于后續(xù)還原播放時(shí)使用)的 payload,然后再執(zhí)行wrappedEmit 函數(shù),這個(gè)函數(shù)包裝了外界傳參進(jìn)來的 emit 方法,也就是說帶時(shí)間戳的 payload 會被作為入?yún)鹘o rrweb 使用的開發(fā)者所寫的 emit 方法。

        這些 payload 根據(jù)變化類型的不同會有各自的屬性,來幫助播放時(shí)還原錄制的現(xiàn)場,比如鼠標(biāo)的移動就需要鼠標(biāo)的位置信息:

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

        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, {
                trailingfalse,
            });

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

        首先對幾種鼠標(biāo)變化添加 addEventListener 監(jiān)聽器,當(dāng)發(fā)生變化時(shí)執(zhí)行 updatePosition 函數(shù)把獲得的 position 作為 mousemoveCb 函數(shù)的入?yún)鞒鰜恚罱K給到上文的 emit 方法。注意其中做了節(jié)流的處理,rrweb 支持用 sampling 屬性來配置抽樣的頻率。

        再來看看我們最關(guān)注的 DOM 變化是如何轉(zhuǎn)換成增量快照的。和鼠標(biāo)移動的處理方式一樣,在 initObservers 函數(shù)中調(diào)用處理 mutation 的 initMutationObserver 函數(shù),其中我們創(chuàng)造了一個(gè) MutationBuffer 對象 mutationBuffer 并 init 用來存放每次的 DOM 變化,然后利用創(chuàng)造一個(gè) MutationObserver 對象 observer,observer 觀察文檔所有內(nèi)容的變化。

        function initMutationObserver(options, rootEl{
            const mutationBuffer = new MutationBuffer(); // 存放本次變化有關(guān)的信息
            mutationBuffers.push(mutationBuffer);
            mutationBuffer.init(options);
            const observer = new MutationObserver(mutationBuffer.processMutations.bind(mutationBuffer));
            observer.observe(rootEl, {
                attributestrue,
                attributeOldValuetrue,
                characterDatatrue,
                characterDataOldValuetrue,
                childListtrue,
                subtreetrue,
            });
            return observer;
        }

        當(dāng)變化發(fā)生時(shí),執(zhí)行 mutationBuffer 的一個(gè)方法 processMutations,mutation 的類型有三種:

        • characterData:純文本類型的變動;
        • attributes:節(jié)點(diǎn)屬性類的變動;
        • childList:節(jié)點(diǎn)的新增、刪除和移動。

        我們最關(guān)注第三類節(jié)點(diǎn)的變化:

        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 對象維護(hù)了兩個(gè)集合:addedSetmovedSet,還有一個(gè) removes 數(shù)組,用于處理三種節(jié)點(diǎn)的變化:

        • 新增節(jié)點(diǎn)(mutation.addedNodes): 直接添加到 addedSet 中,對于這個(gè)節(jié)點(diǎn)的 childNodes ****中每個(gè)子節(jié)點(diǎn)都去遞歸執(zhí)行 genAdds 函數(shù);
        • 刪除節(jié)點(diǎn)(mutation.removedNodes): 由于 MutationObserver 批量異步處理的特性,如果本次變化中出現(xiàn)先增加 A 節(jié)點(diǎn),再刪除 A 節(jié)點(diǎn),此次變化的 addedNodesremovedNodes 都會有 A 節(jié)點(diǎn)。按照處理順序會先把該節(jié)點(diǎn)添加進(jìn) addedSet 中,再處理 removedNodes 時(shí)應(yīng)該把它從 addedSet 中刪掉。對于需要真正刪掉之前已有節(jié)點(diǎn)的情況,我們在回放時(shí)只需要拿到它的父節(jié)點(diǎn)和被刪除的節(jié)點(diǎn)本身,所以直接將它的父節(jié)點(diǎn) ID 和它本身的 ID (由于是已有的節(jié)點(diǎn),所以在我們的 mirror 映射中一定能找到對應(yīng)的節(jié)點(diǎn)信息)存放到 removes 數(shù)組當(dāng)中即可;
        • 移動節(jié)點(diǎn): 當(dāng)我們的映射 mirror 中已經(jīng)存在節(jié)點(diǎn) n 時(shí),代表本次 mutation 的節(jié)點(diǎn)之前就在我們的 DOM 結(jié)構(gòu)中。移動產(chǎn)生的根本原因也一定是先 removeChild,再 appendChild 這個(gè)移除的節(jié)點(diǎn)到新的父節(jié)點(diǎn)下。因此在 MutationObserver 中會先產(chǎn)生一個(gè) mutation.removedNodes 的記錄,再產(chǎn)生一個(gè) mutation.addedNodes 的記錄。首先按照刪除節(jié)點(diǎn)的邏輯,會存放該節(jié)點(diǎn)信息到 removes 數(shù)組中,然后到 genAdds ****函數(shù)中發(fā)現(xiàn)此節(jié)點(diǎn)在 mirror 映射中,因此屬于移動的節(jié)點(diǎn),添加到 movedSet 中,同樣遞歸它的子節(jié)點(diǎn)執(zhí)行 genAdds 函數(shù)。

        添加節(jié)點(diǎn)時(shí)使用集合 Set 的原因:

        以下兩種操作會生成相同的 DOM 結(jié)構(gòu),但是產(chǎn)生不同的 mutation 記錄:

        • 會生成兩條 mutation 記錄,但是由于 MutationObserver 的批量異步處理特性,在第一條 mutation 記錄中拿到的 n1 節(jié)點(diǎn)此時(shí)已經(jīng)有 childNodes 了(即 n2 節(jié)點(diǎn));
        • 只會產(chǎn)生一條 mutation 記錄,即 n1 添加到父節(jié)點(diǎn)中,為了不落下 n2 節(jié)點(diǎn),需要對這條 mutation 記錄遍歷它的所有子節(jié)點(diǎn)(上文新增節(jié)點(diǎn)中有提到)。

        那么如果對于第一種情況,處理 n1 時(shí)遍歷它的子節(jié)點(diǎn)添加了一次 n2,再處理第二條 mutation 記錄 n2 節(jié)點(diǎn)時(shí)又會添加一遍,因此為了去重需要使用集合 Set。而刪除節(jié)點(diǎn)則無需用集合,因?yàn)樵诨胤?removeChild 時(shí)自然會把所有子節(jié)點(diǎn)都刪掉。

        processMutations 中,以上工作將本次回調(diào)的所有變動都收集好了,接下來繼續(xù)執(zhí)行 emit 方法:

        共識:序列化節(jié)點(diǎn)的順序應(yīng)當(dāng)是從位置能確定的節(jié)點(diǎn)(父節(jié)點(diǎn)和兄弟節(jié)點(diǎn)已經(jīng)過序列化)開始。對于不確定的節(jié)點(diǎn),需要先存儲起來( 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 方法最終會組合出一個(gè)代表本次 DOM 變化的 payload 傳給 mutationCb(在 mutationBuffer init 時(shí)傳入)執(zhí)行,最終一路向上追溯到執(zhí)行 rrweb 使用方所寫的 emit 函數(shù)。

        我們分析下是如何拿到這個(gè) payload 的:

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

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

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

        到這里所有被添加的節(jié)點(diǎn)也都處理完成了,adds 數(shù)組就是我們 payload 需要的,也就完成了從一次 mutationObserver 回調(diào)的多條記錄到一個(gè) payload 中的文本、屬性、添加節(jié)點(diǎn)信息、移除節(jié)點(diǎn)信息的轉(zhuǎn)變

        3.2.3 舉例

        舉一個(gè)稍微復(fù)雜的例子,按 1234 的順序添加節(jié)點(diǎn)到 DOM 中:

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

          return (
            <div className="App">
              <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 節(jié)點(diǎn)的 childNodes 已經(jīng)有 n3 和 n4 節(jié)點(diǎn)了:

        對于第一條 mutation 執(zhí)行 processMutation,由于是新增節(jié)點(diǎn)會執(zhí)行 m.addedNodes.forEach((n) => this.genAdds(n, m.target));。 genAdds 函數(shù)會對 n1 節(jié)點(diǎn)的子節(jié)點(diǎn)遞歸,所以第一次執(zhí)行完 n1 節(jié)點(diǎn)時(shí),addedSet 中已經(jīng)存在了 n1 和它的兩個(gè)子節(jié)點(diǎn) n3、n4:

        接下來執(zhí)行第二條 mutation 即新增 n2 節(jié)點(diǎn),執(zhí)行完成后 addedSet 中就有全部四個(gè)新節(jié)點(diǎn)了:

        最后執(zhí)行第三、四條 mutation,但是 addedSet 不會有變化。

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

        遍歷 addedSet,先將 n1 節(jié)點(diǎn)取出執(zhí)行 pushAdd(n1),由于 n1 的 nextSibling n2 節(jié)點(diǎn)尚未序列化,需要先存儲 n1 到雙向鏈表 addList 的 head 位置待 n2 序列化后再處理。接著取 n3 節(jié)點(diǎn)執(zhí)行 pushAdd(n3),和 n1 一樣,n3 的 nextSibling n4 節(jié)點(diǎn)尚未序列化,需要先存到 addList 中,按雙向鏈表添加節(jié)點(diǎn)的規(guī)則,n3 的前一兄弟節(jié)點(diǎn)和后一兄弟節(jié)點(diǎn)都沒有在雙向鏈表中,所以需要將 n3 添加到 head 位置上,此時(shí)雙向鏈表的結(jié)構(gòu)是:

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

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

        addedSet 的四個(gè)節(jié)點(diǎn)遍歷完成后,最后一步是倒序處理雙向鏈表暫存的那些節(jié)點(diǎn)。最后一個(gè)節(jié)點(diǎn)是 n1,n1 的

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

        到這里雙向鏈表中暫存的三個(gè)節(jié)點(diǎn)也處理完了,此時(shí) adds 數(shù)組中保存了全部處理后的四個(gè)節(jié)點(diǎn):

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

        最后經(jīng)過一系列包裝處理這個(gè) payload 傳遞給使用者寫的 emit 方法去執(zhí)行,看到瀏覽器打印的信息如下:

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

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

        3.3 回放過程 replay

        3.3.1 前置知識

        • XState(https://xstate.js.org/docs/zh/:有限狀態(tài)機(jī),通過各種不同的 action 管理狀態(tài)的流轉(zhuǎn)。
        • requestAnimationFrame(https://developer.mozilla.org/zh-CN/docs/Web/API/window/requestAnimationFrame:告訴瀏覽器執(zhí)行一個(gè)動畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動畫。該方法需要傳入一個(gè)回調(diào)函數(shù)作為參數(shù),該回調(diào)函數(shù)會在瀏覽器下一次重繪之前執(zhí)行。

        3.3.2 重建 DOM 流程

        在回放過程中,播放器是用 XState 做狀態(tài)管理的,有兩個(gè)狀態(tài):播放 playing 和暫停 paused,初始狀態(tài)是暫停。創(chuàng)建 Replayer 播放器實(shí)例時(shí),會創(chuàng)建兩個(gè) service:createPlayerService 用于處理事件回放的邏輯,createSpeedService 用于控制播放速度。然后會用事件中的第一個(gè)全量快照來還原一個(gè)初始的 DOM 樹作為后續(xù)添加增量快照變更的基礎(chǔ)。與錄屏?xí)r相同,對每個(gè)節(jié)點(diǎn)也要做序列化 buildNodeWithSN 并維護(hù)同樣的 mirror 映射。在構(gòu)建全量 DOM 樹和后面處理增量快照時(shí),都是結(jié)合目標(biāo)節(jié)點(diǎn)本身、父節(jié)點(diǎn)和兄弟節(jié)點(diǎn)的信息來定位位置和屬性,再調(diào)用 appendChildinsertBefore、removeChild 這幾個(gè) Node 節(jié)點(diǎn)的方法(或者其他處理節(jié)點(diǎn)屬性的方法)。調(diào)用 replayer 實(shí)例上的 play 方法就開始按時(shí)間順序還原增量快照了,會向 playerService 派發(fā) 'PLAY' 事件,此時(shí)狀態(tài)機(jī)就從初始的 paused 轉(zhuǎn)變?yōu)?playing。當(dāng)調(diào)用 replayer 實(shí)例上的 pause 方法時(shí),會向 playerService 派發(fā) 'PAUSE' 事件,此時(shí)狀態(tài)由 playing 轉(zhuǎn)變?yōu)?paused。

        回放重建 DOM 與錄屏?xí)r的區(qū)別是:錄屏?xí)r先對 DOM 做改動再產(chǎn)出序列化節(jié)點(diǎn),回放重建是先根據(jù) event 序列化節(jié)點(diǎn),再改動 DOM 結(jié)構(gòu)。兩者各自都隨時(shí)維護(hù)著一個(gè) mirror 映射。

        3.3.3 播放器

        rrweb 的播放器是在一個(gè) iframe 上回放錄屏的,為了阻斷 iframe 上的用戶交互需要做一些特殊處理,比如在 iframe 標(biāo)簽上設(shè)置 CSS 屬性:

        pointer-eventsnone;

        為了去腳本化,將 <script> 標(biāo)簽替換為 <noscript> 標(biāo)簽,另外將 iframesandbox 屬性設(shè)置為 “allow-same-origin”,可以防止任何腳本的執(zhí)行。

        播放器的進(jìn)度條是如何控制與每個(gè)增量快照發(fā)生的時(shí)間對應(yīng)上呢?

        比如在播放時(shí)用戶點(diǎn)擊進(jìn)度條上的某一點(diǎn),這一點(diǎn)距離初始時(shí)間點(diǎn)是 timeOffset 長度,點(diǎn)擊的這個(gè)點(diǎn)可以叫做基線時(shí)間點(diǎn) baselineTime,rrweb 會根據(jù)這個(gè)點(diǎn)將所有的事件分成兩部分:前一部分是在基線時(shí)間點(diǎn)前已經(jīng)發(fā)生的事件隊(duì)列,后一部分是待回放的事件隊(duì)列。把前一部分事件同步還原構(gòu)建完成,作為后面隊(duì)列的全量基準(zhǔn) DOM 樹,再繼續(xù)異步地按照正確的時(shí)間間隔構(gòu)建后面的增量快照。

        rrweb 借助 requestAnimationFrame 實(shí)現(xiàn)了一個(gè)高精度的計(jì)時(shí)器 Timer。上面介紹待回放的事件隊(duì)列會被加到定時(shí)器的 actions 中,當(dāng)每次requestAnimationFrame 調(diào)用回調(diào)函數(shù) check 時(shí),會判斷當(dāng)前時(shí)間與下一個(gè)待回放事件的時(shí)間先后順序,如果發(fā)現(xiàn)當(dāng)前時(shí)間大于等于下一事件的播放時(shí)間了,就去 doAction 執(zhí)行它,確保絕大部分情況下增量快照的重放延遲不超過一幀。

        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 對比


        rrweb WebRTC
        錄制顯示器上的完整信息 僅能錄制當(dāng)前瀏覽器 TAB 頁 ?
        用戶無感知錄制 ? 需要用戶同意并選擇錄制的屏幕內(nèi)容
        錄制內(nèi)容大小 均為 JSON 數(shù)據(jù),且頁面無變動時(shí)不會增加大小 與錄制時(shí)間成正比,占據(jù)存儲空間較大
        播放器 提供了一套獨(dú)立設(shè)計(jì)的播放器,功能完整 需自行尋找合適的播放器
        回放視頻清晰度 完全還原 DOM 結(jié)構(gòu) 清晰度會有損失

        參考資料:

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

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

        rrweb 帶你還原問題現(xiàn)場:https://musicfe.com/rrweb/


        瀏覽 508
        點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評論
        圖片
        表情
        推薦
        點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 日本玉足footjob脚交 freechina麻豆hdvideo | 性少妇vide0seⅹfree | 伊人久久香蕉网 | 婷婷色天使18禁久久yyy | 美国三级日本三级人妇www |