<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          前端錄制回放初體驗

          共 18789字,需瀏覽 38分鐘

           ·

          2021-04-29 21:38

          本篇文章來自團(tuán)隊小伙伴 @陳小信 的一次學(xué)習(xí)分享,希望跟大家分享與探討。

          求積硅步以致千里,勇于探享生活之美。

          前端錄制回放系統(tǒng)初體驗

          問題背景

          什么是前端錄制回放?

          顧名思義,就是錄制用戶在網(wǎng)頁中的各種操作,并且支持能隨時回放操作。

          為什么需要?

          說到需要就不得不說一個經(jīng)典的場景,一般前端做異常監(jiān)控和錯誤上報,會采用自研或接入第三方 SDK 的形式,來收集和上報網(wǎng)站交互過程中 JavaScript 的報錯信息和其它相關(guān)數(shù)據(jù),也就是埋點。

          在傳統(tǒng)的埋點方案中,根據(jù) SourceMap 能定位到具體報錯代碼文件和行列信息等?;灸芏ㄎ淮蟛糠謭鼍皢栴},但有一些情況下是很難復(fù)現(xiàn)錯誤,多是在測試扯皮的時候,程序員口頭禪之一(我這里沒有報錯呀,是不是你電腦有問題)。

          要是能把出錯的操作過程錄制下來就好了,這樣就能方便我們復(fù)現(xiàn)場景了,且留存證據(jù),好像是自己給自己挖了個坑。

          如何實現(xiàn)?

          前端能實現(xiàn)錄視頻?我第一反應(yīng)就是質(zhì)疑,接著我就是一波 Google ,發(fā)現(xiàn)確實有可行方案。

          Google 之前,我想到了通過設(shè)定定時器,對視圖窗口進(jìn)行截圖,截圖可用 canvas2html 的方式來實現(xiàn),但這種方式無疑會造成性能問題,立馬否決。

          下面介紹我所「知道」的 Google 的方案,如有問題,歡迎指正。

          思路初現(xiàn)

          網(wǎng)頁本質(zhì)上是一個 DOM 節(jié)點形式存在,通過瀏覽器渲染出來。我們是否可以把 DOM 以某種方式保存起來,并且在不同時間節(jié)點持續(xù)記錄 DOM 數(shù)據(jù)狀態(tài)。再將數(shù)據(jù)還原成 DOM 節(jié)點渲染出來完成回放呢?

          操作記錄

          通過 document.documentElement.cloneNode() 克隆到 DOM 的數(shù)據(jù)對象,此時這個數(shù)據(jù)不能直接通過接口傳輸給后端,需要進(jìn)行一些格式化預(yù)處理,處理成方便傳輸及存儲的數(shù)據(jù)格式。最簡單的方式就是進(jìn)行序列化,也就是轉(zhuǎn)換成 JSON 數(shù)據(jù)格式。

          // 序列化后
          let docJSON = {
            "type""Document",
            "childNodes": [
              {
                "type""Element",
                "tagName""html",
                "attributes": {},
                "childNodes": [
                 {
                   "type""Element",
                    "tagName""head",
                    "attributes": {},
                    "childNodes": []
                  }
                ]
              }
            ]
          }

          有完整的 DOM 數(shù)據(jù)之后,還需要在 DOM 變化時進(jìn)行監(jiān)聽,記錄每次變化的 DOM 節(jié)點信息。對數(shù)據(jù)進(jìn)行監(jiān)聽可用 MutationObserver,它是一個可以監(jiān)聽 DOM 變化的 API。

          const observer = new MutationObserver(mutationsList => {
              console.log(mutationsList); // 發(fā)生變化的數(shù)據(jù)
          });
          // 以上述配置開始觀察目標(biāo)節(jié)點
          observer.observe(document, {});

          除了對 DOM 變化進(jìn)行監(jiān)聽以外,還有一個就是事件監(jiān)聽,用戶與網(wǎng)頁的交互多是通過鼠標(biāo),鍵盤等輸入設(shè)備來進(jìn)行。而這些交互的背后就是 JavaScript 的事件監(jiān)聽。事件監(jiān)聽可以通過綁定系統(tǒng)事件來完成,同樣是需要記錄下來,以鼠標(biāo)移動為例:

          // 鼠標(biāo)移動
          document.addEventListener('mousemove', e => {
            // 偽代碼 獲取鼠標(biāo)移動的信息并記錄下來
            positions.push({
              x: clientX,
              y: clientY,
              timeOffsetDate.now() - timeBaseline,
            });
          });

          addEventListener可以綁定多個相同事件,不影響開發(fā)者的事件綁定

          回放操作

          數(shù)據(jù)已經(jīng)有了,接著就是回放,回放本質(zhì)上是將 JSON 數(shù)據(jù)還原成 DOM 節(jié)點渲染出來。那就將快照數(shù)據(jù)還原就可以啊「嘴強王者」,數(shù)據(jù)還原并非那么容易??!

          渲染環(huán)境

          首先為了確?;胤胚^程代碼隔離,需要沙箱環(huán)境, iframe 標(biāo)簽可以做到,并且 iframe 提供了 sandbox 屬性可配置沙箱。沙箱環(huán)境的作用是確保代碼安全并且不被干擾。

          <iframe sandbox srcdoc></iframe>

          sanbox 屬性可以做到沙箱作用,點擊查看文檔

          srcdoc 可以直接設(shè)置成一段 html 代碼

          數(shù)據(jù)還原

          快照重組主要是 DOM 節(jié)點的重組,有點像虛擬 DOM 轉(zhuǎn)成真實文檔節(jié)點的過程,但是事件類型快照是不需要重組。

          定時器

          有了數(shù)據(jù)和環(huán)境,還需要定時器。通過定時器不停渲染 DOM ,實質(zhì)上就是一個播放視頻的效果, requestAnimationFrame 是最合適的。

          requestAnimationFrame 會在每一幀中執(zhí)行,從而避免堵塞,是比 setTimeout 更合適的選擇

          至此有一個大概的想法,距離落地還是有段距離。得益于開源,我們可上 Github 看看有沒有合適的輪子可復(fù)制(借鑒),剛好有現(xiàn)成的一框架 「「rrweb」」,不妨一起看看。

          rrweb 框架

          rrweb 是一個前端錄制和回放的框架。全稱 record and replay the web ,顧名思義就是可以錄制和回放 web 界面中的操作,其核心原理就是上面介紹的方案。

          rrweb 組成

          rrweb 包含三個部分:

          • rrweb-snapshot 主要處理 DOM 結(jié)構(gòu)序列化和重組;
          • rrweb 主要功能是錄制和回放;
          • rrweb-player 一個視頻播放器 UI 空間

          rrweb 使用

          npm 安裝習(xí)以為常,import/require 引入問題不大

          錄制

          通過 rrweb.record 方法來錄制頁面,emit 回調(diào)可接受到錄制的數(shù)據(jù)。

          // 1.錄制
          let events = []; // 記錄快照

          rrweb.record({
            emit(event) {
              // 將 event 存入 events 數(shù)組中
              events.push(event);
            },
          });

          回放

          通過 rrweb.Replayer 可回放視頻,需要傳遞錄制好的數(shù)據(jù)。

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

          點擊查看官方案例效果

          rrweb 源碼

          按照以上所說的思路,接下來會解析其中一些關(guān)鍵代碼,當(dāng)然只是在我個人理解上做的一些分析,實際上 rrweb 源碼遠(yuǎn)不止這些。

          rrweb 組成

          核心部分為三大塊: record (錄制)、 replay 回放、 snapshot 快照。

          Record 錄制

          DOM 加載完成后,record 會做一次完整的 DOM 序列化,我們把它叫做全量快照,全量快照記錄了整個 HTML 數(shù)據(jù)結(jié)構(gòu)。

          record.ts 中找到關(guān)鍵的入口函數(shù)的定義 init,入口函數(shù)是會在 document 加載完成或(可交互,完成)時調(diào)用了 takeFullSnapshot 以及 observe(document) 函數(shù)。

          if (
              document.readyState === 'interactive' ||
              document.readyState === 'complete'
          ) {
              init();
          else {
              //...
              on('load',() => { init(); },),
          }
          const init = () => {
              takeFullSnapshot(); // 生成全量快照
              handlers.push(observe(document)); //監(jiān)聽器
          };

          document.readyState 包含三種狀態(tài):

          1. 可交互 interactive;
          2. 正在加載中 loading
          3. 完成 complete

          takeFullSnapshot 從字面意思能看出其作用是生成「完整」的快照,也就是會將 document 序列化出一個完整的數(shù)據(jù),稱之為 「「全量快照」」。

          所有序列化相關(guān)操作都是使用 snapshot 完成,snapshot 接受一個 dom 對象和一個配置對象傳遞 document 將整個頁面序列化得到完成的快照數(shù)據(jù)。

          // 生成全量快照
          takeFullSnapshot = (isCheckout = false) => {
              //...
              const [node, idNodeMap] = snapshot(document, {
                  //...一些配置項
              });
              //...
          }

          idNodeMap 是一個 id  為 key ,DOM 對象為 valuekey-value 鍵值對對象

          observe(document) 是一些監(jiān)聽器的初始化,同樣是將整個 document 對象傳過去進(jìn)行監(jiān)聽,通過調(diào)用 initObservers 來初始化一些監(jiān)聽器。

          const observe = (doc: Document) => {
              return initObservers(//...)
          }

          observer.ts 文件中可以找到 initObservers 函數(shù)定義,該函數(shù)初始化了 11 個監(jiān)聽器,可以分為 DOM 類型 / Event 事件類型 / Media 媒體三大類:

          export function initObservers(
              // dom
              const mutationObserver = initMutationObserver(
          );
              const mousemoveHandler = initMoveObserver();
              const mouseInteractionHandler = initMouseInteractionObserver();
              const scrollHandler = initScrollObserver();
              const viewportResizeHandler = initViewportResizeObserver();
              // ...
          )
          • DOM 變化監(jiān)聽器,主要有 DOM 變化(增刪改), 樣式變化,核心是通過 MutationObserver 來實現(xiàn)

            let mutationObserverCtor = window.MutationObserver;

            const observer = new mutationObserverCtor(
                // 處理變化的數(shù)據(jù)
                mutationBuffer.processMutations.bind(mutationBuffer),
            );
            observer.observe(doc, {});
            return observer;
          • 交互監(jiān)聽-以鼠標(biāo)移動 initMoveObserver 為例

          // 鼠標(biāo)移動記錄
          function initMoveObserver({
              const updatePosition = throttle<MouseEvent | TouchEvent>(
                  (evt) => {
                      positions.push({
                          x: clientX,
                          y: clientY,
                      });
              });
              const handlers = [
                  on('mousemove', updatePosition, doc),
                  on('touchmove', updatePosition, doc),
              ];
          }
          • 媒體類型監(jiān)聽器,有 canvas / video / audio,以 video 為例,本質(zhì)上記錄播放和暫停狀態(tài),mediaInteractionCbplay / pause 狀態(tài)回調(diào)出來。
          function initMediaInteractionObserver(): listenerHandler {
              mediaInteractionCb({
                  type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause,
                  id: mirror.getId(target as INode),
              });
          }

          Snapshot 快照

          snapshot 負(fù)責(zé)序列化和重組的功能,主要通過 serializeNodeWithId 處理 DOM 序列化和 rebuildWithSN 函數(shù)處理 DOM 重組。

          serializeNodeWithId 函數(shù)負(fù)責(zé)序列化,主要做了三件事:

          • 調(diào)用 serializeNode 序列化 Node
          • 通過 genId() 生成唯一ID 并綁定到 Node 中;
          • 遞歸實現(xiàn)序列化子節(jié)點,并最終返回一個帶 ID 的對象
          // 序列化一個帶有ID的DOM
          export function serializeNodeWithId(n{
            // 1. 序列化 核心函數(shù) serializeNode
            const _serializedNode = serializeNode(n);
            // 2. 生成唯一ID
            let id = genId();
            // 綁定ID
            const serializedNode = Object.assign(_serializedNode, { id });
            
            // 3.子節(jié)點序列化-遞歸
            for (const childN of Array.from(n.childNodes)) {
              const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
              if (serializedChildNode) {
                serializedNode.childNodes.push(serializedChildNode);
              }
            }
          }

          serializeNodeWithId 核心是通過 serializeNode 序列化 DOM ,針對不同的節(jié)點分別做了一些特殊處理。

          節(jié)點屬性的處理:

          for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
              attributes[name] = transformAttribute(doc, tagName, name, value);
          }

          處理外聯(lián) css 樣式,通過 getCssRulesString 獲取到具體樣式代碼,并且儲存到 attributes 中。

          const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
          if (cssText) {
              attributes._cssText = absoluteToStylesheet(
                  cssText,
                  stylesheet!.href!,
              );
          }

          處理 form 表單,邏輯是保存選中狀態(tài),并且做了一些安全處理,例如密碼框內(nèi)容替換成 *

          if (
              attributes.type !== 'radio' &&
              attributes.type !== 'checkbox' &&
              // ...
          ) {
              attributes.value = maskInputOptions[tagName] 
                  ? '*'.repeat(value.length) 
                  : value;
            } else if (n.checked) {
              attributes.checked = n.checked;
            }

          canvas 狀態(tài)保存通過 toDataURL 保存 canvas 數(shù)據(jù):

          attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();

          「rebuild」 負(fù)責(zé)重建 DOM :

          • 通過 buildNodeWithSN 函數(shù)重組 Node
          • 遞歸調(diào)用 重組子節(jié)點
          export function buildNodeWithSN(n{
            // DOM 重組核心函數(shù) buildNode
            let node = buildNode(n, { doc, hackCss });
            // 子節(jié)點重建并且appendChild
            for (const childN of n.childNodes) {
              const childNode = buildNodeWithSN(childN);
              if (afterAppend) {
                afterAppend(childNode);
              }
            }
          }

          Replay 回放

          回放部分在 replay.ts 文件中,先創(chuàng)建沙箱環(huán)境,接著或進(jìn)行重建 document 全量快照,在通過 requestAnimationFrame 模擬定時器的方式來播放增量快照。

          replay 的構(gòu)造函數(shù)接收兩個參數(shù),快照數(shù)據(jù) events 和 配置項 config

          export class Replayer {
              constructor(events, config) {
                  // 1.創(chuàng)建沙箱環(huán)境
                  this.setupDom();
                  // 2.定時器
                  const timer = new Timer();
                  // 3.播放服務(wù)
                  this.service = new createPlayerService(events, timer);
                  this.service.start();
              }
          }

          構(gòu)造函數(shù)中最核心三步,創(chuàng)建沙箱環(huán)境,定時器,和初始化播放器并且啟動。播放器創(chuàng)建依賴 eventstimer ,本質(zhì)上還是使用 timer 來實現(xiàn)播放。

          沙箱環(huán)境

          首先,在 replay.ts 的構(gòu)造函數(shù)中可以找打 this.setupDom 的調(diào)用,setupDom 核心是通過 iframe 來創(chuàng)建出一個沙箱環(huán)境。

          private setupDom() {
            // 創(chuàng)建iframe
            this.iframe = document.createElement('iframe');
            this.iframe.style.display = 'none';
            this.iframe.setAttribute('sandbox', attributes.join(' '));
          }

          播放服務(wù)

          同樣在 replay.ts 構(gòu)造函數(shù)中,調(diào)用 createPlayerService 函數(shù)來創(chuàng)建播放器服務(wù)器,該函數(shù)在同級目錄下的 machine.ts 中定義了,核心思路是通過給定時器 timer 加入需要執(zhí)行的快照動作 actions , 在調(diào)用 timer.start() 開始回放快照。

          export function createPlayerService({
              //...
              play(ctx) {
                  // 獲取每個 event 執(zhí)行的 doAction 函數(shù)
                  for (const event of needEvents) {
                      //..
                      const castFn = getCastFn(event);
                      actions.push({
                          doAction() => {
                              castFn();
                          }
                      })
                      //..
                   }
                   // 添加到定時器隊列中
                   timer.addActions(actions);
                   // 啟動定時器播放 視頻
                   timer.start();
              },
              //...
          }

          播放服務(wù)使用到第三方庫 @xstate/fsm 狀態(tài)機來控制各種狀態(tài)(播放,暫停,直播)

          定時器 timer.ts 也是在同級目錄下,核心是通過 requestAnimationFrame 實現(xiàn)了定時器功能, 并對快照回放,以隊列的形式存儲需要播放的快照 actions ,接著在 start 中遞歸調(diào)用 action.doAction 來實現(xiàn)對應(yīng)時間節(jié)點的快照還原。

          export class Timer {
              // 添加隊列
              public addActions(actions: actionWithDelay[]) {
                  this.actions = this.actions.concat(actions);
              }
              // 播放隊列
              public start() {
                  function check({
                      // ...
                      // 循環(huán)調(diào)用actions中的doAction 也就是 castFn 函數(shù)
                      while (actions.length) {
                          const action = actions[0];
                          actions.shift();
                          // doAction 會對快照進(jìn)行回放動作,針對不同快照會執(zhí)行不同動作
                          action.doAction();
                      }
                      if (actions.length > 0 || self.liveMode) {
                          self.raf = requestAnimationFrame(check);
                      }
                  }
                  this.raf = requestAnimationFrame(check);
              }
          }

          doAction 在不同類型快照會執(zhí)行不同動作,在播放服務(wù)中 doAction 最終會調(diào)用 getCastFn 函數(shù)來做了一些 case:

          private getCastFn(event: eventWithTime, isSync = false) {
              switch (event.type) {
                  case EventType.DomContentLoaded: //dom 加載解析完成
                  case EventType.FullSnapshot: // 全量快照
                  case EventType.IncrementalSnapshot: //增量
                      castFn = () => {
                          this.applyIncremental(event, isSync);
                      }
              }
          }

          applyIncremental 函數(shù)會增對不同的增量快照做不同處理,包含 DOM 增量, 鼠標(biāo)交互,頁面滾動等,以DOM 增量快照的 case 為例,最終會走到 applyMutation中:

          private applyIncremental(){
            switch (d.source) {
                case IncrementalSource.Mutation: {
                  this.applyMutation(d, isSync); // DOM變化
                  break;
                }
                case IncrementalSource.MouseMove: //鼠標(biāo)移動
                case IncrementalSource.MouseInteraction: //鼠標(biāo)點擊事件
                //...
          }

          applyMutation 才是最終執(zhí)行 DOM 還原操作的地方,包含 DOM 的增刪改步驟:

          private applyMutation(d: mutationData, useVirtualParent: boolean) {
              d.removes.forEach((mutation) => {
                  //.. 移除dom
              });
              const appendNode = (mutation: addedNodeMutation) => {
                  // 添加dom到具體節(jié)點下
              };
              d.adds.forEach((mutation) => {
                  // 添加
                  appendNode(mutation);
              });
              d.texts.forEach((mutation) => {
                  //...文本處理
              });
              d.attributes.forEach((mutation) => {
                  //...屬性處理
              });
          }

          以上就是回放的關(guān)鍵流程實現(xiàn)代碼,rrweb 中不僅僅是做了這些,還包含數(shù)據(jù)壓縮,移動端處理,隱私問題等等細(xì)節(jié)處理,有興趣可自行查看源碼。

          最后

          這種實現(xiàn)錄制回放思路確實值得學(xué)習(xí),讀 rrweb 源碼的過程也受益頗多,源碼中對數(shù)據(jù)結(jié)構(gòu)的一些使用,例如雙鏈表,隊列,樹等也值得一覽。

          以上便是本次分享的全部內(nèi)容,希望對你有所幫助 ^_^

          喜歡的話別忘了動動手指,點贊、收藏、關(guān)注三連一波帶走。


          關(guān)于我們

          我們是萬拓科創(chuàng)前端團(tuán)隊,左手組件庫,右手工具庫,各種技術(shù)野蠻生長。

          一個人跑得快,不如一群人跑得遠(yuǎn)。歡迎加入我們的小分隊,牛年牛氣轟轟往前沖。

          參考文章

          • rrweb-io/rrweb
          • rrweb:打開 web 頁面錄制與回放的黑盒子
          瀏覽 73
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  91久久国产综合久久91精品网站 | 欧美在线毛片视频 | 五月丁香婷婷综合网 | 奶大灬大灬大灬硬灬爽灬无码视频 | 国产裸体美女永久免费 |