关于前端:前端埋点sdk封装

60次阅读

共计 13544 个字符,预计需要花费 34 分钟才能阅读完成。

引言

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

正文完
 0