純JavaScript實現頁面行爲的錄製

  在網上有個開源的rrweb項目,該項目採用TypeScript編寫(不瞭解該語言的可參考以前的《TypeScript躬行記》),分爲三大部分:rrweb-snapshot、rrweb和rrweb-player,可蒐集鼠標軌跡、控件交互等用戶行爲,而且可最大程度的回放(請看demo),看上去像是一個視頻,但其實並非。 css

  我會實現一個很是簡單的錄製和回放插件(已上傳至GitHub中),只會監控文本框的屬性變化,並封裝到一個插件中,核心思路和原理參考了rrweb,並作了適當的調整。下圖來自於rrweb的原理一文,只在開始錄製時製做一個完整的DOM快照,以後則記錄全部的操做數據,這些操做數據稱之爲Oplog(operations log)。如此就能在回放時重現對應的操做,也就回放了該操做對視圖的改變。html

1、元素序列化

1)序列化git

  首先要將頁面中的全部元素序列化成一個普通對象,這樣就能調用JSON.stringify()方法將相關數據傳到後臺服務器中。github

  serialization()方法採用遞歸的方式,將元素逐個解析,而且保留了元素的層級關係。web

/** * DOM序列化 */ serialization(parent) { let element = this.parseElement(parent); if (parent.children.length == 0) { parent.textContent && (element.textContent = parent.textContent); return element; } Array.from(parent.children, child => { element.children.push(this.serialization(child)); }); return element; }, /** * 將元素解析成可序列化的對象 */ parseElement(element, id) { let attributes = {}; for (const { name, value } of Array.from(element.attributes)) { attributes[name] = value; } if (!id) {                         //解析新元素才作映射
    id = this.getID(); this.idMap.set(element, id);     //元素爲鍵,ID爲值
 } return { children: [], id: id, tagName: element.tagName.toLowerCase(), attributes: attributes }; } /** * 惟一標識 */ getID() { return this.id++; }

  parseElement()承包瞭解析的邏輯,一個普通元素會變成包含id、tagName、attributes和children屬性,在serialization()中會視狀況爲其增長textContent屬性。segmentfault

  id是一個惟一標識,用於關聯元素,後面在作回放和蒐集動做的時候會用到。this.idMap採用了ES6新增的Map數據結構,可將對象做爲key,它用於記錄ID和元素之間的映射關係。數組

  注意,rrweb遍歷的是Node節點,而我爲了便捷,只是遍歷了元素,這麼作的話會將頁面中的文本節點給忽略掉,例以下面的<div>既包含了<span>元素,也包含了兩個純文本節點。瀏覽器

<div class="ui-mb30"> 提交購買信息審覈後獲油滴,前 <span class="color-red1">100</span>名用戶獲車輪郵寄的 <span class="color-red1">CR2032型號電池</span>
</div>

  當經過本插件還原DOM結構時,只能獲得<span>元素,由此可知只遍歷元素是有缺陷的。服務器

<div class="ui-mb30">
  <span class="color-red1">100</span>
  <span class="color-red1">CR2032型號電池</span>
</div>

2)反序列化數據結構

  既然有序列化,那麼就會有反序列化,也就是將上面生成的普通對象解析成DOM元素。deserialization()方法也採用了遞歸的方式還原DOM結構,在createElement()方法中的this.idMap會以ID爲key,而再也不以元素爲key。

/** * DOM反序列化 */ deserialization(obj) { let element = this.createElement(obj); if (obj.children.length == 0) { return element; } obj.children.forEach(child => { element.appendChild(this.deserialization(child)); }); return element; }, /** * 將對象解析成元素 */ createElement(obj) { let element = document.createElement(obj.tagName); if (obj.id) { this.idMap.set(obj.id, element);         //ID爲鍵,元素爲值
 } for (const name in obj.attributes) { element.setAttribute(name, obj.attributes[name]); } obj.textContent && (element.textContent = obj.textContent); return element; }

