纯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
参考资料:
纯JavaScript实现页面行为的录制的更多相关文章
- 纯javaScript、jQuery实现个性化图片轮播
纯javaScript实现个性化图片轮播 轮播原理说明<如上图所示>: 1. 画布部分(可视区域)属性说明:overflow:hidden使得超出画布部分隐藏或说不可见.position: ...
- javascript实现页面滚屏效果
当我们浏览网页的时候,时常会碰到可以滚动屏幕的炫酷网页,今天笔者对这一技术进行简单实现,效果不及读者理想中那般炫酷,主要针对滚屏的技术原理和思想进行分享和分析.本示例在页面右侧有五个数字标签,代表五个 ...
- Vue 2.x + Webpack 3.x + Nodejs 多页面项目框架(上篇——纯前端多页面)
Vue 2.x + Webpack 3.x + Nodejs 多页面项目框架(上篇--纯前端多页面) @(HTML/JS) 一般来说,使用vue做成单页应用比较好,但特殊情况下,需要使用多页面也有另外 ...
- H5商城,纯前端静态页面
发布时间:2018-09-28 技术:jquery1.10.1+swipeSlide+jquery.mmenu+jquery.touchSwipe+cityinit 概述 纯手写H5商城,2年 ...
- 纯javascript验证,100行超精简代码。
这篇文章转自--寒飞,原帖地址http://blog.csdn.net/luoyehanfei/article/details/42262249 QQ交流群235032949 纯javascript验 ...
- 在SAP UI中使用纯JavaScript显示产品主数据的3D模型视图
在Jerry写这篇文章时,通过Google才知道,SAP其实是有自己的3D模型视图显示解决方案的. 故事要从Right Hemisphere说起,这是一家专业的企业级2D/3D模型浏览及转换的软件供应 ...
- 纯css3手机页面图标样式代码
全部图标:http://hovertree.com/texiao/css/19/ 先看效果: 或者点这里:http://hovertree.com/texiao/css/19/hoverkico.ht ...
- ECharts-基于Canvas,纯Javascript图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表
ECharts http://ecomfe.github.com/echarts 基于Canvas,纯Javascript图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表.创新的拖拽重计算 ...
- JavaScript禁用页面刷新
JavaScript禁用页面刷新代码如下: //禁用F5刷新 document.onkeydown = function () { if (event.keyCode == 116) { event. ...
随机推荐
- Django常用字段及参数、事务、数据库查询优化
常用字段 注意: Django中没有设置对应char类型的字段,但可以支持自己定义. 自定义对应于数据库的char类型字段: from django.db.models import Field cl ...
- Oracle基础之保留字和关键字
Oracle基础之保留字和关键字 在Oracle之中,有分为保留字和关键字,所谓关键字就是Oracle中有实际意义的,而保留字(比如DESC.ORDER等等)是Oracle中不能随便使用的,比如不能随 ...
- ios 页面滑动到底部无法往上滚的问题
简单说明:当h5端使用vue-infinite-scroll 插件 做滚动加载 如:页面布局 <header></header> <div ...
- Python - 线性回归(Linear Regression) 的 Python 实现
背景 学习 Linear Regression in Python – Real Python,前面几篇文章分别讲了"regression怎么理解","线性回归怎么理解& ...
- Java工作流引擎系统节点接收人设置“其他方式总结”系列讲解
关键字: 驰骋工作流程快速开发平台 工作流程管理系统 工作流引擎 asp.net工作流引擎 java工作流引擎. 开发者表单 拖拽式表单 工作流系统CCBPM节点访问规则接收人规则 适配数据库: o ...
- dubbo配置文件解读(2)
<dubbo:service/> <dubbo:reference/> <dubbo:protocol/> <dubbo:registry/> < ...
- Webpack实战(三):作为前端你不得不懂的Webpack资源入口和出口的配置
关于Webpack前两篇跟大家分享的主要是Webpack的一些基本的配置,今天开始我们详细了解一下有关Webpack的各种配置,今天主要跟大家分享的是Webpack的资源入口和资源出口的配置. 如果想 ...
- Proxmox VE:自建虚拟化方案
Proxmox VE 简介 Proxmox Virtual Environment,或 Proxmox VE,是来自德国的开源虚拟化方案.软件和社区支持都是免费的,企业用户则可以通过订阅制获得付费商业 ...
- 华为,小米部分机型微信浏览器rem不适配的解决方案
针对近日华为,小米的部分机型,在升级系统或升级微信之后,微信内置浏览器产生的rem不能正确填充满的问题,有如下解决方案 目前来看,产生这个情况的原因是因为给html附font-size时,附上的fon ...
- 三分钟网络基础-IP地址分类
IP 地址的编址方法共经过了三个历史阶段: 分类的 IP 地址 子网的划分 超网 这篇文章首先介绍,最初始的 IP 地址分类方法. 分类的 IP 将 IP 地址划分为若干个固定类,每一类地址都由两个固 ...