# 前端监控 SDK 开发

# 构建方案

构建方案 优点 缺点 是否采用
webpack 通用强、插件丰富、兼容性强 不适合做 lib 包开发
rollup + ts 打包 tree shaking 好、通常开发 lib 选择方案 开发环境不友好、配置繁多
esbuild 开发体验好、加载快 只支持 esmodule
microbundle + parcel 开箱即用、零配置、底层 rollup、适合开发 SD 看

# 浏览器的 5 种 Observer

  • MutationObserver 监听 DOM 树的变化(属性、子节点的增删改)
  • IntersectionObserver: 监听一个元素和可视区域相交部分的比例,然后在可视比例达到某个阈值的时候触发回调
  • PerformanceObserver:用于监测性能度量事件,在浏览器的性能时间轴记录下一个新的 performance entries 的时候将会被通知。
  • ResizeObserver:接口可以监听到 DOM 的变化(节点的出现和隐藏,节点大小的变化)
  • ReportingObserver:监听过时的 api、浏览器的一些干预行为的报告

根据前面的架构设计、我们将使用 IntersectionObserver 进行埋点监控的 SDK 开发、用于开发 PV、Click、EXP(曝光)、自定义等功能、下来介绍下 IntersectionObserver 的基本用法

# 基本用法

  • 监听一个目标元素
const intersectionObserver = new IntersectionObserver(callback, options);
intersectionObserver.observer("dom1");
intersectionObserver.observer("dom2");
  • 停止监听
intersectionObserver.disconnect();
  • 停止对特定目标元素监听
intersectionObserver.unobserve(targetElement);
  • 返回所有观察目标的 IntersectionObserverEntry 对象数组。
intersectionObserverEntries = intersectionObserver.takeRecords();

# 配置项

  • targetElement: 目标 DOM
  • root: 指定根目录,也就是当目标元素显示在这个元素中时会触发监控回调
  • rootMargin: 类似于 css 的 margin,设定 root 元素的边框区域。
  • threshold: 阙值.决定了什么时候触发回调函数(比如看到元素的多大就触发、如 [0.5] 看到一半)

# 返回参数

  • time: 可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
  • rootBounds: 是在根元素矩形区域的信息
  • intersectionRatio: 目标元素的可见比例
  • intersectionRect: 目标元素与根元素交叉区域的信息
  • isIntersecting: 判断元素是否符合 options 中的可见条件
  • boundingClientRect: 目标元素的矩形区域的信息
  • target: 被观察的目标元素

具体可以参考阮一峰老师的教程:https://www.ruanyifeng.com/blog/2016/11/intersectionobserver_api.html

# SDK 设计

在开发 SDK 之前我们要思考

  • sdk 使用如何简单、比如接入方式、只需要简单 new 一下即可完成、传入自己的 appId 应用标识
  • sdk 足够灵活、能提供各种 hooks 方法、比如上报前、后的生命钩子、以及能支持对上报参数的修改、合并等等
  • sdk 上报方法简单、无论是 pv、click、exp 等都支持手动和自动等方式切方法统一
  • sdk 支持自定义、对特定 dom 上报方式运行可自定义进行操作

因为一个基础的 SDK 模型就出来了

# Monitor

import qs from "qs";
import MonitorObserver from "./observe";
import {
  MonitorOption,
  AskPriority,
  AnalyticsData,
  Fun,
  Fn,
} from "./typings/index";

const uploadUrl = "http://localhost.8080/api/";

class Monitor {
  options: MonitorOption;
  // 参数创建前
  beforeCreateParams: Fun;
  // 上报日志前
  beforeUpload: Fun;
  // 上报日志后
  afterUpload: Fun;
  onError: Fun;
  private observer: MonitorObserver;
  constructor(options: MonitorOption) {
    this.options = options;
    this.collect();
    this.beforeCreateParams = null;
    this.beforeUpload = null;
    this.afterUpload = null;
    this.onError = (err: Error) => console.error(err);
    this.observer = new MonitorObserver(this);
  }
  /**
   * 收集浏览器基本信息
   * @returns
   */
  private collect() {}

  /**
   * 上报数据
   * @param data
   */
  private upload(data: string) {}

  /**
   * 上报执行前钩子函数
   * @param fn
   */
  registerBeforeCreateParams(fn: Fn) {
    this.beforeCreateParams = fn;
  }

  /**
   * 上报执行前参数 transforme 钩子函数
   * @param fn
   */
  registerBeforeUpload(fn: Fn) {
    this.beforeUpload = fn;
  }

  /**
   * 上报执行完钩子函数
   * @param fn
   */
  registerAfterUpload(fn: Fn) {
    this.afterUpload = fn;
  }

  /**
   * 上报错误钩子函数
   * @param fn
   */
  registerOnError(fn: Fn) {
    this.onError = fn;
  }

