参考资料 网易云音乐大前端团队 – 前端组件化埋点的实践
在流量红利逐渐消失的现在,数据的采集、分析和精细化的运营显得更加重要,埋点能够帮助我们收集用户数据,辅助运营决策的同时提供算法以致力于建立用户图像,从而实现个性化推荐。
组件埋点: 当前采用的埋点方案,利用组件实现埋点上传,业务代码和埋点逻辑基本抽离
优点:埋点逻辑与业务代码解耦,利于代码维护和复用。
缺点:小程序没有DOM元素,因此会增加DOM层级,组件内使用曝光埋点不便。
手动代码埋点:用户触发某个动作后手动上报数据
优点:准确,可以满足很多定制化的需求。
缺点:埋点逻辑与业务代码耦合到一起,不利于代码维护和复用。
无埋点:也叫“全埋点”,前端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据
优点:收集用户的所有端上行为,很全面。
缺点:无效的数据很多,上报数据量大,服务器压力很大。
由于小程序无法像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({
/**
* 虚拟化组件节点,防止影响 css 样式以及不增加 DOM 层级
*/
options: {
virtualHost: true
},
/**
* 组件的属性列表
*/
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({
/**
* 虚拟化组件节点,防止影响 css 样式以及不增加 DOM 层级
*/
options: {
virtualHost: true
},
/**
* 组件的属性列表
*/
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 来实现检测页面的显示和关闭。
一些场景下我们没法绑定事件到 dom 上,比如小程序的分享,针对这种场景我们提供了 appendQueue 方法,把埋点加入到缓冲队列中。
参考网易云方案,我们也采用定时任务上报,点击类上报频率 1000ms,曝光类 3000ms,不过相较于网易云,我们采用了两种方案减少埋点数据丢失的可能性。
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>
注意事项:
<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('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 },
});
},