摘要: 很有意思的操作...

Fundebug经授权转载,版权归原作者所有。

写在前面的话

在看到评论后,突然意识到自己没有提前说明,本文可以说是一篇调研学习文,是我自己感觉可行的一套方案,后续会去读读已经开源的一些类似的代码库,补足自己遗漏的一些细节,所以大家可以当作学习文,生产环境慎用。

录屏重现错误场景

如果你的应用有接入到web apm系统中,那么你可能就知道apm系统能帮你捕获到页面发生的未捕获错误,给出错误栈,帮助你定位到BUG。但是,有些时候,当你不知道用户的具体操作时,是没有办法重现这个错误的,这时候,如果有操作录屏,你就可以清楚地了解到用户的操作路径,从而复现这个BUG并且修复。

实现思路

思路一:利用Canvas截图

这个思路比较简单,就是利用canvas去画网页内容,比较有名的库有:html2canvas,这个库的简单原理是:

  1. 收集所有的DOM,存入一个queue中;
  2. 根据zIndex按照顺序将DOM一个个通过一定规则,把DOM和其CSS样式一起画到Canvas上。

这个实现是比较复杂的,但是我们可以直接使用,所以我们可以获取到我们想要的网页截图。

为了使得生成的视频较为流畅,我们一秒中需要生成大约25帧,也就是需要25张截图,思路流程图如下:

但是,这个思路有个最致命的不足:为了视频流畅,一秒中我们需要25张图,一张图300KB,当我们需要30秒的视频时,图的大小总共为220M,这么大的网络开销明显不行。

思路二:记录所有操作重现

为了降低网络开销,我们换个思路,我们在最开始的页面基础上,记录下一步步操作,在我们需要"播放"的时候,按照顺序应用这些操作,这样我们就能看到页面的变化了。这个思路把鼠标操作和DOM变化分开:

鼠标变化:

  1. 监听mouseover事件,记录鼠标的clientX和clientY。
  2. 重放的时候使用js画出一个假的鼠标,根据坐标记录来更改"鼠标"的位置。

DOM变化:

  1. 对页面DOM进行一次全量快照。包括样式的收集、JS脚本去除,并通过一定的规则给当前的每个DOM元素标记一个id。
  2. 监听所有可能对界面产生影响的事件,例如各类鼠标事件、输入事件、滚动事件、缩放事件等等,每个事件都记录参数和目标元素,目标元素可以是刚才记录的id,这样的每一次变化事件可以记录为一次增量的快照。
  3. 将一定量的快照发送给后端。
  4. 在后台根据快照和操作链进行播放。

当然这个说明是比较简略的,鼠标的记录比较简单,我们不展开讲,主要说明一下DOM监控的实现思路。

页面首次全量快照

首先你可能会想到,要实现页面全量快照,可以直接使用outerHTML

  1. const content = document.documentElement.outerHTML;

这样就简单记录了页面的所有DOM,你只需要首先给DOM增加标记id,然后得到outerHTML,然后去除JS脚本。

但是,这里有个问题,使用outerHTML记录的DOM会将把临近的两个TextNode合并为一个节点,而我们后续监控DOM变化时会使用MutationObserver,此时你需要大量的处理来兼容这种TextNode的合并,不然你在还原操作的时候无法定位到操作的目标节点。

那么,我们有办法保持页面DOM的原有结构吗?

答案是肯定的,在这里我们使用Virtual DOM来记录DOM结构,把documentElement变成Virtual DOM,记录下来,后面还原的时候重新生成DOM即可。

DOM转化为Virtual DOM

