侧边栏壁纸
  • 累计撰写 47 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

工程化:前端埋点监控系统的实现

埋点是在应用中特定的流程收集一些信息,用来跟踪应用使用的状况,以便后续进一步优化产品或提供运营的数据支撑。如访问数、访客数、停留时长、页面浏览数等等。

埋点方式
  1. 手动埋点

    在需要上报的地方主动编写相应的上报代码。如注册完成时需要记录上报,则在注册完成逻辑中编写相关的上报代码。此种方式需要在每个上报的地方进行埋点(编写代码),所以较为繁琐,以及不便于维护。但由于是手动进行埋点,所以可以精准控制埋点的位置,上报精准的数据(对服务端的数据分析较友好),以及可以灵活的自定义事件和属性。

  2. 可视化埋点

    可视化埋点可以看作是手动埋点的可视化版,通过可视化操作进行对页面的埋点。由于可视化操作,所以可视化埋点只能对可见元素进行埋点,一些复杂页面,动态页面则力不逮了,但也大大降低了埋点的门槛,使非开发人员也可以进行埋点操作,数据也较为准确。此种方式一般使用第三方平台,毕竟需要开发 SDK 以及页面的可视化操作,有一定的难度,以及需要耗费不少的时间。

  3. 全埋点

    全埋点,对应用中的行为进行无差别的记录上报。
    此种方式由于是无差别的进行记录上报,所以对于服务端的数据分析非常不友好,需要先进行数据筛选,再进行数据分析,数据量也非常大。

数据收集

埋点主要有两步:
收集数据
上报数据

  1. 事件数据收集

    分析用户使用的某个功能的频次,就可以在用户操作时收集并上报数据。

const eventList = ["click", "dblclick", "mouseout", "mouseover"];

eventList.forEach((event) => {
  window.addEventListener(event, (e) => {
    const target = e.target;
    const reportKey = target.getAttribute("report-key");
    if (reportKey) {
      // 上报代码
      console.log("上报", event);
    }
  });
});
  1. 页面访问量收集

    页面的访问量统计,根据不同的产品,记录的时机也不同,如有些只需要页面加载完毕,有些需要浏览页面才算等。

    但如果页面是单页应用,那么监听 load 就不准确了。由于单页应用的路由是在不刷新页面的情况下进行渲染页面的,所以可以监听 url 的变化进行记录。

window.addEventListener("load", (event) => {
  console.log("上报", "page load");
});

如果单页应用的路由模式为 hash,则可以通过监听 hashchange

// 单页面应用hash
window.addEventListener("hashchange", () => {
  console.log("上报", "hashchange");
});

如果单页应用的路由模式为 history,则可监听 pushstate 和 replacestate,但由于 History API 在调用 history.pushState 和 history.replaceState 时,并不会触发 pushstate 和 replacestate 事件,所以需要重新创建一个 pushState 和 replaceState 事件

function wrap(event) {
  const fun = history[event];
  return function () {
    const res = fun.apply(this, arguments);
    const e = new Event(event);
    window.dispatchEvent(e);
    return res;
  };
}

经过上面的包装,再进行监听 pushState 和 replaceState,在调用 history.pushState 和 history.replaceState(单页应用跳转),就可正常监听并记录了

window.addEventListener("pushState", (e) => {
  console.log("上报", "pushState");
});

window.addEventListener("replaceState", (e) => {
  console.log("上报", "replaceState");
});

如果是需要浏览页面时上报数据,方案就比较多样,如可以以某个元素出现在可视区域时进行记录,或监听滚动条滚动等等

  1. 页面停留时间收集

    页面停留时间,就是记录从访问页面到离开/关闭页面的时间。不管是 SPA 应用还是非 SPA 应用,都是记录从页面加载到页面关闭或跳转的时间

const dulation = {
  startTime: 0,
  value: 0,
};

// 首次进入页面
window.addEventListener("load", () => {
  // 记录时间
  const time = new Date().getTime();
  dulation.startTime = time;
});

// 单页应用页面跳转(触发 replaceState)
window.addEventListener("replaceState", () => {
  const time = new Date().getTime();
  dulation.value = time - dulation.startTime;

  // 上报
  // .....

  // 上报后重新初始化 dulation, 以便后续跳转计算
  dulation.startTime = time;
  dulation.value = 0;
});

