1. 程式人生 > >純JavaScript實現頁面行為的錄製

純JavaScript實現頁面行為的錄製

  在網上有個開源的rrweb專案,該專案採用TypeScript編寫(不瞭解該語言的可參考之前的《TypeScript躬行記》),分為三大部分:rrweb-snapshot、rrweb和rrweb-player,可蒐集滑鼠軌跡、控制元件互動等使用者行為,並且可最大程度的回放(請看demo),看上去像是一個視訊,但其實並不是。 

  我會實現一個非常簡單的錄製和回放外掛(已上傳至GitHub中),只會監控文字框的屬性變化,並封裝到一個外掛中,核心思路和原理參考了rrweb,並做了適當的調整。下圖來自於rrweb的原理一文,只在開始錄製時製作一個完整的DOM快照,之後則記錄所有的操作資料,這些操作資料稱之為Oplog(operations log)。如此就能在回放時重現對應的操作,也就回放了該操作對檢視的改變。

一、元素序列化

1)序列化

  首先要將頁面中的所有元素序列化成一個普通物件,這樣就能呼叫JSON.stringify()方法將相關資料傳到後臺伺服器中。

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

/**
 * 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屬性。

  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;
}

二、監控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();
}

三、回放

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

&n