我们在这里只需要关心两种Node类型:Node.TEXT_NODENode.ELEMENT_NODE。同时,要注意,SVG和SVG子元素的创建需要使用API:createElementNS,所以,我们在记录Virtual DOM的时候,需要注意namespace的记录,上代码:

  1. const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
  2. const XML_NAMESPACES = ['xmlns', 'xmlns:svg', 'xmlns:xlink'];
  3. function createVirtualDom(element, isSVG = false) {
  4. switch (element.nodeType) {
  5. case Node.TEXT_NODE:
  6. return createVirtualText(element);
  7. case Node.ELEMENT_NODE:
  8. return createVirtualElement(element, isSVG || element.tagName.toLowerCase() === 'svg');
  9. default:
  10. return null;
  11. }
  12. }
  13. function createVirtualText(element) {
  14. const vText = {
  15. text: element.nodeValue,
  16. type: 'VirtualText',
  17. };
  18. if (typeof element.__flow !== 'undefined') {
  19. vText.__flow = element.__flow;
  20. }
  21. return vText;
  22. }
  23. function createVirtualElement(element, isSVG = false) {
  24. const tagName = element.tagName.toLowerCase();
  25. const children = getNodeChildren(element, isSVG);
  26. const { attr, namespace } = getNodeAttributes(element, isSVG);
  27. const vElement = {
  28. tagName, type: 'VirtualElement', children, attributes: attr, namespace,
  29. };
  30. if (typeof element.__flow !== 'undefined') {
  31. vElement.__flow = element.__flow;
  32. }
  33. return vElement;
  34. }
  35. function getNodeChildren(element, isSVG = false) {
  36. const childNodes = element.childNodes ? [...element.childNodes] : [];
  37. const children = [];
  38. childNodes.forEach((cnode) => {
  39. children.push(createVirtualDom(cnode, isSVG));
  40. });
  41. return children.filter(c => !!c);
  42. }
  43. function getNodeAttributes(element, isSVG = false) {
  44. const attributes = element.attributes ? [...element.attributes] : [];
  45. const attr = {};
  46. let namespace;
  47. attributes.forEach(({ nodeName, nodeValue }) => {
  48. attr[nodeName] = nodeValue;
  49. if (XML_NAMESPACES.includes(nodeName)) {
  50. namespace = nodeValue;
  51. } else if (isSVG) {
  52. namespace = SVG_NAMESPACE;
  53. }
  54. });
  55. return { attr, namespace };
  56. }

通过以上代码,我们可以将整个documentElement转化为Virtual DOM,其中__flow用来记录一些参数,包括标记ID等,Virtual Node记录了:type、attributes、children、namespace。

Virtual DOM还原为DOM

将Virtual DOM还原为DOM的时候就比较简单了,只需要递归创建DOM即可,其中nodeFilter是为了过滤script元素,因为我们不需要JS脚本的执行。

  1. function createElement(vdom, nodeFilter = () => true) {
  2. let node;
  3. if (vdom.type === 'VirtualText') {
  4. node = document.createTextNode(vdom.text);
  5. } else {
  6. node = typeof vdom.namespace === 'undefined'
  7. ? document.createElement(vdom.tagName)
  8. : document.createElementNS(vdom.namespace, vdom.tagName);
  9. for (let name in vdom.attributes) {
  10. node.setAttribute(name, vdom.attributes[name]);
  11. }
  12. vdom.children.forEach((cnode) => {
  13. const childNode = createElement(cnode, nodeFilter);
  14. if (childNode && nodeFilter(childNode)) {
  15. node.appendChild(childNode);
  16. }
  17. });
  18. }
  19. if (vdom.__flow) {
  20. node.__flow = vdom.__flow;
  21. }
  22. return node;
  23. }

DOM结构变化监控

在这里,我们使用了API:MutationObserver,更值得高兴的是,这个API是所有浏览器都兼容的,所以我们可以大胆使用。

使用MutationObserver:

  1. const options = {
  2. childList: true, // 是否观察子节点的变动
  3. subtree: true, // 是否观察所有后代节点的变动
  4. attributes: true, // 是否观察属性的变动
  5. attributeOldValue: true, // 是否观察属性的变动的旧值
  6. characterData: true, // 是否节点内容或节点文本的变动
  7. characterDataOldValue: true, // 是否节点内容或节点文本的变动的旧值
  8. // attributeFilter: ['class', 'src'] 不在此数组中的属性变化时将被忽略
  9. };
  10. const observer = new MutationObserver((mutationList) => {
  11. // mutationList: array of mutation
  12. });
  13. observer.observe(document.documentElement, options);

