在網上有個開源的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
參考資料:
