微信小程序组件化埋点实践

1,116 阅读5分钟

微信小程序组件化埋点实践

参考资料 网易云音乐大前端团队 -- 前端组件化埋点的实践

前言

在流量红利逐渐消失的现在,数据的采集、分析和精细化的运营显得更加重要,埋点能够帮助我们收集用户数据,辅助运营决策的同时提供算法以致力于建立用户图像,从而实现个性化推荐。

技术调研

  1. 组件埋点: 当前采用的埋点方案,利用组件实现埋点上传,业务代码和埋点逻辑基本抽离 优点:埋点逻辑与业务代码解耦,利于代码维护和复用。 缺点:小程序没有 DOM 元素,因此会增加 DOM 层级,组件内使用曝光埋点不便。

  2. 手动代码埋点:用户触发某个动作后手动上报数据 优点:准确,可以满足很多定制化的需求。 缺点:埋点逻辑与业务代码耦合到一起,不利于代码维护和复用。

  3. 无埋点:也叫“全埋点”,前端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据 优点:收集用户的所有端上行为,很全面。 缺点:无效的数据很多,上报数据量大,服务器压力很大。

具体技术实现

点击埋点

由于小程序无法像 REACT 利用 prop.children,只能使用 slot 绑定实现点击埋点。

<slot bindtap="trackerClick"></slot>
// components/common/trackerClick/index.js
import { appendQueue } from "../../../utils/report/reportQueue";
import { formatDate } from "../../../utils/formatDate";
Component({
  /**
   * 组件的属性列表
   */
  properties: {
    event: {
      type: String,
      value: "Click",
    },
    project: {
      type: String,
      value: "",
    },
    properties: {
      type: Object,
      value: null,
    },
  },

  /**
   * 组件的方法列表
   */
  methods: {
    trackerClick() {
      const { event, project, properties } = this.data;
      let time = formatDate(new Date(), "YYYY-MM-DD hh:mm:ss.S");
      // 后端字段要求时间格式化保留两位小数
      time =
        time.length >= 19
          ? time.slice(0, 19)
          : `${time.slice(0, 17)}0${time.slice(17, 18)}`;
      appendQueue("click", {
        time,
        event,
        project,
        properties,
      });
    },
  },
});

曝光埋点

曝光埋点是最难实现的埋点,需要满足以下三点:

  • 元素出现在视窗内一定的比例才算一次合法的曝光
  • 元素在视窗内停留的时长达到一定的标准才算曝光
  • 统计元素曝光时长

微信官方文档 -- IntersectionObservers

由于微信小程序已支持 IntersectionObservers API,因此我们采用这个 API 实现曝光埋点。

不过由于小程序不具有 DOM 结构,没法像 REACT 传递 DOM 元素实现曝光埋点,并且组件内部的使用有一些需要特别注意的点。

