前端埋点sdk封装

引言 前端埋点sdk的方案十分成熟,之前用的都是公司内部统一的埋点产品,从前端埋点和数据上报后的可视化查询全链路打通。但是在最近的一个私有化项目中就遇到了问题,因为服务都是在客户自己申请的服务器上的,需要将埋点数据存放到自己的数据库中,同时前端埋点的功能简洁,不需要太多花里胡哨的东西。公司内部的埋点产品不适用,外部一些十分成熟的埋点产品又显得太臃肿,因此着手自己在开源包的基础上封了一个简单的埋点sdk,简单聊聊其中的一些功能和解决方式。
功能 对于产品来说,埋点上首要关心的是页面的pv、uv,其次是一些重要操作(以点击事件为主)的频率,针对某些曝光量高的页面,可能也会关注页面的热力图效果。满足这些关键功能的基础上,同时把一些通用的用户环境参数(设备参数、时间参数、地区参数)携带上来,发送请求到指定的后端服务接口,这就基本上满足了一个埋点skd的功能。
而我这次封装的这个sdk,大概就具备了以下一些功能:
1.页面加载完成自动上报pv、uv
2.支持用户手动上报埋点
3.上报时默认携带时间、设备等通用参数
4.支持用户自定义埋点参数上报
5.支持用户标识设置
6.支持自动开始热力图埋点(页面中的任意点击会自动上报)
7.支持dom元素配置化的点击事件上报
8.支持用户自定义埋点上报接口配置
使用方式 第一步:前端工程中引入 打包后的埋点sdk的文件放到cdn上,前端工程再页面中通过cdn方式引入
第二步:初始化埋点配置