// 单页应用页面跳转(触发 pushState)
window.addEventListener("pushState", () => {
  const time = new Date().getTime();
  dulation.value = time - dulation.startTime;

  // 上报
  // .....

  // 上报后重新初始化 dulation, 以便后续跳转计算
  dulation.startTime = time;
  dulation.value = 0;
});

// 非单页应用跳转触发 popstate
window.addEventListener("popstate", () => {
  const time = new Date().getTime();
  dulation.value = time - dulation.startTime;

  // 上报
  // .....

  // 上报后重新初始化 dulation, 以便后续跳转计算
  dulation.startTime = time;
  dulation.value = 0;
});

// 页面没有任何跳转, 直接关闭页面的情况
window.addEventListener("beforeunload", () => {
  const time = new Date().getTime();
  dulation.value = time - dulation.startTime;

  // 上报
  // .....

  // 上报后重新初始化 dulation, 以便后续跳转计算
  dulation.startTime = time;
  dulation.value = 0;
});

上面代码上报了从页面加载到页面跳转或关闭的时间差,这个时间差就是页面的停留时间。页面加载只有一个入口 (load),而跳转分为单页应用和非单页应用;单页应用又有两种路由模式(模式不同,跳转触发的事件不同);又有无跳转直接关闭页面的情况,而上面代码都一一考虑到了。

  1. 异常数据收集

    收集异常数据,不仅可以监控产品的 bug 以便可以快速的修复,还能根据异常数据进行错误率分析。根据产生异常的方式不同,监听获取异常数据的方式也不同,如一般的语法错误或者运行错误,则可使用 error 事件进行监听,而 Promise 抛出的异常,则需要监听 unhandledrejection 事件

window.addEventListener("error", (e) => {
  console.log("上报", "error");
});

window.addEventListener("unhandledrejection", (e) => {
  console.log("上报", "unhandledrejection");
});

如果页面使用了框架进行开发, 如 Vue、React 等,一般这种框架有自带的异常捕获机制,那么就需要使用其相关的异常监听了,如 Vue 的 app.config.errorHandler

数据上报

上报的数据会影响数据分析的准确性。数据上报时,除了上报业务需要的信息(用户 ID,点击的元素名称等),还需要客户端的设备信息(UA,当前访问的 URL 等)以及使用的 SDK 信息(版本)。

数据格式

上报的数据需要一个较为稳定的格式,以便后端方便分析

{
  uid; // 用户id
  title; // 页面标题
  url; // 页面的url
  time; // 触发埋点的时间
  ua; // userAgent
  screen; // 屏幕信息, 如 1920x1080
  type // 数据类型,根据触发的不同埋点有不同的类型
  data; // 根据不同的type,此处的数据也不同,如 事件数据有元素名称,事件名称、页面停留时间有停留时长....
  sdk // sdk 相关信息
}
上报方式

数据的上报就是将数据发送给服务端,发送数据在一般情况下是使用 XMLHttpReuqest(axios)或者 Fetch,但用其有两个缺陷,一是有跨域问题,二是在页面关闭时会中断正在发送的数据

考虑到以上几点,一般使用图片来发送数据或者使用 navigator.sendBeacon,也可以两者结合(如果浏览器不支持 sendBeancon,就使用图片的方式)。

function send(data) {
  const url = `xxxx`
  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, JSON.stringify(data));
  } else {
    const imgReq = new Image();
    imgReq.src = `${url}?params=${JSON.stringify(
      data
    )}&t=${new Date().getTime()}`;
  }
}
SDK 实现

数据收集以及数据上报的方式都有了,那么就可以开始实现 SDK 了,这里先以单文件(非工程化)进行实现