使用起来很简单,你只需要指定一个根节点和需要监控的一些选项,那么当DOM变化时,在callback函数中就会有一个mutationList,这是一个DOM的变化列表,其中mutation的结构大概为:

  1. {
  2. type: 'childList', // or characterData、attributes
  3. target: <DOM>,
  4. // other params
  5. }

我们使用一个数组来存放mutation,具体的callback为:

  1. const onMutationChange = (mutationsList) => {
  2. const getFlowId = (node) => {
  3. if (node) {
  4. // 新插入的DOM没有标记,所以这里需要兼容
  5. if (!node.__flow) node.__flow = { id: uuid() };
  6. return node.__flow.id;
  7. }
  8. };
  9. mutationsList.forEach((mutation) => {
  10. const { target, type, attributeName } = mutation;
  11. const record = {
  12. type,
  13. target: getFlowId(target),
  14. };
  15. switch (type) {
  16. case 'characterData':
  17. record.value = target.nodeValue;
  18. break;
  19. case 'attributes':
  20. record.attributeName = attributeName;
  21. record.attributeValue = target.getAttribute(attributeName);
  22. break;
  23. case 'childList':
  24. record.removedNodes = [...mutation.removedNodes].map(n => getFlowId(n));
  25. record.addedNodes = [...mutation.addedNodes].map((n) => {
  26. const snapshot = this.takeSnapshot(n);
  27. return {
  28. ...snapshot,
  29. nextSibling: getFlowId(n.nextSibling),
  30. previousSibling: getFlowId(n.previousSibling)
  31. };
  32. });
  33. break;
  34. }
  35. this.records.push(record);
  36. });
  37. }
  38. function takeSnapshot(node, options = {}) {
  39. this.markNodes(node);
  40. const snapshot = {
  41. vdom: createVirtualDom(node),
  42. };
  43. if (options.doctype === true) {
  44. snapshot.doctype = document.doctype.name;
  45. snapshot.clientWidth = document.body.clientWidth;
  46. snapshot.clientHeight = document.body.clientHeight;
  47. }
  48. return snapshot;
  49. }

这里面只需要注意,当你处理新增DOM的时候,你需要一次增量的快照,这里仍然使用Virtual DOM来记录,在后面播放的时候,仍然生成DOM,插入到父元素即可,所以这里需要参照DOM,也就是兄弟节点。

表单元素监控

上面的MutationObserver并不能监控到input等元素的值变化,所以我们需要对表单元素的值进行特殊处理。

oninput事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

事件对象:select、input,textarea

  1. window.addEventListener('input', this.onFormInput, true);
  2. onFormInput = (event) => {
  3. const target = event.target;
  4. if (
  5. target &&
  6. target.__flow &&
  7. ['select', 'textarea', 'input'].includes(target.tagName.toLowerCase())
  8. ) {
  9. this.records.push({
  10. type: 'input',
  11. target: target.__flow.id,
  12. value: target.value,
  13. });
  14. }
  15. }

在window上使用捕获来捕获事件,后面也是这样处理的,这样做的原因是我们是可能并经常在冒泡阶段阻止冒泡来实现一些功能,所以使用捕获可以减少事件丢失,另外,像scroll事件是不会冒泡的,必须使用捕获。

onchange事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

input事件没法满足type为checkbox和radio的监控,所以需要借助onchange事件来监控

  1. window.addEventListener('change', this.onFormChange, true);
  2. onFormChange = (event) => {
  3. const target = event.target;
  4. if (target && target.__flow) {
  5. if (
  6. target.tagName.toLowerCase() === 'input' &&
  7. ['checkbox', 'radio'].includes(target.getAttribute('type'))
  8. ) {
  9. this.records.push({
  10. type: 'checked',
  11. target: target.__flow.id,
  12. checked: target.checked,
  13. });
  14. }
  15. }
  16. }

onfocus事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

  1. window.addEventListener('focus', this.onFormFocus, true);
  2. onFormFocus = (event) => {
  3. const target = event.target;
  4. if (target && target.__flow) {
  5. this.records.push({
  6. type: 'focus',
  7. target: target.__flow.id,
  8. });
  9. }
  10. }