<slot></slot>
import IntersectionObserver from "../../../utils/report/intersection-observer";
import { appendQueue } from "../../../utils/report/reportQueue";
import { formatDate } from "../../../utils/formatDate";
import CONFIG from "../../../utils/report/config";
Component({
  /**
   * 组件的属性列表
   */
  properties: {
    /**
     * 上报的参数
     */
    event: {
      type: String,
      value: "View",
    },
    project: {
      type: String,
      value: "baoyanmp",
    },
    properties: {
      type: String,
    },
    /**
     * IntersectionObserver属性
     */
    // 监听的对象(组件内部需要使用组件的this属性)
    context: {
      type: Function,
      value: null,
    },
    // 选择器
    /**
    selector类似于 CSS 的选择器,但仅支持下列语法。
    ID选择器:#the-id
    class选择器(可以连续指定多个):.a-class.another-class
    子元素选择器:.the-parent > .the-child
    后代选择器:.the-ancestor .the-descendant
    跨自定义组件的后代选择器:.the-ancestor >>> .the-descendant
    多选择器的并集:#a-node, .some-other-nodes
    */
    selector: {
      type: String,
    },
    // 相交区域,默认为页面显示区域
    relativeTo: {
      type: String,
      value: null,
    },
    // 是否同时观测多个目标节点,动态列表不会自动增加
    observeAll: {
      type: Boolean,
      value: false,
    },
    // 是否只观测一次
    once: {
      type: Boolean,
      value: CONFIG.DEFAULT_EXPOSURE_ONCE,
    },
    // 曝光时长,满足此时长记为曝光一次,单位ms
    exposureTime: {
      type: Number,
      value: CONFIG.DEFAULT_EXPOSURETIME,
    },
    // 成功曝光后间隔时长,在此时长内不进行观测,单位ms
    interval: {
      type: Number,
      value: CONFIG.DEFAULT_EXPOSURE_INTERVAL,
    },
  },

  lifetimes: {
    ready: function () {
      if (!this.data.selector) return;
      this.ob = new IntersectionObserver({
        context: this.data.context ? this.data.context() : null,
        selector: this.data.selector,
        relativeTo: this.data.relativeTo,
        observeAll: this.data.observeAll,
        once: this.data.once,
        interval: this.data.interval,
        exposureTime: this.data.exposureTime,
        onFinal: (startTime, endTime) => {
          const { event, project, properties } = this.data;
          let time = formatDate(new Date(), "YYYY-MM-DD hh:mm:ss.S");
          time =
            time.length >= 19
              ? time.slice(0, 19)
              : `${time.slice(0, 17)}0${time.slice(17, 18)}`;
          appendQueue("exposure", {
            time,
            event,
            project,
            properties: {
              ...properties,
              startTime,
              endTime,
            },
          });
        },
      });
      this.ob.connect();
    },
    detached: function () {
      // 在组件实例被从页面节点树移除时执行
      this.ob.disconnect();
    },
  },

  pageLifetimes: {
    show: function () {
      // 所在页面被展示
      if (this.ob) this.ob.connect();
    },
    hide: function () {
      // 所在页面被隐藏
      if (this.ob) this.ob.disconnect();
    },
  },
});
import CONFIG from "./config";
export default class IntersectionObserver {
  constructor(options) {
    this.$options = {
      context: null,
      selector: null,
      relativeTo: null,
      observeAll: false,
      initialRatio: 0,
      // 露出比例
      threshold: CONFIG.DEFAULT_THRESHOLD,
      once: CONFIG.DEFAULT_EXPOSURE_ONCE,
      exposureTime: CONFIG.DEFAULT_EXPOSURETIME,
      interval: CONFIG.DEFAULT_EXPOSURE_INTERVAL,
      // 满足曝光后回调
      onFinal: () => null,
      ...options,
    };
    this.$observer = null;
    this.startTime = null;
    this.isIntervaling = false;
    this.stopObserving = false;
    this.neverObserving = false;
  }
  connect() {
    this.stopObserving = false;
    if (this.$observer || this.isIntervaling || this.neverObserving) return;
    this.$observer = this._createObserver();
  }
  reconnect() {
    this.disconnect();
    this.connect();
  }
  disconnect() {
    this.stopObserving = true;
    if (!this.$observer) return;
    this.$observer.disconnect();
    this.$observer = null;
    // 断开连接,即停止浏览,判断是否上报
    if (!this.startTime) return;
    this._judgeExposureTime();
  }
  _createObserver() {
    const opt = this.$options;
    const observerOptions = {
      thresholds: [opt.threshold],
      observeAll: opt.observeAll,
      initialRatio: opt.initialRatio,
    };
    // 创建监听器
    const ob = opt.context
      ? opt.context.createIntersectionObserver(observerOptions)
      : wx.createIntersectionObserver(null, observerOptions);
    // 相交区域设置
    if (opt.relativeTo) ob.relativeTo(opt.relativeTo);
    else ob.relativeToViewport();
    // 开始监听
    ob.observe(opt.selector, (res) => {
      const { intersectionRatio } = res;
      const visible = intersectionRatio >= opt.threshold;
      if (visible && !this.startTime) {
        this.startTime = new Date();
      }
      if (!visible && this.startTime) {
        this._judgeExposureTime();
      }
    });
    return ob;
  }
  _judgeExposureTime() {
    const endTime = new Date();
    const lastTime = endTime.getTime() - this.startTime.getTime();
    if (lastTime < this.$options.exposureTime) {
      this.startTime = null;
      console.log("曝光时间不足", lastTime / 1000);
      return;
    }
    console.log("曝光时间足够", lastTime / 1000);
    this.$options.onFinal(this.startTime, endTime);
    this.startTime = null;
    if (this.$options.once) {
      this.neverObserving = true;
      if (this.$observer) {
        this.$observer.disconnect();
        this.$observer = null;
      }
    }
    if (this.$options.interval) {
      if (this.$observer) {
        this.$observer.disconnect();
        this.$observer = null;
      }
      this.isIntervaling = true;
      setTimeout(() => {
        this.isIntervaling = false;
        if (!this.stopObserving) this.connect();
      }, this.$options.interval);
    }
  }
}

