前端埋点sdk的方案十分红熟,以前用的都是公司内部统一的埋点产品,从前端埋点和数据上报后的可视化查询全链路打通。可是在最近的一个私有化项目中就遇到了问题,由于服务都是在客户本身申请的服务器上的,须要将埋点数据存放到本身的数据库中,同时前端埋点的功能简洁,不须要太多花里胡哨的东西。公司内部的埋点产品不适用,外部一些十分红熟的埋点产品又显得太臃肿,所以着手本身在开源包的基础上封了一个简单的埋点sdk,简单聊聊其中的一些功能和解决方式。前端
对于产品来讲,埋点上首要关心的是页面的pv、uv,其次是一些重要操做(以点击事件为主)的频率,针对某些曝光量高的页面,可能也会关注页面的热力图效果。知足这些关键功能的基础上,同时把一些通用的用户环境参数(设备参数、时间参数、地区参数)携带上来,发送请求到指定的后端服务接口,这就基本上知足了一个埋点skd的功能。vue
而我此次封装的这个sdk,大概就具有了如下一些功能:react
1.页面加载完成自动上报pv、uv
2.支持用户手动上报埋点
3.上报时默认携带时间、设备等通用参数
4.支持用户自定义埋点参数上报
5.支持用户标识设置
6.支持自动开始热力图埋点(页面中的任意点击会自动上报)
7.支持dom元素配置化的点击事件上报
8.支持用户自定义埋点上报接口配置ajax
打包后的埋点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'})
了解了功能和用法以后,下面具体说说功能中的一些具体设计思路和实现方案json
埋点字段指的是埋点请求上报时须要携带的参数,也是最终对埋点数据进行分析时要用到的字段,一般包括业务字段和通用字段两部分,根据具体需求进行设计。业务字段倾向于规范和简洁,而通用字段倾向于完整和实用。并非上报越多字段越好,不管是对前端请求自己,仍是后端数据入库都是一种负担。我这边针对需求设计的埋点字段以下:后端
字段 | 含义 |
---|---|
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的统计根据业务方需求有两种方式,第1种是彻底由业务方本身来控制,在页面加载或变化的时候调用通用埋点方法来上报。第2种是经过初始化配置开启自动pv统计,由sdk来完成这一部分的埋点上报。第1种方式很是好理解,就不具体展开来,下面具体说一些sdk自动埋点统计的实现原理:跨域
对于多页面应用,每次进一个页面就是一次pv访问,因此配置了 addEventListener = true 以后,sdk内部会对浏览器的load事件进行监听,当页面load后进行埋点上报,因此本质上是对浏览器load事件的监听和处理。数组
对于单页面应用来讲,只有第一次加载页面才会触发load事件,后续路由的变化都不会触发。所以除了监听load事件外,还须要根据路由的变化监听对应的事件,单页面应用有两种路由模式:hash模式和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模式下路由变化的埋点上报。
埋点对pv的支持是必不可少的,sdk会提供了一个设置用户uid的方法setUserId暴露给业务使用,当业务平台获取到登陆用户的信息后,调用该方法,则会在后续的埋点请求中都带上uid,最后在埋点分析的时候以该字段进行uv的统计。可是这样的uv统计是不许确的,由于忽略了用户未登陆的状况,统计出来的uv值是小于实际的,所以须要在用户未登陆的状况下也给一个区分标识。这种标识常见的有如下几种方式:
这几种方式各自存在着本身的一些弊端,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元素,配置一个 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 = 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核心代码:
// 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 = 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 = 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 = { 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 = {}; // 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 = 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 = `${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 }