如何从0-1构建数据平台(2)- 前端埋点

930 阅读3分钟

规律是创造与发现的,记忆是规律的推导与提取

埋点系统

首先我们考虑到业务需求的H5数据一般为PVUV和页面的点击事件,那么我们的埋点系统就需要对页面信息进行收集,同时对所有的点击事件进行收集。我们简单设计一下数据结构:基础信息+事件+属性。 基础信息主要是页面信息我们可以通过ua和location获取:

export const GetPageInfo = () => {
  const url = window.location.href;
  const hash = window.location.hash;
  const referrer = document.referrer;
  const title = document.title;
  const query = window.location.search;
  const host = window.location.host;
  const pathname = window.location.pathname;
  const protocol = window.location.protocol.split(":")[0];
  const port = window.location.port;
  return {
    url,
    host,
    pathname,
    protocol,
    port,
    hash,
    referrer,
    title,
    query: query.substring(1),
  };
};

  data: Record<string, any> = {
    version: "1.0.0",
    distinct_id: UUID(),
    event: "",
    env: navigator.userAgent,
    time: new Date().getTime(),
    ...GetPageInfo()
  };

上报前需要转化一下数据格式:

const { version, distinct_id, event } = this.data;
const data = {
      version,
      distinct_id,
      event,
      properties: {
        ...Object.keys(this.data).reduce((pre, cur) => {
          if (["version", "distinct_id", "event"].indexOf(cur) === -1) {
            pre[cur] = this.data[cur];
          }
          return pre;
        }, {} as Record<string, any>),
      },
    };

数据结构设计好了之后,需要考虑作为开发人员我们需要在什么时候收集数据呢,对于PVUV肯定是页面第一次加载和路由切换变化的时候进行上报,我们需要对页面加载和路由切换事件进行监听:

setPageLoadListener() {
    const trackPage = this.trackPage.bind(this);
    // 监听页面加载
    document.addEventListener("DOMContentLoaded", function () {
      trackPage("pageload");
    });
    // 监听页面跳转
    window.addEventListener("hashchange", function () {
      trackPage("hashchange");
    });
    // 监听页面前进后退
    window.addEventListener("popstate", function () {
      trackPage("pageview");
    });
    let historyPush = window.history.pushState;
    window.history.pushState = function () {
      historyPush.apply(window.history, arguments as any);
      trackPage("pageview");
    };
  }

对于点击事件,我们可以约定自动上报只针对a标签和button标签的点击事件进行监听,而手动上报可以针对任意html元素。

点击事件业务那边需要的信息一般为元素标识即事件属性,我们可以约定以className为默认属性,当然对于开发人员定位问题我们可以获取该元素选择器路径(主要是为可视化埋点预留扩展接口)。

这里我们主要定义自动上报和手动上报的方法。

自动上报我们通过监听document上的click方法,通过对事件冒泡的拦截来获取选择器路径:

checkValidTagName(tagName: string) {
    const validTagNames = ["a", "button"];
    return validTagNames.indexOf(tagName.toLocaleLowerCase()) > -1;
}

// 以body或者其下第一个容器元素为开始节点,生成selector path
getElementPathName(el: HTMLElement) {
    if (el.id && el.id !== "") {
      return `#${el.id}`;
    } else {
      const parentElement = el.parentElement as HTMLElement;
      const childIndex = Array.prototype.indexOf.call(
        parentElement.children,
        el
      );
      if (
        el.parentNode &&
        parentElement.tagName.toLocaleLowerCase() === "body"
      ) {
        return `body > ${el.tagName.toLocaleLowerCase()}:nth-child(${
          childIndex + 1
        })`;
      } else {
        return ` > ${el.tagName.toLocaleLowerCase()}:nth-child(${
          childIndex + 1
        })`;
      }
    }
  }

// 判断是否是有效自动上报节点 同时生成上报数据
getClickElementInfo(ev: MouseEvent) {
    // 重置点击数据
    let validClick = false;
    this.clickData = {};
    const pathEls = ev.composedPath() as HTMLElement[];
    let elementInPath = [] as HTMLElement[];
    let hasValidElement = false;
    let targetElement;
    // 从下往上查找有效的点击元素,a标签或者button内可能包含其他元素
    while (pathEls.length > 0) {
      const el = pathEls.shift() as HTMLElement;
      if (el.tagName && this.checkValidTagName(el.tagName)) {
        hasValidElement = true;
        targetElement = el;
        break;
      }
    }
    elementInPath = pathEls.reverse().slice(4);
    elementInPath.push(targetElement as HTMLElement);
    if (elementInPath.length > 0 && hasValidElement) {
      const target = elementInPath[elementInPath.length - 1];
      const tagName = target.tagName.toLowerCase();
      const className = target.className;
      const content = target.innerText;
      // selector拼接 保证querySelector能正确定位元素
      const selector = elementInPath
        .map((item) => this.getElementPathName(item))
        .join("");
      this.clickData = {
        event: "click",
        action: "click",
        selector,
        content,
        className,
        tagName,
      };
      if (tagName === "a") {
        const href = target.getAttribute("href");
        this.clickData.targetUrl = href;
      }
      validClick = true;
    }
    return validClick;
  }

手动上报比较简单,我们根据当前传入元素的父元素进行遍历就行:

  getPathElemsByEl(el: HTMLElement) {
    let elementInPath = [] as HTMLElement[];
    while (el.parentNode && el.tagName.toLocaleLowerCase() !== "body") {
      elementInPath.push(el);
      el = el.parentNode as HTMLElement;
    }
    return elementInPath.reverse();
  }

根据这两个方法我们再封装监听方法和手动上报方法:

// 监听方法 全局自动上报
  setElementListener() {
    document.addEventListener("click", (e) => {
      this.getClickElementInfo(e) && this.trackClick();
    });
  }

// 手动上报方法
  trackWebclick(el: HTMLElement, props: Record<string, any>) {
    const target = el;
    const elementInPath = this.getPathElemsByEl(target);
    const tagName = target.tagName.toLowerCase();
    const className = target.className;
    const content = target.innerText;
    const selector = elementInPath
      .map((item) => this.getElementPathName(item))
      .join("");
    this.clickData = {
      event: "click",
      action: "click",
      selector,
      content,
      className,
      tagName,
      ...props,
    };
    if (tagName === "a") {
      const href = target.getAttribute("href");
      this.clickData.targetUrl = href;
    }
    this.trackClick();
  }
  
  
  trackClick() {
    this.data = { ...this.data, ...this.clickData };
    this.sendData();
  }

那么我们实例化了监听器对象之后,即可进行数据监听了:

  constructor(props: Record<string, any>) {
    this.data = { ...this.data, ...props };
    this.initData = { ...this.data, ...props };
    this.setPageLoadListener();
    this.setElementListener();
    this.setPerformanceListener();
    window.VITE_MONITOR = this;
  }
  
  // 手动点击
  
function TestMonitor(e: MouseEvent) {
  window.VITE_MONITOR.trackWebclick(e.target, {
    test: 'hello'
  })
}

效果

pageview:

pv.png autoclick:

autoclick.png click:

click.png

以上简单的埋点数据获取完成