  /**
   * 发送PV、曝光、点击 日志
   * @param data
   */
  sendToAnalytics(data: AnalyticsData) {
    this.beforeCreateParams && this.beforeCreateParams();
    if (!data.pageId) {
      throw new Error("请传入 pageId、⚠️");
    }
    // 采集页面的基本信息
    // 1. 应用
    //    a. 应用id (SDK 初始化)
    //    b. 页面id (sendPv 自己带来)
    let body = {
      ...data,
      appId: this.options.appId,
      pageId: data.pageId,
      eventType: data.eventType || "pv",
    };
    // 2. 页面上报信息收集
    //    a. 应用id和页面id
    //    b. 访问时间
    //    c. ua
    const navigatorInfo = this.collect();
    Object.assign(body, navigatorInfo);

    // 3. 调用日志上报API
    if (this.beforeUpload) {
      body = this.beforeUpload(body);
    }
    const qsdata = qs.stringify(body);
    try {
      this.upload(qsdata);
      throw new Error("错误");
    } catch (error) {
      // console.error(error);
      this.onError && this.onError(error);
    } finally {
      this.afterUpload && this.afterUpload(body);
    }
  }
}

export default Monitor;

在接入过程中只需要简单 new 一下传入规定的字段即可、也可以返回一个实例化后的 Monitor、由我们调用 init 初入参数即可

const monitor = new CodeRobotMonitor({
  appId: "123",
});

monitor.registerBeforeCreateParams(() => {
  console.log("注册自定义的请求前函数");
});

monitor.registerBeforeUpload((body) => {
  body.name = "xikun";
  return body;
});

monitor.registerOnError((err: any) => {
  console.error(err.message);
});

// 发送 pv 上报
monitor.sendToAnalytics({
  pageId: "home",
});

# 上报方式的降级处理

通常上报数据最经典的莫过于使用 1kb 的 gif 进行上报数据、但是根据需要可以进行一系列的配置,分别优先级为:

  • 原生 fetch
  • 原生 XMLHttpRequest
  • navigator.sendBeacon
  • Image

如下:

const level = this.options.level || 3;
if (level === AskPriority.URGENT) {
  if (!!window.fetch) {
    window.fetch(`${uploadUrl}?${data}`);
  } else {
    let xhr: XMLHttpRequest | null = new XMLHttpRequest();
    xhr.open("post", uploadUrl, true);
    // 设置请求头
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.send(data); // 发送参数
    xhr.onload = function(e) {
      //及时清理以防多次创建
      xhr = null;
    };
  }
} else if (level === AskPriority.IDLE) {
  if (!!navigator.sendBeacon) {
    navigator.sendBeacon(uploadUrl, data);
  }
} else if (level === AskPriority.IMG) {
  let img: HTMLImageElement | null = new Image();
  img.src = `${uploadUrl}?${data}`;
  img.onload = function() {
    //统计完成收回创建的元素防止内存泄露
    img = null;
  };
}

# 曝光埋点的处理

针对曝光埋点、都是由不可见变为可见、有以下两种情况

  • 渲染未显示、比如商品瀑布流
  • 最初未渲染、比如动态创建了 DOM

针对第一种情况、我们直接使用 IntersectionObserver 对我们要曝光的 dom 进行监听即可、给需要曝光的 dom 标记上 appear 属性即可、如下:

this.ob = new IntersectionObserver(
  (e: IntersectionObserverEntry[]) => {
    e.forEach((inter) => {
      if (inter.intersectionRatio > 0) {
        console.log(inter.target.className + "appear");
        inter.target.dispatchEvent(appearEvent);
        const target = inter.target as HTMLElement;
        const data = target.dataset;
        // 自动上报数据
        this.monitor.sendToAnalytics({
          ...data,
          pageId: data.pageId!,
          eventType: EventType.EXP,
        });
      } else {
        console.log(inter.target.className + "disappear");
        inter.target.dispatchEvent(disappearEvent);
        // 防止重复曝光
        this.ob.unobserve(inter.target);
      }
    });
  },
  {
    threshold: [0.2],
  }
);
const appear = document.querySelectorAll("[appear]");
for (let i = 0; i < appear.length; i++) {
  this.ob.observe(appear[i]);
}

前面说到 SDK 需要支持对 dom 进行自定义、比如我们曝光中特定 dom 需要进行操作、那么应该怎么操作呢?我们可以给它添加自定义监听事情、由他自己触发:如下:

const appearEvent = new CustomEvent("onAppear");
const disappearEvent = new CustomEvent("onDisappear");
// 接上面
inter.target.dispatchEvent(appearEvent);
inter.target.dispatchEvent(disappearEvent);

// 曝光触发自定义事件
document
  .getElementsByClassName("demo2")[0]
  .addEventListener("onAppear", function(e) {
    monitor.sendToAnalytics({
      pageId: "my",
      eventType: EventType.EXP,
      productId: 22,
      index: 10,
    });
  });

# 动态创建 DOM 的曝光

上面处理了常规的曝光、针对动态创建的 dom 我们的监听不可再次触发、有什么办法让再次触发呢?其实很简单、只需要让曝光的整个逻辑再运行一次便可以达到我们的需求。需要注意的是、我们要做一个防止重复监听的标记即可,如下:

 init() {
    const appear = document.querySelectorAll("[appear]");
    for (let i = 0; i < appear.length; i++) {
      if (!this.observerList.includes(appear[i])) {
        this.ob.observe(appear[i]);
        this.observerList.push(appear[i]);
      }
    }
  }

# More

后面还有针对请求并发、缓存、以及大数据数仓开发、后续。。。

更新时间: 5/16/2022, 10:52:52 AM