前端埋點sdk封裝

引言

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

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 = 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
}
相關文章
相關標籤/搜索