31

引言

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

款冬
1.5k 声望42 粉丝

前端小小弄潮儿~