在網上有個開源的rrweb項目,該項目採用TypeScript編寫(不瞭解該語言的可參考以前的《TypeScript躬行記》),分爲三大部分:rrweb-snapshot、rrweb和rrweb-player,可蒐集鼠標軌跡、控件交互等用戶行爲,而且可最大程度的回放(請看demo),看上去像是一個視頻,但其實並非。 css
我會實現一個很是簡單的錄製和回放插件(已上傳至GitHub中),只會監控文本框的屬性變化,並封裝到一個插件中,核心思路和原理參考了rrweb,並作了適當的調整。下圖來自於rrweb的原理一文,只在開始錄製時製做一個完整的DOM快照,以後則記錄全部的操做數據,這些操做數據稱之爲Oplog(operations log)。如此就能在回放時重現對應的操做,也就回放了該操做對視圖的改變。html
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; }
在作好元素序列化的準備後,接下來就是在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
參考資料:
原文出處:https://www.cnblogs.com/strick/p/12206766.html