乐趣区

如何实现Web页面录屏

摘要: 很有意思的操作 …

  • 原文:web 页面录屏实现
  • 译者:frontdog

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

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 的记录,上代码:

const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const XML_NAMESPACES = ['xmlns', 'xmlns:svg', 'xmlns:xlink'];

function createVirtualDom(element, isSVG = false)  {switch (element.nodeType) {
    case Node.TEXT_NODE:
      return createVirtualText(element);
    case Node.ELEMENT_NODE:
      return createVirtualElement(element, isSVG || element.tagName.toLowerCase() === 'svg');
    default:
      return null;
  }
}

function createVirtualText(element) {
  const vText = {
    text: element.nodeValue,
    type: 'VirtualText',
  };
  if (typeof element.__flow !== 'undefined') {vText.__flow = element.__flow;}
  return vText;
}

function createVirtualElement(element, isSVG = false) {const tagName = element.tagName.toLowerCase();
  const children = getNodeChildren(element, isSVG);
  const {attr, namespace} = getNodeAttributes(element, isSVG);
  const vElement = {tagName, type: 'VirtualElement', children, attributes: attr, namespace,};
  if (typeof element.__flow !== 'undefined') {vElement.__flow = element.__flow;}
  return vElement;
}

function getNodeChildren(element, isSVG = false) {const childNodes = element.childNodes ? [...element.childNodes] : [];
  const children = [];
  childNodes.forEach((cnode) => {children.push(createVirtualDom(cnode, isSVG));
  });
  return children.filter(c => !!c);
}

function getNodeAttributes(element, isSVG = false) {const attributes = element.attributes ? [...element.attributes] : [];
  const attr = {};
  let namespace;
  attributes.forEach(({nodeName, nodeValue}) => {attr[nodeName] = nodeValue;
    if (XML_NAMESPACES.includes(nodeName)) {namespace = nodeValue;} else if (isSVG) {namespace = SVG_NAMESPACE;}
  });
  return {attr, namespace};
}

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

Virtual DOM 还原为 DOM

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

function createElement(vdom, nodeFilter = () => true) {
  let node;
  if (vdom.type === 'VirtualText') {node = document.createTextNode(vdom.text);
  } else {
    node = typeof vdom.namespace === 'undefined'
      ? document.createElement(vdom.tagName)
      : document.createElementNS(vdom.namespace, vdom.tagName);
    for (let name in vdom.attributes) {node.setAttribute(name, vdom.attributes[name]);
    }
    vdom.children.forEach((cnode) => {const childNode = createElement(cnode, nodeFilter);
      if (childNode && nodeFilter(childNode)) {node.appendChild(childNode);
      }
    });
  }
  if (vdom.__flow) {node.__flow = vdom.__flow;}
  return node;
}

DOM 结构变化监控

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

使用 MutationObserver:

const options = {
  childList: true, // 是否观察子节点的变动
  subtree: true, // 是否观察所有后代节点的变动
  attributes: true, // 是否观察属性的变动
  attributeOldValue: true, // 是否观察属性的变动的旧值
  characterData: true, // 是否节点内容或节点文本的变动
  characterDataOldValue: true, // 是否节点内容或节点文本的变动的旧值
  // attributeFilter: ['class', 'src'] 不在此数组中的属性变化时将被忽略
};

const observer = new MutationObserver((mutationList) => {// mutationList: array of mutation});
observer.observe(document.documentElement, options);

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

{
    type: 'childList', // or characterData、attributes
    target: <DOM>,
    // other params
}

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

const onMutationChange = (mutationsList) => {const getFlowId = (node) => {if (node) {
      // 新插入的 DOM 没有标记,所以这里需要兼容
      if (!node.__flow) node.__flow = {id: uuid() };
      return node.__flow.id;
    }
  };
  mutationsList.forEach((mutation) => {const { target, type, attributeName} = mutation;
    const record = { 
      type, 
      target: getFlowId(target), 
    };
    switch (type) {
      case 'characterData':
        record.value = target.nodeValue;
        break;
      case 'attributes':
        record.attributeName = attributeName;
        record.attributeValue = target.getAttribute(attributeName);
        break;
      case 'childList':
        record.removedNodes = [...mutation.removedNodes].map(n => getFlowId(n));
        record.addedNodes = [...mutation.addedNodes].map((n) => {const snapshot = this.takeSnapshot(n);
          return {
            ...snapshot,
            nextSibling: getFlowId(n.nextSibling),
            previousSibling: getFlowId(n.previousSibling)
          };
        });
        break;
    }
    this.records.push(record);
  });
}

function takeSnapshot(node, options = {}) {this.markNodes(node);
  const snapshot = {vdom: createVirtualDom(node),
  };
  if (options.doctype === true) {
    snapshot.doctype = document.doctype.name;
    snapshot.clientWidth = document.body.clientWidth;
    snapshot.clientHeight = document.body.clientHeight;
  }
  return snapshot;
}

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

表单元素监控

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

oninput 事件监听

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

事件对象:select、input,textarea

window.addEventListener('input', this.onFormInput, true);

onFormInput = (event) => {
  const target = event.target;
  if (
    target && 
    target.__flow &&
    ['select', 'textarea', 'input'].includes(target.tagName.toLowerCase())
   ) {
     this.records.push({
       type: 'input', 
       target: target.__flow.id, 
       value: target.value, 
     });
   }
}

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

onchange 事件监听

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

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

window.addEventListener('change', this.onFormChange, true);

onFormChange = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    if (target.tagName.toLowerCase() === 'input' &&
      ['checkbox', 'radio'].includes(target.getAttribute('type'))
    ) {
      this.records.push({
        type: 'checked', 
        target: target.__flow.id, 
        checked: target.checked,
      });
    }
  }
}

onfocus 事件监听

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

window.addEventListener('focus', this.onFormFocus, true);

onFormFocus = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'focus', 
      target: target.__flow.id,
    });
  }
}

onblur 事件监听

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

window.addEventListener('blur', this.onFormBlur, true);

onFormBlur = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'blur', 
      target: target.__flow.id,
    });
  }
}

媒体元素变化监听

这里指 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

function play(options = {}) {const { container, records = [], snapshot ={}} = options;
  const {vdom, doctype, clientHeight, clientWidth} = snapshot;
  this.nodeCache = {};
  this.records = records;
  this.container = container;
  this.snapshot = snapshot;
  this.iframe = document.createElement('iframe');
  const documentElement = createElement(vdom, (node) => {
    // 缓存 DOM
    const flowId = node.__flow && node.__flow.id;
    if (flowId) {this.nodeCache[flowId] = node;
    }
    // 过滤 script
    return !(node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === 'script'); 
  });
    
  this.iframe.style.width = `${clientWidth}px`;
  this.iframe.style.height = `${clientHeight}px`;
  container.appendChild(iframe);
  const doc = iframe.contentDocument;
  this.iframeDocument = doc;
  doc.open();
  doc.write(`<!doctype ${doctype}><html><head></head><body></body></html>`);
  doc.close();
  doc.replaceChild(documentElement, doc.documentElement);
  this.execRecords();}

function execRecords(preDuration = 0) {const record = this.records.shift();
  let node;
  if (record) {setTimeout(() => {switch (record.type) {
        // 'childList'、'characterData'、// 'attributes'、'input'、'checked'、// 'focus'、'blur'、'play''pause' 等事件的处理
      }
      this.execRecords(record.duration);
    }, record.duration - preDuration)
  }
}

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

关于 Fundebug

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

退出移动版