页面曝光埋点

由于小程序页面有很多生命周期,因此我们可以借助 onShow,onHide 来实现检测页面的显示和关闭。

appendQueue

一些场景下我们没法绑定事件到 dom 上,比如小程序的分享,针对这种场景我们提供了 appendQueue 方法,把埋点加入到缓冲队列中。

缓存方案

参考网易云方案,我们也采用定时任务上报,点击类上报频率 1000ms,曝光类 3000ms,不过相较于网易云,我们采用了两种方案减少埋点数据丢失的可能性。

  1. 进入队列的同时保存到 localStorage,上报后自动删除,避免在上报间隙用户关闭小程序导致埋点数据丢失。
  2. 小程序 APP 的 onHide 生命周期立即上报,减少由于用户清理小程序缓存导致埋点数据丢失。
import { multiReport } from "./report";
import CONFIG from "./config";

let exposureQueue = [];
let clickQueue = [];
let isCollectingExposure = false;
let isCollectingClick = false;

const appendQueue = (action, track) => {
  action === "exposure" ? exposureQueue.push(track) : clickQueue.push(track);
  report(action);
  wx.setStorage({
    key: `${action}Queue`,
    data: JSON.stringify(action === "exposure" ? exposureQueue : clickQueue),
  });
};

const clearQueue = (action) => {
  action === "exposure" ? (exposureQueue = []) : (clickQueue = []);
  wx.removeStorage({ key: `${action}Queue` });
};

const report = (action) => {
  const delay =
    action === "exposure" ? CONFIG.EXPOSURE_DELAY : CONFIG.CLICK_DELAY;
  if (action === "exposure" ? isCollectingExposure : isCollectingClick) return;
  action === "exposure"
    ? (isCollectingExposure = true)
    : (isCollectingClick = true);
  const queue = action === "exposure" ? exposureQueue : clickQueue;
  if (queue.length !== 0) {
    setTimeout(() => {
      multiReport(queue);
      action === "exposure"
        ? (isCollectingExposure = false)
        : (isCollectingClick = false);
      clearQueue(action);
    }, delay);
  }
};

const reportImediately = () => {
  multiReport(exposureQueue).then(() => {
    isCollectingExposure = false;
    clearQueue("exposure");
  });
  multiReport(clickQueue).then(() => {
    isCollectingClick = false;
    clearQueue("click");
  });
};

export { appendQueue, reportImediately };

具体使用说明

点击埋点

注意事项: 包裹的元素最外层需要有个 container

<tracker-click
  event="ClickIcon"
  project="test"
  properties='{"iconName":"{{item.text}}"}'
></tracker-click>

曝光埋点

注意事项

  1. 包裹的元素最外层需要有个 container
  2. 如果是动态加载列表 selector 需要绑定每一个元素 id,如果不是动态加载列表,可以绑定 class 结合 observeAll 使用。
  3. 组件内部使用曝光埋点,需要传递父元素 this 给曝光埋点组件
<tracker-exposure
  selector="#menuIcon{{index}}"
  event="ViewIcon"
  project="test"
  properties='{"iconName":"{{item.text}}"}'
>
</tracker-exposure>

<!-- 组件内部使用 -->
<tracker-exposure
  selector="#banner{{index}}"
  event="ViewBanner"
  project="test"
  properties='{"bannerId":"{{item.id}}"}'
  context="{{context}}"
></tracker-exposure>
// 组件内部使用,父组件js文件
Component({
  data: {
    indicatorDots: true,
    autoplay: true,
    interval: 4000,
    context: null,
  },
  lifetimes: {
    created() {
      this.setData({ context: () => this });
    },
  },
});

appendQueue 使用

appendQueue("click", {
  event: "ClickShare",
  project: "test",
  properties: { page: "首页" },
});

页面曝光

onShow: function () {
	this.startTime = new Date()
},
onHide: function () {
	const endTime = new Date()
	appendQueue('exposure', {
      event: 'ExposurePage',
      project: 'test',
      properties: { page: '首页', startTime: this.startTime, endTime },
    });
},

优化方向

  1. 使用 slot 还是增加了 DOM 结构
  2. 组件内部使用曝光组件,如何拿到父组件 this