friends-1149841_1920.jpg

概念相关

本篇文章重点在怎么实现一个简单的微信小程序无痕埋点方案,所以对埋点的概念性知识不做过多解读,大家可以自行Google
我们在这里只需要来了解两个关键性的问题即可。

什么是埋点

埋点是数据采集领域的一个术语,简单点讲就是用来追踪用户的一些特定行为和事件,进行记录上报
而常见的埋点主要分为三类:

  • 手动埋点(代码埋点)
  • 自动埋点(无痕埋点)
  • 可视化埋点

埋点的作用

我感觉从用户和自身产品两点来说会清晰一些:

  • 针对用户:对采集的数据可以进行分析,以便提供给用户精准的信息推送、个性化推荐等,当然这些用户的行为信息也可以提高产品的运营体验。
  • 针对产品:通过分析用户在每个页面的停留时间,以及交互的节点等诸多信息,来分析产品的现存问题,提供后续的优化思路。

    参考链接:https://xw.qq.com/partner/vivoscreen/20210520A08ATP00

其实从概念中也可以知道,埋点时信息的追踪,和信息的上报其实是完全独立的。所以我们在设计时也可以将这两部分独立设计。

ok,接下来就是手摸手 time!

实现过程

总体思路

首先我们需要知道其实一个微信小程序就是一个App,然后这个App里面由多个Page(页面)和Component(自定义组件)组合而成。
image.png
有趣的是,无论是App的注册,还是PageComponent的注册都是由各自对应的方法传入对应的参数(options)来完成的(从上图中也可以看出这种形式)

我们拿Page的注册来举个例子,将图中的注册方法换个方式写,来贴切一下我们的描述。

const options = {
  data: {
    msg: 'Hello World',
  },
  // 用户自定义事件
  bindViewTap() {
    wx.navigateTo({
      url: '../logs/logs',
    });
  },
  // Page自带的生命周期
  onLoad() {
    if (wx.getUserProfile) {
      this.setData({
        canIUseGetUserProfile: true,
      });
    }
  },
};
// 注册当前页面,由微信提供的Page方法传入对应的参数来完成
Page(options);
AppComponent 的注册与此类似,有兴趣可以看一下。

所以所以所以!我们可以通过重写AppPageComponent这三个方法,来实现对其options身上一些方法和生命周期的监听
image.png
(恩,大概就是这么个意思,我踩你们都看懂了)

ok,了解了大致思想,我们先来总结一下,然后开始动手做:

  1. 设计一个tracker来对AppPageComponent方法进行重写
  2. 设计一个reporter将监听的的事件数据进行记录,并发送给服务端

Tracker

在开始敲代码之前我们先来确认一下我们实现的这个埋点要怎么使用,只有确定了一个正确的使用方式,后面才可以根据这个口子来补充我们对应的功能。

通常情况下,我们如果要修改微信小程序的原生方法的话,那么就要在其入口文件app.js处来导入我们的重写方法,来达到该目的

// app.js
import init from './track/index';

init({
  ak: 'minapp-001',
  url: 'http://baidu.com',
  autoTrack: {
    appLaunch: false,
    appHide: false,
    appShow: false,
    pageShow: false,
    pageHide: false,
    pageUnload: false,
    onShare: false,
  },
  // other
});

其中的config是暂定的,后续可以根据具体需求来添加各种配置参数,这里我们为了保证简单只定三个属性

  • ak: 保证本次埋点程序的唯一值
  • url: 埋点获取到的信息需要上传的服务端地址
  • autoTrack:全埋点的开启方案,对应字段设置为true则开启自动埋点信息的捕获

确定了使用方法之后,我们就可以由此来确定我们的代码要怎么展开。

1. init

// 将原生的三个方法先暂存起来,后面会用到
const collector = {
  oldApp = App,
  oldPage = Page,
}
const init = (config) => {
  // 生成cid
  if (!storage.get('cid')) {
    storage.set('cid', getUUID());
  }
  // 初始化用户自定义配置,store是一个全局的数据仓库(不用关心)
  if (config !== undefined) store.set('config', config);

  // 重写App&Page方法
  App = (options) => collector.oldApp(proxyAppOptions(options));
  Page = (options) => collector.oldPage(proxyPageOptions(options));
};

这里可以看到,我们在init方法中,实现了最关键的一步,就是对AppPage方法的重写。

这里有一点需要注意的是,我们需要明确我们重写方法的目的是为了获取它身上传入的options参数的一些属性和方法
所以在这里我们会对他的options进行重写,然后将重写后的结果,再次传入原生的AppPage方法中执行。

我们在上方定义的collector就是为了保存原生的这些方法,方便我们在这里调用。
(当然collector中不止有这些属性,后面还会收集一些其他信息)

2. proxyAppOptions

接下来让我们来看一下proxyAppOptions方法都做了啥操作

/**
 * 重写App中的options参数
 * @param {*} options 原始的options参数
 * @returns 新的options参数
 */
const _proxyAppOptions = (options) => {
  // 向App中注入手动埋点的方法
  options.$ta = {
    // 方便用户通过getApp()方法直接调用track方法
    track: $ta.track.bind(reporter),
    // 游客访问uid默认为0,用户登录之后需要手动更新其用户id
    login: (uid) => this.login(uid),
  };

  // onLaunch 事件监听
  options.onLaunch = useAppLaunch(options.onLaunch);
  // onShow 事件监听
  options.onShow = useAppShow(options.onShow);
  // onHide 事件监听
  options.onHide = useAppHide(options.onHide);

  return options;
};

这段代码可以看出就是对options中的生命周期进行重写, 然后加入自己的一些处理逻辑,比如useAppLaunchuseAppShow这些钩子函数。
接下来我们先来看一下这些钩子的实现,其实这些钩子很简单就是加入一些自己埋点需要采集数据的一些逻辑