onblur事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

  1. window.addEventListener('blur', this.onFormBlur, true);
  2. onFormBlur = (event) => {
  3. const target = event.target;
  4. if (target && target.__flow) {
  5. this.records.push({
  6. type: 'blur',
  7. target: target.__flow.id,
  8. });
  9. }
  10. }

媒体元素变化监听

这里指audio和video,类似上面的表单元素,可以监听onplay、onpause事件、timeupdate、volumechange等等事件,然后存入records

Canvas画布变化监听

canvas内容变化没有抛出事件,所以我们可以:

  1. 收集canvas元素,定时去更新实时内容
  2. hack一些画画的API,来抛出事件

canvas监听研究没有很深入,需要进一步深入研究

播放

思路比较简单,就是从后端拿到一些信息:

  • 全量快照Virtual DOM
  • 操作链records
  • 屏幕分辨率
  • doctype

利用这些信息,你就可以首先生成页面DOM,其中包括过滤script标签,然后创建iframe,append到一个容器中,其中使用一个map来存储DOM

  1. function play(options = {}) {
  2. const { container, records = [], snapshot ={} } = options;
  3. const { vdom, doctype, clientHeight, clientWidth } = snapshot;
  4. this.nodeCache = {};
  5. this.records = records;
  6. this.container = container;
  7. this.snapshot = snapshot;
  8. this.iframe = document.createElement('iframe');
  9. const documentElement = createElement(vdom, (node) => {
  10. // 缓存DOM
  11. const flowId = node.__flow && node.__flow.id;
  12. if (flowId) {
  13. this.nodeCache[flowId] = node;
  14. }
  15. // 过滤script
  16. return !(node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === 'script');
  17. });
  18. this.iframe.style.width = `${clientWidth}px`;
  19. this.iframe.style.height = `${clientHeight}px`;
  20. container.appendChild(iframe);
  21. const doc = iframe.contentDocument;
  22. this.iframeDocument = doc;
  23. doc.open();
  24. doc.write(`<!doctype ${doctype}><html><head></head><body></body></html>`);
  25. doc.close();
  26. doc.replaceChild(documentElement, doc.documentElement);
  27. this.execRecords();
  28. }
  29. function execRecords(preDuration = 0) {
  30. const record = this.records.shift();
  31. let node;
  32. if (record) {
  33. setTimeout(() => {
  34. switch (record.type) {
  35. // 'childList'、'characterData'、
  36. // 'attributes'、'input'、'checked'、
  37. // 'focus'、'blur'、'play''pause'等事件的处理
  38. }
  39. this.execRecords(record.duration);
  40. }, record.duration - preDuration)
  41. }
  42. }

上面的duration在上文中省略了,这个你可以根据自己的优化来做播放的流畅度,看是多个record作为一帧还是原本呈现。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了20亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对1、微脉、青团社等众多品牌企业。欢迎大家免费试用

