纯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

相关文章
相关标签/搜索