/** ====================App事件代理======================== */
export const useAppLaunch = (oldOnLunch) =>
  _proxyHooks(oldOnLunch, function () {
    const data = {
      event: 'appLaunch',
      path,
      title,
      timemap,
    };
    $ta.track('devices', data);
  });

export const useAppShow = (oldOnShow) =>
  _proxyHooks(oldOnShow, function () {
    const data = {
      event: 'appShow',
    };
    $ta.track('devices', data);
  });

export const useAppHide = (oldOnHide) => {
  _proxyHooks(oldOnHide, function () {
    const data = {
      event: 'appHide',
    };
    $ta.track('devices', data);
  });
};

/**
 * 代理原始方法,并执行回调函数
 * @param {*} fn 需要代理的方法
 * @param {*} cb 需要执行的回调
 */
function _proxyHooks(fn = function () {}, cb) {
  return function () {
    // 如果回调存在
    if (cb) {
      cb.apply(this, arguments);
    }
    // 执行原函数
    fn.apply(this);
  };
}

其实这里也没什么好说的,逻辑也比较清晰,可以看下proxyHooks这个方法,其实就是执行原始方法,然后再执行传入的回调,而我们在回调中其实就是添加一些自己需要埋点的一些数据信息。

$ta这个方法就是Reporter那部分的,这里我们只要了解它是用来发送埋点数据的就可以了,在下文会详细讲一下它的实现。

3. proxyPageOptions

其实 page 这部分和上面的 app 中做的事情都一样,都是对生命周期进行一些处理,只不过page页面中除了生命周期之外,还会有很多自定义事件,所以这里针对这块我们可以一起来看一下。

这里的自定义事件,一般是就是一些点击事件。

// page的原始声明周期集合
const PAGE_LIFE_METHOD = [
  'onLoad',
  'onShow',
  'onReady',
  'onHide',
  'onUnload',
  'onPullDownRefresh',
  'onReachBottom',
  'onShareAppMessage',
  'onShareTimeline',
  'onAddToFavorites',
  'onPageScroll',
  'onResize',
  'onTabItemTap',
  'onSaveExitState',
];
/**
 * 重写Page中的options参数
 * @param {*} options 原始的options参数
 * @returns 新的options参数
 */
const proxyPageOptions = (options) => {
  // ...

  // 自定义事件监听
  for (let prop in options) {
    // 需要保证是函数,并且不是原生的生命周期函数
    if (
      typeof options[prop] == 'function' &&
      !PAGE_LIFE_METHOD.includes(prop)
    ) {
      // 重写options身上的自定义方法
      options[prop] = usePageClickEvent(options[prop]);
    }
  }
  return options;
};

这一部分的处理其实也比较简单,就是遍历 options 身上的属性,判断出来自定义的一些事件就行处理,我们来看一下pageClickEvent这个钩子做了啥

/**
 * 监听页面的点击事件
 * @param {*} oldEvent 原生的page自定义事件
 */
export const pageClickEvent = (oldEvent) =>
  _proxyHooks(oldEvent, function (e) {
    if (e && e.type === 'tap') {
      $ta.track('event', {
        event: 'pageClick',
        // ...
      });
    }
  });

Reporter

这一部分的其实就是封装一个请求方法,将我们追踪到的信息发送到服务端。
不过这里我们有几个点需要考虑一下:

  1. 埋点的网络请求不应该去抢占原始事件的请求,即应该先发送业务请求
  2. 埋点信息发送的时候应该保证顺序才会更利于分析,就比如小程序 show 的信息就应该在 hide 之后去发送
  3. 网络存在波动的时候,如果埋点信息发送失败我们应该缓存该数据,等待下一次发送,保证信息的完整性
import store from '../store';
import { storage } from '../../utils';
import platform from '../platform';
import qs from 'qs';

class Reporter {
  constructor() {
    // 需要发送的追踪信息的队列
    this.queue = [];
    this.timerId;
  }
  /**
   * 追踪埋点数据
   * @param {*} data 需要上报的数据
   */
  track(type, data = {}) {
    // 添加一些公共信息字段
    data.t = type;

    this.queue.push(qs.stringify(data));

    if (!this.timerId) {
      // 为了不影响正常的业务请求,这里延时发出我们的埋点信息
      this.timerId = setTimeout(() => {
        this._flush();
      }, store.get('config').delay);
    }
  }
  /**
   * 执行队列中的任务(向后台发送追踪信息)
   */
  _flush() {
    const config = store.get('config');

    // 队列中有数据时进行请求
    if (this.queue.length > 0) {
      const data = this.queue.shift();
      platform.request({
        // 请求地址
        url: config.url,
        // 超时时间
        timeout: config.request_timeout,
        method: 'POST',
        header: { 'content-type': 'application/x-www-form-urlencoded' },
        data: {
          ak: config.ak,
          cid: storage.get('cid'),
          ns: store.get('networkType'),
          uid: storage.get('uid') || 0,
          data: Date.now(),
          data,
        },
        // TODO 发送失败的时候将该次信息保存的storage中
        success: () => {},
        fail: ({ errMsg }) => {
          console.error(errMsg);
        },
        complete: () => {
          // 执行完成后发送下一个信息
          this._flush();
        },
      });
    } else {
      this.timerId = null;
    }
  }
}

export default new Reporter();

总结

本文只是对小程序埋点方案的一个简单实现,从整体框架上入手去描述的,很多细节都没有涉及到,大家有什么问题可以一起讨论。

本文始发于我的公众号。大家可以扫码关注一下,平常会发一些奇奇怪怪的东西hh

二维码美化.png


眼里有光
10 声望0 粉丝

保持热爱,奔赴山海