埋点是在应用中特定的流程收集一些信息,用来跟踪应用使用的状况,以便后续进一步优化产品或提供运营的数据支撑。如访问数、访客数、停留时长、页面浏览数等等。
埋点方式
-
手动埋点
在需要上报的地方主动编写相应的上报代码。如注册完成时需要记录上报,则在注册完成逻辑中编写相关的上报代码。此种方式需要在每个上报的地方进行埋点(编写代码),所以较为繁琐,以及不便于维护。但由于是手动进行埋点,所以可以精准控制埋点的位置,上报精准的数据(对服务端的数据分析较友好),以及可以灵活的自定义事件和属性。
-
可视化埋点
可视化埋点可以看作是手动埋点的可视化版,通过可视化操作进行对页面的埋点。由于可视化操作,所以可视化埋点只能对可见元素进行埋点,一些复杂页面,动态页面则力不逮了,但也大大降低了埋点的门槛,使非开发人员也可以进行埋点操作,数据也较为准确。此种方式一般使用第三方平台,毕竟需要开发 SDK 以及页面的可视化操作,有一定的难度,以及需要耗费不少的时间。
-
全埋点
全埋点,对应用中的行为进行无差别的记录上报。
此种方式由于是无差别的进行记录上报,所以对于服务端的数据分析非常不友好,需要先进行数据筛选,再进行数据分析,数据量也非常大。
数据收集
埋点主要有两步:
收集数据
上报数据
-
事件数据收集
分析用户使用的某个功能的频次,就可以在用户操作时收集并上报数据。
const eventList = ["click", "dblclick", "mouseout", "mouseover"];
eventList.forEach((event) => {
window.addEventListener(event, (e) => {
const target = e.target;
const reportKey = target.getAttribute("report-key");
if (reportKey) {
// 上报代码
console.log("上报", event);
}
});
});
-
页面访问量收集
页面的访问量统计,根据不同的产品,记录的时机也不同,如有些只需要页面加载完毕,有些需要浏览页面才算等。
但如果页面是单页应用,那么监听 load 就不准确了。由于单页应用的路由是在不刷新页面的情况下进行渲染页面的,所以可以监听 url 的变化进行记录。
window.addEventListener("load", (event) => {
console.log("上报", "page load");
});
如果单页应用的路由模式为 hash,则可以通过监听 hashchange
// 单页面应用hash
window.addEventListener("hashchange", () => {
console.log("上报", "hashchange");
});
如果单页应用的路由模式为 history,则可监听 pushstate 和 replacestate,但由于 History API 在调用 history.pushState 和 history.replaceState 时,并不会触发 pushstate 和 replacestate 事件,所以需要重新创建一个 pushState 和 replaceState 事件
function wrap(event) {
const fun = history[event];
return function () {
const res = fun.apply(this, arguments);
const e = new Event(event);
window.dispatchEvent(e);
return res;
};
}
经过上面的包装,再进行监听 pushState 和 replaceState,在调用 history.pushState 和 history.replaceState(单页应用跳转),就可正常监听并记录了
window.addEventListener("pushState", (e) => {
console.log("上报", "pushState");
});
window.addEventListener("replaceState", (e) => {
console.log("上报", "replaceState");
});
如果是需要浏览页面时上报数据,方案就比较多样,如可以以某个元素出现在可视区域时进行记录,或监听滚动条滚动等等
-
页面停留时间收集
页面停留时间,就是记录从访问页面到离开/关闭页面的时间。不管是 SPA 应用还是非 SPA 应用,都是记录从页面加载到页面关闭或跳转的时间
const dulation = {
startTime: 0,
value: 0,
};
// 首次进入页面
window.addEventListener("load", () => {
// 记录时间
const time = new Date().getTime();
dulation.startTime = time;
});
// 单页应用页面跳转(触发 replaceState)
window.addEventListener("replaceState", () => {
const time = new Date().getTime();
dulation.value = time - dulation.startTime;
// 上报
// .....
// 上报后重新初始化 dulation, 以便后续跳转计算
dulation.startTime = time;
dulation.value = 0;
});
// 单页应用页面跳转(触发 pushState)
window.addEventListener("pushState", () => {
const time = new Date().getTime();
dulation.value = time - dulation.startTime;
// 上报
// .....
// 上报后重新初始化 dulation, 以便后续跳转计算
dulation.startTime = time;
dulation.value = 0;
});
// 非单页应用跳转触发 popstate
window.addEventListener("popstate", () => {
const time = new Date().getTime();
dulation.value = time - dulation.startTime;
// 上报
// .....
// 上报后重新初始化 dulation, 以便后续跳转计算
dulation.startTime = time;
dulation.value = 0;
});
// 页面没有任何跳转, 直接关闭页面的情况
window.addEventListener("beforeunload", () => {
const time = new Date().getTime();
dulation.value = time - dulation.startTime;
// 上报
// .....
// 上报后重新初始化 dulation, 以便后续跳转计算
dulation.startTime = time;
dulation.value = 0;
});
上面代码上报了从页面加载到页面跳转或关闭的时间差,这个时间差就是页面的停留时间。页面加载只有一个入口 (load),而跳转分为单页应用和非单页应用;单页应用又有两种路由模式(模式不同,跳转触发的事件不同);又有无跳转直接关闭页面的情况,而上面代码都一一考虑到了。
-
异常数据收集
收集异常数据,不仅可以监控产品的 bug 以便可以快速的修复,还能根据异常数据进行错误率分析。根据产生异常的方式不同,监听获取异常数据的方式也不同,如一般的语法错误或者运行错误,则可使用 error 事件进行监听,而 Promise 抛出的异常,则需要监听 unhandledrejection 事件
window.addEventListener("error", (e) => {
console.log("上报", "error");
});
window.addEventListener("unhandledrejection", (e) => {
console.log("上报", "unhandledrejection");
});
如果页面使用了框架进行开发, 如 Vue、React 等,一般这种框架有自带的异常捕获机制,那么就需要使用其相关的异常监听了,如 Vue 的 app.config.errorHandler
数据上报
上报的数据会影响数据分析的准确性。数据上报时,除了上报业务需要的信息(用户 ID,点击的元素名称等),还需要客户端的设备信息(UA,当前访问的 URL 等)以及使用的 SDK 信息(版本)。
数据格式
上报的数据需要一个较为稳定的格式,以便后端方便分析
{
uid; // 用户id
title; // 页面标题
url; // 页面的url
time; // 触发埋点的时间
ua; // userAgent
screen; // 屏幕信息, 如 1920x1080
type // 数据类型,根据触发的不同埋点有不同的类型
data; // 根据不同的type,此处的数据也不同,如 事件数据有元素名称,事件名称、页面停留时间有停留时长....
sdk // sdk 相关信息
}
上报方式
数据的上报就是将数据发送给服务端,发送数据在一般情况下是使用 XMLHttpReuqest(axios)或者 Fetch,但用其有两个缺陷,一是有跨域问题,二是在页面关闭时会中断正在发送的数据
考虑到以上几点,一般使用图片来发送数据或者使用 navigator.sendBeacon,也可以两者结合(如果浏览器不支持 sendBeancon,就使用图片的方式)。
function send(data) {
const url = `xxxx`
if (navigator.sendBeacon) {
navigator.sendBeacon(url, JSON.stringify(data));
} else {
const imgReq = new Image();
imgReq.src = `${url}?params=${JSON.stringify(
data
)}&t=${new Date().getTime()}`;
}
}
SDK 实现
数据收集以及数据上报的方式都有了,那么就可以开始实现 SDK 了,这里先以单文件(非工程化)进行实现
// JS 完整代码部分
(function (e) {
function wrap(event) {
const fun = history[event];
return function () {
const res = fun.apply(this, arguments);
const e = new Event(event);
window.dispatchEvent(e);
return res;
};
}
class TrackingDemo {
constructor(options = {}) {
// 重写 pushState、replaceState
window.history.pushState = wrap("pushState");
window.history.replaceState = wrap("replaceState");
// 上报地址
this.reportUrl = options.reportUrl || "";
this.sdkVersion = "1.0.0";
this._eventList = ["click", "dblclick", "mouseout", "mouseover"];
this._dulation = {
startTime: 0,
value: 0,
};
this._initJSError();
// 初始化事件数据收集
this._initEventHandler();
// 初始化PV统计
this._initPV();
this._initPageDulation();
}
setUserId(uid) {
this.uid = uid;
}
_initEventHandler() {
this._eventList.forEach((event) => {
window.addEventListener(event, (e) => {
const target = e.target;
const reportKey = target.getAttribute("report-key");
if (reportKey) {
this._report("event", {
tagName: e.target.nodeName,
tagText: e.target.innerText,
event,
});
}
});
});
}
_initPV() {
window.addEventListener("pushState", (e) => {
this._report("pv", {
type: "pushState",
referrer: document.referrer,
});
});
window.addEventListener("replaceState", (e) => {
this._report("pv", {
type: "replaceState",
referrer: document.referrer,
});
});
window.addEventListener("hashchange", () => {
this._report("pv", {
type: "hashchange",
referrer: document.referrer,
});
});
}
_initPageDulation() {
let self = this;
function initDulation() {
const time = new Date().getTime();
self._dulation.value = time - self._dulation.startTime;
self._report("dulation", {
...self._dulation,
});
self._dulation.startTime = time;
self._dulation.value = 0;
}
// 首次进入页面
window.addEventListener("load", () => {
// 记录时间
const time = new Date().getTime();
this._dulation.startTime = time;
});
// 单页应用页面跳转(触发 replaceState)
window.addEventListener("replaceState", () => {
initDulation();
});
// 单页应用页面跳转(触发 pushState)
window.addEventListener("pushState", () => {
initDulation();
});
// 非单页应用跳转触发 popstate
window.addEventListener("popstate", () => {
initDulation();
});
// 页面没有任何跳转, 直接关闭页面的情况
window.addEventListener("beforeunload", () => {
initDulation();
});
}
_initJSError() {
window.addEventListener("error", (e) => {
this._report("error", {
message: e.message,
});
});
window.addEventListener("unhandledrejection", (e) => {
this._report("error", {
message: e.reason,
});
});
}
// 用户可主动上报
reportTracker(data) {
this._report("custom", data);
}
_getPageInfo() {
const { width, height } = window.screen;
const { userAgent } = navigator;
return {
uid: this.uid,
title: document.title,
url: window.location.href,
time: new Date().getTime(),
ua: userAgent,
screen: `${width}x${height}`,
};
}
_report(type, data) {
const reportData = {
...this._getPageInfo(),
type,
data,
sdk: this.sdkVersion,
};
if (navigator.sendBeacon) {
navigator.sendBeacon(this.reportUrl, JSON.stringify(reportData));
} else {
const imgReq = new Image();
imgReq.src = `${this.reportUrl}?params=${JSON.stringify(
reportData
)}&t=${new Date().getTime()}`;
}
}
}
e.TrackingDemo = TrackingDemo;
})(window);
// HTML 代码部分
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button report-key="button">按钮</button>
</body>
<script src="./tackerDemo.js"></script>
<script>
const trackingDemo = new TrackingDemo()
</script>
</html>
工程化
上面的非工程化实现只是一个单文件形式(iife),如果项目是模块化开发的话,上面形式就不支持 import 和 require 了,所以就需要打包工具进行打包,以便支持 esm、cjs 和 umd 三种方式,同时工程化还便于维护。这里使用 rollup 进行打包,以及使用 typescript 进行类型约束。
安装
npm i @guodz/monitordemo
使用方法如下:
import Monitor from "@guodz/monitordemo"
const monitor = new Monitor({
reportUrl: 'xxx'
})
评论区