@pzds/frontend-tracking
Version:
PZDS 前端埋点收集上报 SDK,支持点击事件、页面访问、元素曝光等多种埋点类型
717 lines (663 loc) • 23.4 kB
JavaScript
'use strict';
function isFunction(arg) {
return typeof arg === "function";
}
// 解析埋点参数
function parseTrackParams(params) {
if (!params) return {};
// 如果已经是对象,直接返回
if (typeof params === "object") return params;
// 如果是字符串,尝试解析为JSON
try {
return JSON.parse(params);
} catch (e) {
console.error("Track params parse error:", e);
return {};
}
}
const EXPOSURE_TYPE = {
// 默认值
BATCH: "batch",
DURATION: "duration"
};
/*
** 埋点类
*/
class AutoTracking {
constructor(router = null, apiMethod = null, apiMethodAlive = null) {
this.pendingEvents = [];
this.debounceTimer = null;
this.isProcessing = false;
this.batchSize = 6; // 批量上报阈值
this.debounceTime = 500; // 防抖时间
this.intersectionObserver = null;
this.mutationObserver = null;
this.elementVisibilityMap = new Map(); // 元素可见性状态
this.lastClickEvent = null; // 记录最后一次点击事件
this.clickDedupeTime = 300; // 点击事件去重时间阈值ms
this.pageEnterTime = Date.now(); // 记录页面进入时间
// this.uuidTimeout = 30 * 60 * 1000; // UUID更新时间阈值,30分钟
// this.initTrackUUID() // 初始化事件链路UUID
this.router = router; // 传入的路由实例
this.apiMethod = apiMethod; // API埋点上报
this.apiMethodAlive = apiMethodAlive; // API埋点异步上报,不会被页面卸载中断
this.exposeQueue = new Map(); // 存储已曝光元素ID
this.pageTrackInfo = null; // 页面params&query参数信息
this.pageTitle = ""; // 页面标题
this.pageUnloadCb = null;
this.globalParams = {};
}
// 初始化事件链路UUID
// initTrackUUID() {
// const storedUUID = localStorage.getItem("track_uuid");
// const lastTrackTime = localStorage.getItem("last_track_time");
// // 如果没有UUID或者距离上次埋点时间超过阈值,生成新的UUID
// if (
// !storedUUID ||
// !lastTrackTime ||
// Date.now() - Number.parseInt(lastTrackTime) > this.uuidTimeout
// ) {
// this.updateTrackUUID();
// }
// }
// // 更新事件链路UUID
// updateTrackUUID() {
// const uuid = this.generateUUIDv4();
// localStorage.setItem("track_uuid", uuid);
// this.updateLastTrackTime();
// }
// 生成UUIDv4
// generateUUIDv4() {
// // 优先使用原生 crypto.randomUUID()
// if (typeof crypto !== "undefined" && crypto.randomUUID) {
// return crypto.randomUUID().replace(/-/g, "");
// }
// // 如果原生不支持,使用加密安全的随机数生成
// return ([1e7] + 1e3 + 4e3 + 8e3 + 1e11).replace(/[018]/g, (c) => {
// // 生成加密安全的随机数
// const random = crypto.getRandomValues(new Uint8Array(1))[0];
// return (c ^ (random % 16 >> (c / 4))).toString(16);
// });
// }
// 更新最后一次埋点时间
// updateLastTrackTime() {
// localStorage.setItem("last_track_time", Date.now().toString());
// }
// 初始化全局点击事件监听
init() {
document.addEventListener("click", this.handleClick.bind(this), true);
window.addEventListener("beforeunload", this.handlePageUnload.bind(this));
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
// this.initRouteTracking()
this.initIntersectionObserver();
this.batchElementExposure();
this.initPageLoadTracking();
this.initRemainTrack();
}
// 销毁实例时清理观察器
destroy() {
document.removeEventListener("click", this.handleClick.bind(this), true);
window.removeEventListener("beforeunload", this.handlePageUnload.bind(this));
document.removeEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
this.elementVisibilityMap.clear();
}
// 记录页面加载完成事件
trackPageLoad() {
const navigationTiming = performance.getEntriesByType("navigation")[0];
const pageLoadTime = navigationTiming ? Math.round(navigationTiming.loadEventStart - navigationTiming.startTime) : 0;
this.addEvent({
eventType: "pageLoad",
pageTitle: document.title,
loadTime: pageLoadTime
});
}
// 初始化页面加载完成监听
initPageLoadTracking() {
if (document.readyState === "complete") {
this.trackPageLoad();
}
}
// 初始化元素可见事件埋点观察器
initIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
const element = entry.target;
const {
_type: exposureType = "",
_id: trackId = "",
...trackParams
} = parseTrackParams(element.getAttribute("data-track-exposure"));
// 批量曝光,每6个曝光一次
const tagName = element.tagName.toLowerCase();
if (exposureType === EXPOSURE_TYPE.BATCH) {
if (entry.isIntersecting) {
this.queueEleExposureReport(element);
this.intersectionObserver.unobserve(element);
}
} else if (exposureType === EXPOSURE_TYPE.DURATION) {
if (entry.isIntersecting) {
// 元素进入视区
this.elementVisibilityMap.set(trackId, {
enterTime: Date.now(),
trackId,
trackParams
});
} else {
// 元素离开视区,计算停留时长并上报
const visibilityInfo = this.elementVisibilityMap.get(trackId);
if (visibilityInfo) {
const duration = Date.now() - visibilityInfo.enterTime;
this.trackElementExposure(trackId, duration, visibilityInfo.trackParams, tagName);
this.elementVisibilityMap.delete(trackId);
}
}
} else {
if (entry.isIntersecting) {
// 元素进入视区
this.trackElementExposure("", null, trackParams, tagName);
// 解除监听
this.intersectionObserver.unobserve(element);
}
}
});
}, {
threshold: 0.5 // 元素可见面积达到50%时触发
});
}
// 初始化路由变化监听
initRouteTracking() {
// 检测运行时环境
if (typeof window === "undefined") return;
// 如果传入了路由实例,优先使用路由实例
if (this.router && isFunction(this.router.beforeEach)) {
this.initVueRouteBeforeTracking();
} else {
// 默认使用 history API 监听路由变化
this.initHistoryRouteTracking();
}
if (this.router && isFunction(this.router.afterEach)) {
this.initVueRouteAfterTracking();
}
}
// Vue路由进入监听
initVueRouteBeforeTracking() {
this.router.beforeEach(async (to, from, next) => {
const trackInfo = {
fromPath: from.path,
toPath: to.path
};
this.pageTrackInfo = {
...to.params,
...to.query
};
if (this.pageTrackInfo) {
Object.assign(trackInfo, this.pageTrackInfo);
}
this.trackPageView(trackInfo);
this.handleUntrackEvent();
next();
});
}
// vue内部路由离开监听
initVueRouteAfterTracking() {
this.router.afterEach((to, from) => {
if (to.path === from.path || from.name == null && from.path === "/") return;
const params = {
pageUrl: window.location.host + from.fullPath,
pageTitle: this.pageTitle || document.title
};
this.handlePageRefresh(params);
this.pageEnterTime = Date.now();
this.pageTitle = document.title;
});
}
// 通用History API路由监听
initHistoryRouteTracking() {
let lastPath = window.location.pathname;
// 监听 popstate 事件
window.addEventListener("popstate", () => {
const currentPath = window.location.pathname;
this.trackPageView({
fromPath: lastPath,
toPath: currentPath
});
lastPath = currentPath;
});
// 重写 history.pushState 和 replaceState
const originalPushState = history.pushState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
const currentPath = window.location.pathname;
this.trackPageView({
fromPath: lastPath,
toPath: currentPath
});
lastPath = currentPath;
};
}
initRemainTrack() {
const trackEvent = JSON.parse(localStorage.getItem("track_event") || null);
if (isFunction(trackEvent)) {
trackEvent.forEach(item => {
this.addEvent(item);
});
localStorage.removeItem("track_event");
}
}
// 添加元素曝光监控
addExposureTracking(element) {
if (element !== null && element !== void 0 && element.hasAttribute && element.hasAttribute("data-track-exposure")) {
setTimeout(() => {
var _this$intersectionObs;
(_this$intersectionObs = this.intersectionObserver) === null || _this$intersectionObs === void 0 || _this$intersectionObs.observe(element);
});
}
}
// 移除元素曝光监控
removeExposureTracking(element) {
if (element && element.hasAttribute("data-track-exposure")) {
var _this$intersectionObs2;
(_this$intersectionObs2 = this.intersectionObserver) === null || _this$intersectionObs2 === void 0 || _this$intersectionObs2.unobserve(element);
// 如果元素在移除时仍在视区内,需要上报停留时长
const trackId = element.getAttribute("data-track-exposure");
const visibilityInfo = this.elementVisibilityMap.get(trackId);
if (visibilityInfo) {
const duration = Date.now() - visibilityInfo.enterTime;
this.trackElementExposure(trackId, duration, visibilityInfo.trackParams, element.tagName.toLowerCase());
this.elementVisibilityMap.delete(trackId);
}
}
}
// 上报元素曝光事件
trackElementExposure(trackId, duration, trackParams, elementType, alive) {
const trackData = {
eventType: "exposure",
elementType,
...(trackId ? {
trackId
} : {}),
...(duration ? {
duration
} : {})
};
this.addEvent({
...trackData,
...trackParams
}, alive);
}
// 批量上报元素曝光 并解除监听
batchEleExposureReport(module, alive = false) {
const params = {
exposure_goods: [],
...JSON.parse(JSON.stringify(module))
};
const exposedArray = module.exposedItems;
if (!(exposedArray !== null && exposedArray !== void 0 && exposedArray.size)) return;
Array.from(exposedArray).forEach(item => {
params.exposure_goods.push(item.exposure_goods);
exposedArray.delete(item);
});
delete params.exposedItems;
this.addEvent(params, alive);
}
// 曝光元素存入队列,到达阈值上报
queueEleExposureReport(el) {
var _module;
const {
_type,
_id,
...trackParams
} = parseTrackParams(el.getAttribute("data-track-exposure"));
const exposure_goods = trackParams.param;
const module_id = trackParams.module_id;
delete trackParams.param;
delete trackParams.module_id;
let module = this.exposeQueue.get(module_id);
if ((_module = module) !== null && _module !== void 0 && (_module = _module.exposedItems) !== null && _module !== void 0 && _module.size) {
const alreadyExist = Array.from(module.exposedItems).some(item => item.exposure_goods === exposure_goods);
!alreadyExist && module.exposedItems.add({
exposure_goods,
el
});
} else {
module = {
eventType: "exposure",
module_id,
...trackParams,
exposedItems: new Set([{
exposure_goods,
el
}])
};
this.exposeQueue.set(module_id, module);
}
if (module.exposedItems.size >= 6) {
this.batchEleExposureReport(module);
}
}
// 批量曝光元素
batchElementExposure() {
// 初始化已有的元素
this.initExpose();
// 创建MutationObserver监听DOM变化
this.mutationObserver = new MutationObserver(mutations => {
// 收集所有新增的元素
const addedElements = [];
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
// 如果是元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
// 检查节点本身是否需要监听
if (node.hasAttribute && node.hasAttribute('data-track-exposure')) {
addedElements.push(node);
}
// 查找节点内部所有需要监听的元素
if (node.querySelectorAll) {
const childElements = node.querySelectorAll('[data-track-exposure]');
if (childElements.length > 0) {
addedElements.push(...Array.from(childElements));
}
}
}
});
}
});
// 批量处理新增的元素
if (addedElements.length > 0) {
this.processElementsBatch(addedElements);
}
});
// 设置观察选项
const config = {
subtree: true,
// 监听所有后代节点
childList: true,
// 监听子节点变化
attributes: false,
characterData: false
};
// 开始观察
this.mutationObserver.observe(document.documentElement, config);
}
// 服务端渲染 页面初始化就已经有了曝光元素, mutationObserver监听不到
initExpose() {
// 直接使用CSS选择器一次性查找所有需要监听的元素
const exposureElements = document.querySelectorAll('[data-track-exposure]');
// 批量处理,避免一次性处理太多元素
if (exposureElements.length > 0) {
this.processElementsBatch(Array.from(exposureElements));
}
}
// 批量处理元素
processElementsBatch(elements, index = 0) {
const batchSize = 50; // 每批处理50个元素
const end = Math.min(index + batchSize, elements.length);
// 处理当前批次的元素
for (let i = index; i < end; i++) {
this.addExposureTracking(elements[i]);
}
// 如果还有元素未处理,安排下一批
if (end < elements.length) {
// 兼容性处理,优先使用requestAnimationFrame,不支持时降级为setTimeout
if (window.requestAnimationFrame) {
requestAnimationFrame(() => {
this.processElementsBatch(elements, end);
});
} else {
setTimeout(() => {
this.processElementsBatch(elements, end);
}, 16); // 大约60fps的帧率
}
}
}
// 处理页面刷新/离开时的未上报埋点上报事件上报&globalId更新时上报
handleUntrackEvent(isAlive = true) {
// 使用alive的方式上报数据,避免数据丢失
this.exposeQueue.forEach(module => {
this.batchEleExposureReport(module, isAlive);
});
}
// 处理页面刷新/离开时的pageRefresh事件上报
handlePageRefresh(params = {}) {
const duration = Date.now() - this.pageEnterTime;
const event = {
eventType: "pageRefresh",
pageTitle: document.title,
duration,
...params
};
if (this.pageTrackInfo) {
Object.assign(event, this.pageTrackInfo);
}
// 使用同步的方式上报数据,避免数据丢失
this.addEvent(event, true);
}
// 处理页面卸载事件
handlePageUnload() {
// 页面刷新/关闭窗口处理页面事件上报
this.handlePageRefresh();
this.handleUntrackEvent();
this.destroy();
// 页面刷新时更新UUID
// this.updateTrackUUID();
this.pageUnloadCb();
}
handleVisibilityChange() {
if (document.visibilityState === "hidden") {
this.handleUntrackEvent();
}
}
// 处理点击事件
handleClick(event) {
// 检查是否是重复点击事件
const currentTime = Date.now();
if (this.lastClickEvent && event.target === this.lastClickEvent.target && currentTime - this.lastClickEvent.timestamp < this.clickDedupeTime) {
return; // 忽略重复点击
}
this.lastClickEvent = {
target: event.target,
timestamp: currentTime
};
let target = event.target;
let trackElement = null;
let trackParams = null;
if (trackElement = this.findTrackElement(target)) {
target = trackElement;
trackParams = trackElement.getAttribute("data-track-click");
} else if (trackElement = this.findVanButton(target)) {
trackParams = this.getDialogButtonParams(trackElement);
trackParams && (target = trackElement);
}
const elementContent = this.getElementContent(target);
const trackData = {
eventType: "click",
targetInfo: {
tagName: target.tagName || null,
// className: target.className || null,
// id: target.id || null,
textContent: elementContent
}
};
trackParams && (trackData.trackParams = parseTrackParams(trackParams));
// 如果没有找到埋点元素,但是点击了有内容的dom,也记录点击事件,上传点击事件target信息等
if (elementContent !== null || trackParams) {
this.addEvent(trackData);
}
}
// 获取元素内容(如果是图片,获取alt属性)
getElementContent(element) {
var _element$textContent3;
if (!element) return null;
// 处理图片元素
if (element.tagName === "IMG") {
return element.alt || element.title || null;
}
// 处理输入框和按钮
if (["INPUT", "BUTTON"].includes(element.tagName)) {
var _element$textContent;
return element.value || element.placeholder || element.getAttribute("aria-label") || ((_element$textContent = element.textContent) === null || _element$textContent === void 0 ? void 0 : _element$textContent.trim()) || null;
}
// 处理链接
if (element.tagName === "A") {
var _element$textContent2;
return ((_element$textContent2 = element.textContent) === null || _element$textContent2 === void 0 ? void 0 : _element$textContent2.trim()) || element.title || element.getAttribute("aria-label") || null;
}
// 其他元素
return ((_element$textContent3 = element.textContent) === null || _element$textContent3 === void 0 ? void 0 : _element$textContent3.trim()) || element.getAttribute("aria-label") || null;
}
// 向上冒泡查找带有埋点属性的最近元素
recursionElement(element, condition) {
let current = element;
while (current && current !== document.body) {
if (condition(current)) {
return current;
}
current = current.parentElement;
}
return null;
}
findTrackElement(element) {
return this.recursionElement(element, el => el.hasAttribute("data-track-click"));
}
findVanButton(element) {
return this.recursionElement(element, el => el.classList.contains("van-button") && el.tagName === "BUTTON");
}
findVanDialog(element) {
return this.recursionElement(element, el => el.getAttribute("role") === "dialog");
}
getDialogButtonParams(element) {
// 兼容服务式调用vant弹窗,获取不到dom
const vanDialog = this.findVanDialog(element);
let trackParams = null;
if (vanDialog) {
const cancelProps = vanDialog.getAttribute("cancel-button-click");
const confirmProps = vanDialog.getAttribute("confirm-button-click");
if (element.classList.contains('van-dialog__cancel') && cancelProps) {
trackParams = cancelProps;
} else if (element.classList.contains('van-dialog__confirm') && confirmProps) {
trackParams = confirmProps;
}
}
return trackParams;
}
// 记录页面访问
trackPageView(pageData) {
this.addEvent({
eventType: "pageView",
...pageData
});
}
// 添加事件到队列
addEvent(event, alive = false) {
// 更新最后一次埋点时间
// this.updateLastTrackTime();
this.pendingEvents.push({
pageUrl: window.location.href,
timestamp: Date.now(),
...this.globalParams,
// deviceInfo,
...event
});
if (alive) {
this.trackEvents(true);
} else {
this.debounceUpload();
}
}
// 防抖上报
debounceUpload() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.processEvents();
}, this.debounceTime);
}
// 处理事件队列
async processEvents() {
if (this.isProcessing || this.pendingEvents.length === 0) return;
this.isProcessing = true;
let events;
try {
events = await this.trackEvents();
} catch (error) {
console.error("Track events error:", error);
// 失败的事件重新加入队列
this.pendingEvents.unshift(...events);
}
this.isProcessing = false;
// 如果还有待处理的事件,继续处理
if (this.pendingEvents.length > 0) {
this.debounceUpload();
}
}
// 处理埋点上报
async trackEvents(alive = false) {
const events = this.pendingEvents.splice(0, this.batchSize);
const trackPromises = events.map(event => this.trackMethod(event, alive));
await Promise.all(trackPromises);
return events;
}
// 上报埋点数据的方法
async trackMethod(event, alive = false) {
// 这里实现接口上报
try {
if (alive) {
if (this.apiMethodAlive) {
try {
this.apiMethodAlive(event);
} catch (error) {
// apiMethodAlive目前使用的是fetch的keepalive属性, 兼容性在约80%,此处失败可能是api不兼容 也可能是接口报错 无论哪种报错 将数据存储进localStorage 在用户下次打开页面时重新上报
let trackEvent = JSON.parse(localStorage.getItem("track_event") || null);
if (isFunction(trackEvent)) {
trackEvent.push(event);
} else {
trackEvent = [event];
}
localStorage.setItem("track_event", JSON.stringify(trackEvent));
}
}
} else {
this.apiMethod && this.apiMethod(event);
}
} catch (error) {
console.error("上报埋点error:", error);
}
}
// 业务与埋点交互 1.传入页面卸载回调2.传入全局埋点参数
config(type, data) {
if (type === "pageUnload") {
this.pageUnloadCb = data;
} else if (type === "params") {
this.globalParams = data;
if (this.globalParams.globalId) {
// 如果要修改globalId 就把未上报的埋点全部上报
this.handleUntrackEvent(false);
}
}
}
}
// 创建单例
let instance = null;
function useAutoTracking(router = null, apiMethod = null, apiMethodAlive = null) {
if (!instance) {
instance = new AutoTracking(router, apiMethod, apiMethodAlive);
}
return instance;
}
var index = {
useWebAutoTracking: useAutoTracking
};
module.exports = index;