2、監控DOM變化

  在作好元素序列化的準備後,接下來就是在DOM發生變化時,記錄相關的動做,這裏涉及兩塊,第一塊是動做記錄,第二塊是元素監控。

1)動做記錄

  setAction()是記錄全部動做的方法,而setAttributeAction()方法則是抽象出來專門處理元素屬性的變化,這麼作便於後期擴展,ACTION_TYPE_ATTRIBUTE常量表示修改屬性的動做。

/** * 配置修改屬性的動做 */ setAttributeAction(element) { let attributes = { type: ACTION_TYPE_ATTRIBUTE }; element.value && (attributes.value = element.value); this.setAction(element, attributes); }, /** * 配置修改動做 */ setAction(element, otherParam = {}) { //因爲element是對象,所以Map中的key會自動更新
  const id = this.idMap.get(element); const action = Object.assign( this.parseElement(element, id), { timestamp: Date.now() }, otherParam ); this.actions.push(action); }

  在setAction()中,timestamp是一個時間戳,記錄了動做發生的時間,後期回放的時候就會按照這個時間有序播放,全部的動做都會插入到this.actions數組中。

2)元素監控

  元素監控會採用兩種方式,第一種是瀏覽器提供的MutationObserver接口,它能監控目標元素的屬性、子元素和數據的變化。一旦監控到變化,就會調用setAttributeAction()方法。

/** * 監控元素變化 */ observer() { const ob = new MutationObserver(mutations => { mutations.forEach(mutation => { const { type, target, oldValue, attributeName } = mutation; switch (type) { case "attributes": const value = target.getAttribute(attributeName); this.setAttributeAction(target); } }); }); ob.observe(document, { attributes: true,             //監控目標屬性的改變
    attributeOldValue: true,      //記錄改變前的目標屬性值
    subtree: true                 //目標以及目標的後代改變都會監控
 }); //ob.disconnect();
}

  第二種是監控元素的事件,本插件只會監控文本框的input事件。在經過addEventListener()方法綁定input事件時,採用了捕獲的方式,而不是冒泡,這樣就能統一綁定的document上。

/** * 監控文本框的變化 */
function observerInput() { const original = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value" ), _this = this; //監控經過代碼更新的value屬性
  Object.defineProperty(HTMLInputElement.prototype, "value", { set(value) { setTimeout(() => { _this.setAttributeAction(this);     //異步調用,避免阻塞頁面
      }, 0); original.set.call(this, value);       //執行原來的set邏輯
 } }); //捕獲input事件
  document.addEventListener("input", event => { const { target } = event; let text = target.value; this.setAttributeAction(target); }, { capture: true     //捕獲
 } ); }

  對於value屬性作了特殊的處理,由於該屬性可經過代碼完成修改,因此會藉助defineProperty()方法,攔截value屬性的set()方法,而原先的邏輯也會保留在original變量中。

  若是沒有執行original.set.call(),那麼爲元素賦值後,頁面中的文本框不會顯示所賦的那個值。

  至此,錄製的邏輯已經所有完成,下面是插件的構造函數,初始化了相關變量。

/** * dom和actions可JSON.stringify()序列化後傳遞到後臺 */
function JSVideo() { this.id = 1; this.idMap = new Map();         //惟一標識和元素之間的映射
  this.dom = this.serialization(document.documentElement); this.actions = [];             //動做日誌
  this.observer(); this.observerInput(); }

3、回放

1)沙盒

  回放分爲兩步,第一步是建立iframe容器,在容器中還原DOM結構。按照rrweb的思路,選擇iframe是由於能夠將其做爲一個沙盒,禁止表單提交、彈窗和執行JavaScript的行爲。

  在建立好iframe元素後,會爲其配置sandbox、style、window和height等屬性,而且在load事件中,反序列化this.dom,以及移除默認的<head>和<body>兩個元素。

