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