• 纯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

  • 相关阅读:
    设计模式——设计原则与思想总结
    SQL——性能优化篇(下)
    计算机组成原理——入门篇
    SQL——性能优化篇(中)
    SQL——性能优化篇(上)
    设计模式——规范与重构(下)
    设计模式——规范与重构(上)
    编译原理——实现一门脚本语言 应用篇
    编译原理——实现一门脚本语言 原理篇(下)
    设计模式——设计原则实战
  • 原文地址:https://www.cnblogs.com/strick/p/12206766.html
Copyright © 2020-2023  润新知