/** * 建立iframe還原頁面 */ createIframe() { let iframe = document.createElement("iframe"); iframe.setAttribute("sandbox", "allow-same-origin"); iframe.setAttribute("scrolling", "no"); iframe.setAttribute("style", "pointer-events:none; border:0;"); iframe.width = `${window.innerWidth}px`; iframe.height = `${document.documentElement.scrollHeight}px`; iframe.onload = () => { const doc = iframe.contentDocument, root = doc.documentElement, html = this.deserialization(this.dom);          //反序列化
    //根元素屬性附加
    for (const { name, value } of Array.from(html.attributes)) { root.setAttribute(name, value); } root.removeChild(root.firstElementChild); //移除head
    root.removeChild(root.firstElementChild);         //移除body
    Array.from(html.children).forEach(child => { root.appendChild(child); }); //加個定時器只是爲了查看方便
    setTimeout(() => { this.replay(); }, 5000); }; document.body.appendChild(iframe); }

  rrweb還會將元素的相對地址改爲絕對地址,特殊處理連接等額外操做。

2)動畫

  第二步就是動畫,也就是還原當時的動做,沒有使用定時器模擬動畫,而採用了更精確的requestAnimationFrame()函數。

  注意,在還原元素的value屬性時,會觸發以前的defineProperty攔截,若是拆分紅兩個插件,就能避免該問題。

/** * 回放 */
function replay() { if (this.actions.length == 0) return; const timeOffset = 16.7;                         //一幀的時間間隔大概爲16.7ms
  let startTime = this.actions[0].timestamp;       //開始時間戳
  const state = () => { const action = this.actions[0]; let element = this.idMap.get(action.id); if (!element) { //取不到的元素直接中止動畫
      return; } if (startTime >= action.timestamp) { this.actions.shift(); switch (action.type) { case ACTION_TYPE_ATTRIBUTE: for (const name in action.attributes) { //更新屬性
 element.setAttribute(name, action.attributes[name]); } //觸發defineProperty攔截,拆分紅兩個插件會避免該問題
          action.value && (element.value = action.value); break; } } startTime += timeOffset;         //最大程度的模擬真實的時間差
    if (this.actions.length > 0) //當還有動做時,繼續調用requestAnimationFrame()
 requestAnimationFrame(state); }; state(); }

  爲了模擬出時間間隔,就須要藉助以前每一個元素對象都會保存的timestamp時間戳。默認以第一個動做爲起始時間,接下來每次調用requestAnimationFrame()函數,起始時間都加一次timeOffset變量。

  當startTime超過動做的時間戳時,就執行該動做,不然就不執行任何邏輯,再次回調requestAnimationFrame()函數。

  rrweb有個倍數回放,其實就是加大間隔,在間隔中多執行幾個動做,從而模擬出倍速的效果。

3)簡單的實例

  假設頁面中有一個表單,表單中包含兩個文本框,可分別輸入姓名和手機。下面會採用定時器,在延遲幾秒後分別輸入值,而且在當前頁面的底部添加沙盒,直接查看回放,效果以下圖所示。

const video = new JSVideo(), input = document.querySelector("[name=name]"), mobile = document.querySelector("[name=mobile]"); //修改placeholder屬性
setTimeout(function() { input.setAttribute("placeholder", "name"); }, 1000); //修改姓名的value值
setTimeout(function() { input.value = "Strick"; }, 3000); //修改手機的value值
setTimeout(function() { mobile.value = "13800138000"; }, 4000); //在iframe中回放
setTimeout(function() { video.createIframe(); }, 5000);

 

GitHub地址以下所示:

https://github.com/pwstrick/jsvideo

 

參考資料:

rrweb:打開Web頁面錄製與回放的黑盒子

MutationObserver

MutationRecord

reworkcss/css

基於rrweb錄屏與重放頁面

rrweb 底層設計簡要總結

rrweb源碼解析1

瞭解HTML5中的MutationObserver

 

原文出處:https://www.cnblogs.com/strick/p/12206766.html

相關文章
相關標籤/搜索