UNPKG

@pzds/frontend-tracking

Version:

PZDS 前端埋点收集上报 SDK,支持点击事件、页面访问、元素曝光等多种埋点类型

715 lines (662 loc) 23.4 kB
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 }; export { index as default };