zoukankan      html  css  js  c++  java
  • 纯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

    参考资料:

    rrweb:打开Web页面录制与回放的黑盒子

    MutationObserver

    MutationRecord

    reworkcss/css

    基于rrweb录屏与重放页面

    rrweb 底层设计简要总结

    rrweb源码解析1

    了解HTML5中的MutationObserver

  • 相关阅读:
    HDU 3401 Trade
    POJ 1151 Atlantis
    HDU 3415 Max Sum of MaxKsubsequence
    HDU 4234 Moving Points
    HDU 4258 Covered Walkway
    HDU 4391 Paint The Wall
    HDU 1199 Color the Ball
    HDU 4374 One hundred layer
    HDU 3507 Print Article
    GCC特性之__init修饰解析 kasalyn的专栏 博客频道 CSDN.NET
  • 原文地址:https://www.cnblogs.com/strick/p/12206766.html
Copyright © 2011-2022 走看看