引言

前端埋点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 = 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.jsimport 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.jsimport 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}