// JS 完整代码部分
(function (e) {
  function wrap(event) {
    const fun = history[event];
    return function () {
      const res = fun.apply(this, arguments);

      const e = new Event(event);

      window.dispatchEvent(e);

      return res;
    };
  }

  class TrackingDemo {
    constructor(options = {}) {
      // 重写 pushState、replaceState
      window.history.pushState = wrap("pushState");
      window.history.replaceState = wrap("replaceState");

      // 上报地址
      this.reportUrl = options.reportUrl || "";
      this.sdkVersion = "1.0.0";

      this._eventList = ["click", "dblclick", "mouseout", "mouseover"];

      this._dulation = {
        startTime: 0,
        value: 0,
      };
      this._initJSError();

      // 初始化事件数据收集
      this._initEventHandler();

      // 初始化PV统计
      this._initPV();

      this._initPageDulation();
    }

    setUserId(uid) {
      this.uid = uid;
    }

    _initEventHandler() {
      this._eventList.forEach((event) => {
        window.addEventListener(event, (e) => {
          const target = e.target;
          const reportKey = target.getAttribute("report-key");
          if (reportKey) {
            this._report("event", {
              tagName: e.target.nodeName,
              tagText: e.target.innerText,
              event,
            });
          }
        });
      });
    }

    _initPV() {
      window.addEventListener("pushState", (e) => {
        this._report("pv", {
          type: "pushState",
          referrer: document.referrer,
        });
      });

      window.addEventListener("replaceState", (e) => {
        this._report("pv", {
          type: "replaceState",
          referrer: document.referrer,
        });
      });

      window.addEventListener("hashchange", () => {
        this._report("pv", {
          type: "hashchange",
          referrer: document.referrer,
        });
      });
    }

    _initPageDulation() {
      let self = this;

      function initDulation() {
        const time = new Date().getTime();
        self._dulation.value = time - self._dulation.startTime;

        self._report("dulation", {
          ...self._dulation,
        });

        self._dulation.startTime = time;
        self._dulation.value = 0;
      }

      // 首次进入页面
      window.addEventListener("load", () => {
        // 记录时间
        const time = new Date().getTime();
        this._dulation.startTime = time;
      });

      // 单页应用页面跳转(触发 replaceState)
      window.addEventListener("replaceState", () => {
        initDulation();
      });

      // 单页应用页面跳转(触发 pushState)
      window.addEventListener("pushState", () => {
        initDulation();
      });

      // 非单页应用跳转触发 popstate
      window.addEventListener("popstate", () => {
        initDulation();
      });

      // 页面没有任何跳转, 直接关闭页面的情况
      window.addEventListener("beforeunload", () => {
        initDulation();
      });
    }

    _initJSError() {
      window.addEventListener("error", (e) => {
        this._report("error", {
          message: e.message,
        });
      });

      window.addEventListener("unhandledrejection", (e) => {
        this._report("error", {
          message: e.reason,
        });
      });
    }

    // 用户可主动上报
    reportTracker(data) {
      this._report("custom", data);
    }

    _getPageInfo() {
      const { width, height } = window.screen;
      const { userAgent } = navigator;
      return {
        uid: this.uid,
        title: document.title,
        url: window.location.href,
        time: new Date().getTime(),
        ua: userAgent,
        screen: `${width}x${height}`,
      };
    }

    _report(type, data) {
      const reportData = {
        ...this._getPageInfo(),
        type,
        data,
        sdk: this.sdkVersion,
      };

      if (navigator.sendBeacon) {
        navigator.sendBeacon(this.reportUrl, JSON.stringify(reportData));
      } else {
        const imgReq = new Image();
        imgReq.src = `${this.reportUrl}?params=${JSON.stringify(
          reportData
        )}&t=${new Date().getTime()}`;
      }
    }
  }
  e.TrackingDemo = TrackingDemo;
})(window);
// HTML 代码部分
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button report-key="button">按钮</button>
</body>
<script src="./tackerDemo.js"></script>
<script>
    const trackingDemo = new TrackingDemo()
</script>
</html>
工程化

上面的非工程化实现只是一个单文件形式(iife),如果项目是模块化开发的话,上面形式就不支持 import 和 require 了,所以就需要打包工具进行打包,以便支持 esm、cjs 和 umd 三种方式,同时工程化还便于维护。这里使用 rollup 进行打包,以及使用 typescript 进行类型约束。

工程化:如何发布 npm 包

MonitorDemo.zip

安装

npm i @guodz/monitordemo

使用方法如下:

import Monitor from "@guodz/monitordemo"
const monitor = new Monitor({
    reportUrl: 'xxx'
})
0

评论区