前端埋點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 }