How to achieve Web page screen recording?

Summary: very interesting operation ...

Fundebug authorized reprint, belongs to original author.

EDITORIAL words

After seeing the reviews, suddenly I realized he did not explain in advance, we can say that learning is a research paper, that I feel workable set of programs, follow-up will go read some similar code base has been open source, make up your own some details missing, so we can learn as text, production environment with caution.

Record screen to reproduce the error scenario

If your application has access to the web apm system, then you probably know apm system can help you capture page to capture error not occurred, given the error stack to help you locate the BUG. But, sometimes, when you do not know the specific operation of the user, there is no way to reproduce this error, this time, if the operation record screen, you can clearly understand that the path to the user's operation, thereby reproduce the BUG and repair.

Realization of ideas

A thought: use Canvas Screenshot

The idea is relatively simple, is the use of canvas to paint the page content, the more famous library has: html2canvas , this library is a simple principle:

  1. Collect all of the DOM, stored in a queue;
  2. The order of the DOM zIndex one by certain rules, and the DOM CSS style to draw together the Canvas.

This implementation is more complex, but we can use directly, so we can get to a screenshot of the page we want.

In order to make a more smooth video generation, we need to generate approximately one second 25, 25 is required screenshot flowchart idea as follows:

However, this idea has most fatal deficiencies: For smooth video, one second we need 25 chart, a diagram 300KB, when we need a 30-second video, the size of the chart for a total of 220M, so much of the network overhead obviously not.

Thinking two: a record of all operations to reproduce

In order to reduce network overhead, we have another idea, we have the beginning of a page basis, recording the next step operation, when we need to "play", according to the order of application of these operations, so that we can see the changes in the page. The idea of ​​separating the mouse and DOM changes:

Mouse change:

  1. Mouseover event monitor, mouse clientX record and clientY.
  2. Js playback when using the mouse to draw a fake, change the "mouse" position according to the coordinates recorded.

DOM changes:

  1. To conduct a full page DOM volume snapshots. Including the style of the collection, JS script to remove, and mark an id for each current DOM element by certain rules.
  2. Listen to all events that may have an impact on the interface, such as various types of mouse events, input events, event scrolling, zooming, etc. events, each event record parameters and target elements, target elements can be just record the id, so every time change events can be recorded as an incremental snapshots.
  3. Send a certain amount of snapshots to the backend.
  4. Play based on snapshots and operate the chain in the background.

Of course, this explanation is relatively simple, relatively simple mouse record, and we will not talk about, mainly to explain the realization of ideas DOM surveillance.

The first full-page volume snapshot

The first thing you might think, to realize the full amount of page snapshots can be used directlyouterHTML

const content = document.documentElement.outerHTML;

This simple records all the page's DOM, you only need to increase the mark to DOM id, then get outerHTML, then remove JS script.

However, there is a problem, use the outerHTMLrecord of the DOM will close two TextNode into one node, and subsequent monitoring will be used when we DOM change MutationObserver, then you need a lot of processing to merge this TextNode compatible, otherwise when you restore operation can not locate the target node operation.

So, we have a way to keep the original structure of the page DOM do?

The answer is yes, here we use to record the Virtual DOM DOM structure, the documentElement become Virtual DOM, recorded and restored later when the DOM can regenerate.

DOM into Virtual DOM

Here we are only concerned with two kinds Node types: Node.TEXT_NODEand Node.ELEMENT_NODE. Also, pay attention, SVG and SVG to create a sub-element requires the use of API: createElementNS, so, when we recorded Virtual DOM, you need to pay attention to namespace records on the code:

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 };
}

By the above code, we can documentElement into the entire Virtual DOM, wherein __flow used to record a number of parameters, including the tag ID, etc., Virtual Node recorded: type, attributes, children, namespace.

Virtual DOM DOM reduced to

The Virtual DOM DOM when reduced to relatively simple, only need to recursively create DOM, which nodeFilter script to filter elements, because we do not need to perform JS script.

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 structural change monitoring

Here, we use the API: MutationObserver, more good news is that this API is compatible with all browsers, so we can boldly use.

Use 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);

Is simple to use, you only need to specify a root node and a number of options need to be monitored, then when the DOM changes, the callback function will have a mutationList, this is a list of DOM changes, the structure of which the mutation is probably:

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

We use an array to store the mutation, specific callback is:

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;
}

There is only need to pay attention when you deal with the new DOM, you need an incremental snapshot, here still use the Virtual DOM to record, play back in time and still generate DOM, can be inserted into the parent element, so here need to refer to DOM, which is sibling.

Form elements surveillance

MutationObserver above does not monitor the input value changes and other elements, so we need to value form element for special treatment.

oninput event listeners

MDN Documentation: developer.mozilla.org/en-US/docs/...

Event object: 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, 
     });
   }
}

On the window using the capture to capture the event, is also behind this treatment, the reason for this is that we are often possible and prevent bubbling in the bubbling stage to implement some of the features, so use the capture event can reduce the loss of the other, like a scroll event is not bubbling, you must use the capture.

onchange event listeners

MDN Documentation: developer.mozilla.org/en-US/docs/...

input event type is unable to meet the checkbox and radio monitoring, so it needs to monitor the onchange event

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 event listeners

MDN Documentation: 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 event listeners

MDN Documentation: 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,
    });
  }
}

Media element change listener

This refers to audio and video, similar to the above form elements, you can listen onplay, onpause event, timeupdate, volumechange events and so on, and then stored records

Canvas Canvas change monitor

Changes in the content canvas without throwing events, so we can:

  1. Collection canvas element, timed to update content in real time
  2. Some hack painting API, to throw an event

canvas listen very in-depth study did not need further study

Broadcast

The idea is relatively simple, is to get some information from the back-end:

  • Full amount Snapshot Virtual DOM
  • Operation Chain records
  • Screen Resolution
  • doctype

Using this information, you can first generate the page DOM, including filtering script tag, and then create iframe, append to a container in which to store a 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)
  }
}

The above duration omitted from the above, the fluency you can do to optimize your play, to see more record as a still originally presented.

About Fundebug

Fundebug focus on JavaScript, applets micro-channel, micro-channel games, Alipay small program, React Native, Node.js and Java applications in real-time online monitoring BUG. Since 2016, two-eleven formally launched, Fundebug handled a total of 2 billion + error event, paying customers have Sunshine Insurance, walnut programming, lychee FM, head of the 1-to-1, micro pulse, the Youth League and many other community brands. Welcome to Free Trial !

Guess you like

Origin www.cnblogs.com/fundebug/p/how-to-implement-web-video-record.html