純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