如何实现Web页面录屏?的更多相关文章

  1. 用js实现web端录屏

    用js实现web端录屏 原创2021-11-14 09:30·无意义的路过 随着互联网技术飞速发展,网页录屏技术已趋于成熟.例如可将录屏技术运用到在线考试中,实现远程监考.屏幕共享以及录屏等:而在我们 ...

  2. IOS客户端UIwebview下web页面闪屏问题

    基于ios客户端uiwebview下的web页面,在其内容高度大于视窗高度时,如果点击超过视窗下文档的底部按钮,收缩内容高度,会发生闪屏问题. 外因是由文档的高度大于视窗的高度所致,本质原因未知. 解 ...

  3. web页面锁屏初级尝试

    因为工作需要,所以在网上找了一些素材来弄这个功能.在我找到的素材中,大多都是不完善的.虽然我的也不是很完善,但是怎么说呢.要求不是很高的话.可以直接拿来用的[需要引用jQuery].废话不多说直接上代 ...

  4. Windows11实现录屏直播,H5页面直播 HLS ,不依赖Flash

    这两天的一个小需求,需要实现桌面实时直播,前面讲了两种方式: 1.Windows 11实现录屏直播,搭建Nginx的rtmp服务 的方式需要依赖与Flash插件,使用场景有限 2.Windows 11 ...

  5. Windows实现桌面录屏、指定窗口录制直播,低延时,H5页面播放

    接着前面记录的3种方式实现桌面推流直播: 1.Windows 11实现录屏直播,搭建Nginx的rtmp服务 的方式需要依赖与Flash插件,使用场景有限 2.Windows 11实现直播,VLC超简 ...

  6. Easyui + asp.net mvc + sqlite 开发教程(录屏)适合入门

    Easyui + asp.net mvc + sqlite 开发教程(录屏)适合入门 第一节: 前言(技术简介) EasyUI 是一套 js的前端框架 利用它可以快速的开发出好看的 前端系统 web ...

  7. 搭建前端监控系统(六)JS截屏和录屏篇

    怎样定位前端线上问题,一直以来,都是很头疼的问题,因为它发生于用户的一系列操作之后.错误的原因可能源于机型,网络环境,接口请求,复杂的操作行为等等,在我们想要去解决的时候很难复现出来,自然也就无法解决 ...

  8. Windows 11实现录屏直播,搭建Nginx的rtmp服务

    先!下载几个工具呗 官方下载FFmpeg:http://www.ffmpeg.org 官方下载nginx-rtmp-module:https://github.com/arut/nginx-rtmp- ...

  9. javascript实现当前页面截屏

    javascript实现当前页面截屏 一.前言 有客户要求能对用户当前页面进行指定区域截屏,类似qq截屏的实现效果.比如用户在处理工作的时候,将当前页面录入后的一些信息进行截图下载保存.但又不能安装任 ...

随机推荐

  1. SVN服务器和客户端的下载和安装

    一.SVN服务器VisualSVN下载和安装 当前版本:4.1.3下载地址:https://www.visualsvn.com/server/download/下载下来的文件:VisualSVN-Se ...

  2. java 获取当前年份 月份,当月第一天和最后一天

    获取当前年份 月份,当月第一天和最后一天,工作中会经常用到,下面是代码: package basic.day01; import java.text.SimpleDateFormat; import ...

  3. java之动态代理设计模式

    代理:专门完成代理请求的操作类,是所有动态代理类的父类,通过此类为一个或多个接口动态地生成实现类. 弄清动态代理的关键是清楚java的反射机制,在https://www.cnblogs.com/xix ...

  4. IT兄弟连 HTML5教程 CSS3属性特效 渐变2 线性渐变实例

    3 线性渐变实例 一.颜色从顶部向底部渐变 制作从顶部到底部直线渐变有三种方法,第一种是起点参数不设置,因为起点参数的默认值为“top”:第二种方法起点参数设置为“top”:第三种起点参数使用“-90 ...

  5. Java基础语法06-面向对象-继承

    七.继承 多个类中存在相同属性和行为时,将这些内容抽取到单独一个类中,那么多个类中无需再定义这些属性和行为,只需要和抽取出来的类构成继承关系. 继承的好处 提高代码的复用性. 提高代码的扩展性. 类与 ...

  6. 系统 (一) Windows10安装Ubuntu子系统

    前言 本文将基于 Windows10专业版 安装 Ubuntu子系统 1.控制面板 -> 程序 -> 选择启用或关闭Windows功能 -> 勾上 适用Linux的Windwos子系 ...

  7. 【原创】flash中DataGrid数据列显示顺序的解决办法(非数据排序)

    今天在用flash做一个简单的地图展示功能,需要把xml绑定到DataGrid,完成后,又仔细看了几遍,发现列的顺序不对,准确的说是不稳定,不固定,于是在网上查了一下,没有相关的内容.于是自己研究了一 ...

  8. try catch在for循环外面还是里面

    static void Main(string[] args) { //将异常写在循环外,出现异常循环终止 try { Console.WriteLine("抛出异常不输出"); ...

  9. VS 2017 代码报错编译正常

    今天遇到一个奇葩的错误,代码报红波浪线错误,但编译正常,程序能正常运行; 解决方法 在项目引用中把报错的代码所在项目先移除,再重新引用,然后编译一下就好了

  10. C language bit byte and word

    bit:The smallest storage unit of a computer byte:Common computer storage unit word:Computer natural ...