const tracker = new Tracker({ appid: 'default', // 应用标识,用来区分埋点数据中的应用 uuid: '', // 设备标识,自动生成并存在浏览器中, extra: {}, // 用户自定义上传字段对象 enableHeatMapTracker: false, // 是否开启热力图自动上报 enableLoadTracker: false, // 是否开启页面加载自动上报,适合多页面应用的pv上报 enableHistoryTracker: false, // 是否开启页面history变化自动上报,适合单页面应用的history路由 enableHashTracker: false, // 是否开启页面hash变化自动上报,适合单页面应用的hash路由 requestUrl: 'http://localhost:3000' // 埋点请求后端接口 })

第三步:使用自定义埋点上报方法
// 设置用户标识,在用户登录后使用 tracker.setUserId('9527')// 埋点发送方法,3个参数分别是:事件类型,事件标识,上报数据 tracker.sendTracker('click', 'module1', {a:1, b:2, c:'ccc'})

方案设计 了解了功能和用法之后,下面具体说说功能中的一些具体设计思路和实现方案
埋点字段设计
埋点字段指的是埋点请求上报时需要携带的参数,也是最终对埋点数据进行分析时要用到的字段,通常包括业务字段和通用字段两部分,根据具体需求进行设计。业务字段倾向于规范和简洁,而通用字段倾向于完整和实用。并不是上报越多字段越好,不论是对前端请求本身,还是后端数据入库都是一种负担。我这边针对需求设计的埋点字段如下:
字段 含义
appid 应用标识
uuid 设备id
userId 用户id
browserType 浏览器类型
browserVersion 浏览器版本
browserEngine 浏览器引擎
language 语言
osType 设备类型
osVersion 设备版本号
eventTime 埋点上报时间
title 页面标题
url 页面地址
domPath 事件触发的dom
offsetX 事件触发的dom的x坐标
offsetY 事件触发的dom的y坐标
eventId 事件标识
eventType 事件类型
extra 用户自定义字段对象
pv统计
pv的统计根据业务方需求有两种方式,第1种是完全由业务方自己来控制,在页面加载或变化的时候调用通用埋点方法来上报。第2种是通过初始化配置开启自动pv统计,由sdk来完成这一部分的埋点上报。第1种方式非常好理解,就不具体展开来,下面具体说一些sdk自动埋点统计的实现原理:
对于多页面应用,每次进一个页面就是一次pv访问,所以配置了 addEventListener = true 之后,sdk内部会对浏览器的load事件进行监听,当页面load后进行埋点上报,所以本质上是对浏览器load事件的监听和处理。
对于单页面应用来说,只有第一次加载页面才会触发load事件,后续路由的变化都不会触发。因此除了监听load事件外,还需要根据路由的变化监听对应的事件,单页面应用有两种路由模式:hash模式和history模式,两者的处理方式有所差异:
  • hash模式,单页面应用的hash路由实现原理是通过改变url的hash值来实现无页面刷新的,hash的变化会触发浏览器的hashchange事件,因此埋点sdk中只需要对hashchange事件进行监听,就可以在事件触发时进行埋点上报。
  • history模式,单页面应用的history路由实现的原理是通过操纵浏览器原生的history对象,history对象中记录着浏览器会话的历史记录,并提供了一些方法对会话栈进行管理。如:
history.go(): history.forward(): history.back(): history.pushState(): history.replaceState():

和hash模式不同的是,上述的history.go、history.forward 和 history.back 3个方法会触发浏览器的popstate事件,但是history.pushState 和 history.replaceState 这2个方法不会触发浏览器的popstate事件。然而主流的前端框架如react、vue中的单页面应用history模式路由的底层实现是依赖 history.pushState 和 history.replaceState 的。因此并没有原生的事件能够被用来监听触发埋点。为了解决这个问题,可以通过改写history的这两个事件来实现新事件触发:
const createHistoryEvent = function(type) { var origin = history[type]; return function() { var res = origin.apply(this, arguments); var e = new Event(type); e.arguments = arguments; window.dispatchEvent(e); return res; }; }; history['pushState'] = createHistoryEvent('pushState'); history['replaceState'] = createHistoryEvent('replaceState');

改写完之后,只要在埋点sdk中对pushState和replaceState事件进行监听,就能实现对history模式下路由变化的埋点上报。
uv统计
埋点对pv的支持是必不可少的,sdk会提供了一个设置用户uid的方法setUserId暴露给业务使用,当业务平台获取到登录用户的信息后,调用该方法,则会在后续的埋点请求中都带上uid,最后在埋点分析的时候以该字段进行uv的统计。但是这样的uv统计是不准确的,因为忽略了用户未登录的情况,统计出来的uv值是小于实际的,因此需要在用户未登录的情况下也给一个区分标识。这种标识常见的有以下几种方式:
  • 用户ip地址
  • 用户第一次访问时,在cookie或localStorage中存储一个随机生成的uuid
  • 浏览器指纹追踪技术,通过获取浏览器具有辨识度的信息,进行一些计算得出一个值,那么这个值就是浏览器指纹,辨识度的信息可以是UA、时区、地理位置或者是你使用的语言等等
这几种方式各自存在着自己的一些弊端,ip地址准确度不够,比如同一个局域网内的共享一个ip、代理、动态ip等原因都会造成数据统计都错误。cookie和localStorage都缺陷是用户可以主动去清除。而浏览器指纹追踪技术的应用目前并不是很成熟。
综合考虑后,sdk中采用了localStorage技术,当用户第一次访问时,会自动生成一个随机的uuid存储下来,后续的埋点上报中都会携带这个uuid,进行用户信息都标识。同时如果业务平台调用了setUserId方法,则会把用户id存储到uid字段中。最后统计uv都时候,根据实际情况参考uid或者uuid字段,准确的uv数据,应该是介于uid和uuid之间的一个数值。
热力图上报
热力图埋点的意思是:监听页面中任意位置的用户点击事件,记录下点击的元素和位置,最后根据点击次数的多少,得到页面中的点击分布热力图。这一块的实现原理比较简单,只需要在埋点sdk中开启对所有元素对点击事件对监听即可,比较关键的一点是要计算出鼠标的点击x、y位置坐标,同时也可以把当前点击的元素名称或者class也一起上报,以便做更精细化的数据分析。
dom点击上报
dom点击上报就是通过在dom元素上添加指定属性来达到自动上报埋点数据的功能。具体来说就是在页面的dom元素,配置一个 tracker-key = 'xxx' 的属性,表示需要进行该元素的点击上报,适用于上报通用的埋点数据(没有自定义的埋点数据),但是又不需要热力图上报的程度。这种配置方式是为了节省了要主动调用上报方法的步骤,但是如果埋点中有自定义的数据字段,还是应该在代码中去调用sdk的埋点上报方法。实现的方式也很简单,通过对body上点击事件进行全局监听,当触发事件时,判断当前event的getAttribute('tracker-key')值是否存在,如果存在则说明需要上报埋点事件,调用埋点上报方法即可。
上报埋点方式
埋点上报的方式最常见的是通过img标签的形式,img标签发送埋点使用方便,且不受浏览器跨域影响,但是存在的一个问题就是url的长度会收到浏览器的限制,超过了长度限制,就会被自动截断,不同浏览器的大小限制不同,为了兼容长度限制最严格的IE浏览器,字符长度不能超过2083。
为了解决img上报的字符长度限制问题,可以使用浏览器自带的beacon请求来上报埋点,使用方式为:
navigator.sendBeacon(url, data);

这种方式的埋点上报使用的是post方法,因此数据长度不受限制,同时可将数据异步发送至服务端,且能够保证在页面卸载完成前发送请求,即埋点的上报不受页面意外卸载的影响,解决了ajax页面卸载会终止请求的问题。但是缺点也有两个:
1.存在浏览器的兼容性,主流的大部分浏览器都能支持,ie不支持。
2.需要服务端配置跨域
因此可以将这两种方式结合起来,封装成统一的方法来进行埋点的上报。优先使用img标签,当字符长度超过2083时,改用beacon请求,若浏览器不支持beacon请求,最好换成原生的ajax请求进行兜底。(不过如果不考虑ie浏览器的情况下,img上报的方式其实已经够用,是最适合的方式)
const reportTracker = function (url, data) { const reportData = https://www.it610.com/article/stringify(data); let urlLength = (url + (url.indexOf('?') < 0 ? '?' : '&') + reportData).length; if (urlLength < 2083) { imgReport(url, data); } else if (navigator.sendBeacon){ sendBeacon(url, data); } else { xmlHttpRequest(url, data); } }

关于通用参数的获取
这一部分想拿出来说一下的原因是因为,一开始获取设备参数时,都是自己写相应的方法,但是因为兼容性不全的原因,不支持某些设备。后面都换成了专门的开源包去处理这些参数,比如 platform 包专门处理当前设备的osType、浏览器引擎等;uuid包专门用来生成随时数。所以在开发的时候还是要用好社区的力量,能找到成熟的解决方案肯定比自己写要更快更好。
关键代码附录
【前端埋点sdk封装】本篇文章大概就说到这里,最后附上埋点sdk核心代码:
// tracker.js import extend from 'extend'; import { getEvent, getEventListenerMethod, getBoundingClientRect, getDomPath, getAppInfo, createUuid, reportTracker, createHistoryEvent } from './utils'; const defaultOptions = { useClass: false, // 是否用当前dom元素中的类名标识当前元素 appid: 'default', // 应用标识,用来区分埋点数据中的应用 uuid: '', // 设备标识,自动生成并存在浏览器中, extra: {}, // 用户自定义上传字段对象 enableTrackerKey: false, // 是否开启约定拥有属性值为'tracker-key'的dom的点击事件自动上报 enableHeatMapTracker: false, // 是否开启热力图自动上报 enableLoadTracker: false, // 是否开启页面加载自动上报,适合多页面应用的pv上报 enableHistoryTracker: false, // 是否开启页面history变化自动上报,适合单页面应用的history路由 enableHashTracker: false, // 是否开启页面hash变化自动上报,适合单页面应用的hash路由 requestUrl: 'http://localhost:3000' // 埋点请求后端接口 }; const MouseEventList = ['click', 'dblclick', 'contextmenu', 'mousedown', 'mouseup', 'mouseenter', 'mouseout', 'mouseover']; class Tracker { constructor(options) { this._isInstall = false; this._options = {}; this._init(options) }/** * 初始化 * @param {*} options 用户参数 */ _init(options = {}) { this._setConfig(options); this._setUuid(); this._installInnerTrack(); }/** * 用户参数合并 * @param {*} options 用户参数 */ _setConfig(options) { options = extend(true, {}, defaultOptions, options); this._options = options; }/** * 设置当前设备uuid标识 */ _setUuid() { const uuid = createUuid(); this._options.uuid = uuid; }/** * 设置当前用户标识 * @param {*} userId 用户标识 */ setUserId(userId) { this._options.userId = userId; }/** * 设置埋点上报额外数据 * @param {*} extraObj 需要加到埋点上报中的额外数据 */ setExtra(extraObj) { this._options.extra = extraObj; }/** * 约定拥有属性值为'tracker-key'的dom点击事件上报函数 */ _trackerKeyReport() { const that = this; const eventMethodObj = getEventListenerMethod(); const eventName = 'click' window[eventMethodObj.addMethod](eventMethodObj.prefix + eventName, function (event) { const eventFix = getEvent(event); const trackerValue = https://www.it610.com/article/eventFix.target.getAttribute('tracker-key'); if (trackerValue) { that.sendTracker('click', trackerValue, {}); } }, false) }/** * 通用事件处理函数 * @param {*} eventList 事件类型数组 * @param {*} trackKey 埋点key */ _captureEvents(eventList, trackKey) { const that = this; const eventMethodObj = getEventListenerMethod(); for (let i = 0, j = eventList.length; i < j; i++) { let eventName = eventList[i]; window[eventMethodObj.addMethod](eventMethodObj.prefix + eventName, function (event) { const eventFix = getEvent(event); if (!eventFix) { return; } if (MouseEventList.indexOf(eventName) > -1) { const domData = https://www.it610.com/article/that._getDomAndOffset(eventFix); that.sendTracker(eventFix.type, trackKey, domData); } else { that.sendTracker(eventFix.type, trackKey, {}); } }, false) } }/** * 获取触发事件的dom元素和位置信息 * @param {*} event 事件类型 * @returns */ _getDomAndOffset(event) { const domPath = getDomPath(event.target, this._options.useClass); const rect = getBoundingClientRect(event.target); if (rect.width == 0 || rect.height == 0) { return; } let t = document.documentElement || document.body.parentNode; const scrollX = (t && typeof t.scrollLeft =='number' ? t : document.body).scrollLeft; const scrollY = (t && typeof t.scrollTop == 'number' ? t : document.body).scrollTop; const pageX = event.pageX || event.clientX + scrollX; const pageY = event.pageY || event.clientY + scrollY; const data = https://www.it610.com/article/{ domPath: encodeURIComponent(domPath), offsetX: ((pageX - rect.left - scrollX) / rect.width).toFixed(6), offsetY: ((pageY - rect.top - scrollY) / rect.height).toFixed(6), }; return data; }/** * 埋点上报 * @param {*} eventType 事件类型 * @param {*} eventId事件key * @param {*} data 埋点数据 */ sendTracker(eventType, eventId, data = {}) { const defaultData = { userId: this._options.userId, appid: this._options.appid, uuid: this._options.uuid, eventType: eventType, eventId: eventId, ...getAppInfo(), ...this._options.extra, }; const sendData = extend(true, {}, defaultData, data); console.log('sendData', sendData); const requestUrl = this._options.requestUrl reportTracker(requestUrl, sendData); }/** * 装载sdk内部自动埋点 * @returns */ _installInnerTrack() { if (this._isInstall) { return this; } if (this._options.enableTrackerKey) { this._trackerKeyReport(); } // 热力图埋点 if (this._options.enableHeatMapTracker) { this._openInnerTrack(['click'], 'innerHeatMap'); } // 页面load埋点 if (this._options.enableLoadTracker) { this._openInnerTrack(['load'], 'innerPageLoad'); } // 页面history变化埋点 if (this._options.enableHistoryTracker) { // 首先监听页面第一次加载的load事件 this._openInnerTrack(['load'], 'innerPageLoad'); // 对浏览器history对象对方法进行改写,实现对单页面应用history路由变化的监听 history['pushState'] = createHistoryEvent('pushState'); history['replaceState'] = createHistoryEvent('replaceState'); this._openInnerTrack(['pushState'], 'innerHistoryChange'); this._openInnerTrack(['replaceState'], 'innerHistoryChange'); } // 页面hash变化埋点 if (this._options.enableHashTracker) { // 首先监听页面第一次加载的load事件 this._openInnerTrack(['load'], 'innerPageLoad'); // 同时监听hashchange事件 this._openInnerTrack(['hashchange'], 'innerHashChange'); }this._isInstall = true; return this; }/** * 开启内部埋点 * @param {*} event 监听事件类型 * @param {*} trackKey 埋点key * @returns */ _openInnerTrack(event, trackKey) { return this._captureEvents(event, trackKey); }}export default Tracker;

//utils.js import extend from 'extend'; import platform from 'platform'; import uuidv1 from 'uuid/dist/esm-browser/v1'; const getEvent = (event) => { event = event || window.event; if (!event) { return event; } if (!event.target) { event.target = event.srcElement; } if (!event.currentTarget) { event.currentTarget = event.srcElement; } return event; }const getEventListenerMethod = () => { let addMethod = 'addEventListener', removeMethod = 'removeEventListener', prefix = ''; if (!window.addEventListener) { addMethod = 'attachEvent'; removeMethod = 'detachEvent'; prefix = 'on'; } return { addMethod, removeMethod, prefix, } }const getBoundingClientRect = (element) => { const rect = element.getBoundingClientRect(); const width = rect.width || rect.right - rect.left; const heigth = rect.heigth || rect.bottom - rect.top; return extend({}, rect, { width, heigth, }); }const stringify = (obj) => { let params = []; for (let key in obj) { params.push(`${key}=${obj[key]}`); } return params.join('&'); }const getDomPath = (element, useClass = false) => { if (!(element instanceof HTMLElement)) { console.warn('input is not a HTML element!'); return ''; } let domPath = []; let elem = element; while (elem) { let domDesc = getDomDesc(elem, useClass); if (!domDesc) { break; } domPath.unshift(domDesc); if (querySelector(domPath.join('>')) === element || domDesc.indexOf('body') >= 0) { break; } domPath.shift(); const children = elem.parentNode.children; if (children.length > 1) { for (let i = 0; i < children.length; i++) { if (children[i] === elem) { domDesc += `:nth-child(${i + 1})`; break; } } } domPath.unshift(domDesc); if (querySelector(domPath.join('>')) === element) { break; } elem = elem.parentNode; } return domPath.join('>'); }const getDomDesc = (element, useClass = false) => { const domDesc = []; if (!element || !element.tagName) { return ''; } if (element.id) { return `#${element.id}`; } domDesc.push(element.tagName.toLowerCase()); if (useClass) { const className = element.className; if (className && typeof className === 'string') { const classes = className.split(/\s+/); domDesc.push(`.${classes.join('.')}`); } } if (element.name) { domDesc.push(`[name=${element.name}]`); } return domDesc.join(''); }const querySelector = function(queryString) { return document.getElementById(queryString) || document.getElementsByName(queryString)[0] || document.querySelector(queryString); }const getAppInfo = function() { let data = https://www.it610.com/article/{}; // title data.title = document.title; // url data.url = window.location.href; // eventTime data.eventTime = (new Date()).getTime(); // browserType data.browserType = platform.name; // browserVersion data.browserVersion = platform.version; // browserEngine data.browserEngine = platform.layout; // osType data.osType = platform.os.family; // osVersion data.osVersion = platform.os.version; // languages data.language = getBrowserLang(); return data; }const getBrowserLang = function() { var currentLang = navigator.language; if (!currentLang) { currentLang = navigator.browserLanguage; } return currentLang; }const createUuid = function() { const key ='VLAB_TRACKER_UUID'; let curUuid = localStorage.getItem(key); if (!curUuid) { curUuid = uuidv1(); localStorage.setItem(key, curUuid); } return curUuid }const reportTracker = function (url, data) { const reportData = https://www.it610.com/article/stringify(data); let urlLength = (url + (url.indexOf('?') < 0 ? '?' : '&') + reportData).length; if (urlLength < 2083) { imgReport(url, data); } else if (navigator.sendBeacon){ sendBeacon(url, data); } else { xmlHttpRequest(url, data); } }const imgReport = function (url, data) { const image = new Image(1, 1); image.onload = function() { image = null; }; image.src = https://www.it610.com/article/`${url}?${stringify(data)}`; }const sendBeacon = function (url, data) { //判断支不支持navigator.sendBeacon let headers = { type:'application/x-www-form-urlencoded' }; let blob = new Blob([JSON.stringify(data)], headers); navigator.sendBeacon(url, blob); }const xmlHttpRequest = function (url, data) { const client = new XMLHttpRequest(); client.open("POST", url, false); client.setRequestHeader("Content-Type", "application/json; charset=utf-8"); client.send(JSON.stringify(data)); }const createHistoryEvent = function(type) { var origin = history[type]; return function() { var res = origin.apply(this, arguments); var e = new Event(type); e.arguments = arguments; window.dispatchEvent(e); return res; }; }; export { getEvent, getEventListenerMethod, getBoundingClientRect, stringify, getDomPath, getDomDesc, querySelector, getAppInfo, getBrowserLang, createUuid, reportTracker, createHistoryEvent }

    推荐阅读