实现一个H5埋点上报库
2023-09-02 本文已影响0人
vincent_z
在现代Web应用程序中,了解用户行为和性能数据是至关重要的。本文将介绍如何创建一个基础的H5埋点上报库,并梳理其关键流程。
目标
- 创建一个JavaScript库,用于在H5应用程序中跟踪和上报事件。
- 支持页面加载、页面显示、页面隐藏、页面卸载、点击、接口请求、接口成功返回和接口请求失败的事件跟踪。
- 能够处理页面导航,并记录页面切换事件。
- 提供错误处理功能,以便捕获JavaScript错误并将其报告给服务器。
- 实现点击事件跟踪,记录用户与特定页面元素的交互。
- 详细记录9种日志信息,包括事件类型、事件名称、事件参数、用户标识、页面信息、设备信息、时间戳、错误信息和自定义日志。
实现关键流程
1. 初始化
库的初始化是首要任务。我们创建一个全局配置对象,包括日志版本、环境、是否开启调试模式以及上报请求地址等信息。
var _config = {
weblogVersion: '1.0.9',
env: 'dev',
debug: false,
reportUrl: '', // 上报请求地址
// 其他配置项
};
2. 跟踪事件
库需要能够跟踪各种事件,包括页面加载、页面显示、页面隐藏、页面卸载、点击、接口请求、接口成功返回和接口请求失败等事件。我们使用reportEvent
方法来触发事件并上报相应数据。
var _reportEvent = {
reportEvent: function (_event, _name, _params, _cbSuccess, _cbFail) {
// 构建事件数据
var _data = _buildData(_event, _name, _params, _cbSuccess, _cbFail);
if (_data && _data.body) {
// 发送数据到服务器
_request(_data, _cbSuccess);
}
},
// 其他事件跟踪方法
};
3. 页面导航
库需要处理页面导航,包括进入新页面和离开当前页面。我们使用enterPage
和leavePage
方法来记录页面切换事件。
_reportEvent.enterPage('newPageUrl', { param1: 'value1', param2: 'value2' });
_reportEvent.leavePage('previousPageUrl', { param3: 'value3' });
4. 错误处理
为了捕获JavaScript错误并将其报告给服务器,我们监听window
对象上的error
事件,并记录错误的详细信息。
window.addEventListener('error', function (_err) {
// 处理并上报错误信息
});
5. 点击事件跟踪
点击事件跟踪允许我们记录用户与页面元素的交互。我们通过检查元素的data-daid
属性来确定是否需要记录点击事件。
var _daid = _findParentWithDaid(e && e.srcElement);
if (_daid) {
_reportEvent.reportEvent('click', 'logReportEvent', {
elemId: _daid,
touch: _touch,
});
}
6. 接口请求和响应
我们也需要跟踪接口请求和响应的事件。当应用程序发出HTTP请求时,我们可以记录请求事件,并在请求成功或失败后相应记录事件。
// 发起接口请求
_reportEvent.reportEvent('REQ', 'apiRequest', { url: 'apiUrl', method: 'GET' });
// 请求成功返回
_reportEvent.reportEvent('REQ_RES_SUCCESS', 'apiRequestSuccess', { url: 'apiUrl' });
// 请求失败
_reportEvent.reportEvent('REQ_RES_FAIL', 'apiRequestFail', { url: 'apiUrl', error: 'errorDetails' });
示例代码片段
以下是关键代码片段,展示了上述关键流程的实现:
var reportEvent = (function () {
// 全局配置信息
var _config = {
weblogVersion: '1.0.9',
env: 'dev',
debug: false,
// 日志上报请求地址
reportUrl: '',
// 初始化时外部传入参数
options: {},
events: ['onLoad', 'onShow', 'onHide', 'onUnload', 'click', 'custom'],
}
// 允许解析为json_tag的事件列表
// 默认日志标签是weblog, 这种情况elk不解析extend
// 需要解析extend的地方可以指定为json_tag
var _jsonTag = [
{ event: 'onLoad', msg: 'enterApp' },
{ event: 'onLoad', msg: 'enterPage' },
{ event: 'onShow', msg: 'enterPage' },
{ event: 'onHide', msg: 'leavePage' },
{ event: 'onUnload', msg: 'leavePage' },
{ event: 'custom', msg: 'REQ_REQ' },
{ event: 'custom', msg: 'REQ_RES_SUCCESS' },
{ event: 'custom', msg: 'REQ_RES_FAIL' },
{ event: 'custom', msg: 'WS_STAT_ANALYSIY_H5' },
]
// 页面跳转记录
var _pageHis = {
// 上个页面
from: {
url: '',
query: '',
time: Date.now(),
},
// 当前页
cur: {
url: '',
query: '',
time: Date.now(),
},
// 下个页面
to: {
url: '',
query: '',
time: Date.now(),
},
}
// ajax实例封装
var _ajax = function (_url, _data, _cbSuccess, _cbFail) {
if (_url && _data) {
var _xhr = new XMLHttpRequest()
_xhr.open('POST', _url)
_xhr.setRequestHeader('Content-Type', 'application/json')
_xhr.onload = function () {
if (_xhr.status == 200 && _xhr.responseText) {
_cbSuccess && _cbSuccess(JSON.parse(_xhr.responseText))
} else if (_xhr.status != 200) {
_cbFail && _cbFail(_xhr.responseText)
}
}
_xhr.send(JSON.stringify(_data))
}
}
// 页面关闭时, ajax可能发不出去, 这个新的API可以解决这个问题, 但兼容性不好
// 另外此API有长度限制, 太长的话会发不出去, 所以这个API仅用来发送页面关闭事件
var _sendBeacon = function (_data) {
// 优先使用sendBeacon, 如果不支持的话就回退到ajax
// 同步ajax在很多设备上会被禁用, 反而导致更大的丢失, 所以用异步ajax
if (navigator && navigator.sendBeacon) {
if (_data && _data.body) {
// 记录使用sendBeacon的数据
if (_data.body.items && _data.body.items[0] && _data.body.items[0].extend) {
_data.body.items[0].extend.sendBeacon = 1
}
navigator.sendBeacon(_config.reportUrl, JSON.stringify(_data))
}
} else {
_ajax(_config.reportUrl, _data)
}
}
// 发送请求
var _request = function (_data, _cbSuccess, _cbFail) {
if (_config.options.sendBeacon) {
_sendBeacon(_data)
} else {
_ajax(
_config.reportUrl,
_data,
function (_res) {
if (_cbSuccess && typeof _cbSuccess === 'function') {
_cbSuccess(_res)
}
},
function (_err) {
if (_cbFail && typeof _cbFail === 'function') {
_cbFail(_err)
}
}
)
}
}
// _event: enterPage, leavePage必填
// _url, _query: 当前页面的url和query, 可缺省, 默认从网址中获取
// _toUrl, _toQuery: 目标页面的url和query, enterPage不需要提供, leavePage应该提供, 缺失不会报错但应该提供
// query是字符串
var _updatePageHis = function (_option) {
if (_option && _option.event) {
var _event = _option.event
var _query = _option.query || location.search || ''
_query = _query.replace('?', '')
var _url = _option.url || _pageHis.cur.url || location.origin + location.pathname
var _toUrl = _option.toUrl || ''
var _toQuery = _option.toQuery || ''
if (_event == 'enterPage') {
// 还没更新才执行更新, 因为onload和onshow触发都需要做这个事
if (_url + _query != _pageHis.cur.url + _query) {
// 这里的from, to描述上一个页面的行为
_pageHis.from = JSON.parse(JSON.stringify(_pageHis.cur)) || {}
_pageHis.cur = {
url: _url,
query: _query,
time: Date.now(),
}
_pageHis.to = JSON.parse(JSON.stringify(_pageHis.cur))
}
} else if (_event == 'leavePage') {
// if (_url + _query != _pageHis.from.url + _pageHis.from.query) {
if (_toUrl + _query != _pageHis.cur.url + _pageHis.cur.query) {
// 这里的from, to描述当前页面的行为
_pageHis.from = JSON.parse(JSON.stringify(_pageHis.cur))
_pageHis.to = {
url: _toUrl,
query: _toQuery,
time: Date.now(),
}
if (!_toUrl) {
console.warn('目标地址不应该缺失')
}
}
}
} else {
console.error('updatePageHis参数错误', _option)
}
}
// 获取页面停留时间, 一般是在onUnload或者onHide中调用
var _getPageStayTime = function () {
// 计算页面停留时间
var _stayTime = 0
var _params = {}
if (_pageHis && _pageHis.cur && _pageHis.cur.time) {
_stayTime = Date.now() - _pageHis.cur.time
_params = { pageStayTimeMS: _stayTime }
}
return _params
}
var _index = 0
// 寻找最近的有daid的父节点
var _findParentWithDaid = function (_elem) {
_index++
var _daid = ''
if (_elem) {
// 找到了直接把daid返回
if (_elem.dataset && _elem.dataset.daid) {
_daid = _elem.dataset.daid
}
// 没找到就遍历父节点
else {
// 有父节点就遍历, 没有就直接返回
if (_elem.parentNode) {
var _tmp = _findParentWithDaid(_elem.parentNode)
if (_tmp) {
_daid = _tmp
}
}
}
}
return _daid
}
// 错误记录
var errorInfo = []
// 点击事件上报
var _clickReport = function (e) {
// 点击数据
var _touch = {
// 相对于屏幕位置
clientX: Math.round(e.clientX / _config.scale),
clientY: Math.round(e.clientY / _config.scale),
// 相对于文档位置, px方案这个值不准确, 待解决
pageX: Math.round(e.pageX / _config.scale),
pageY: Math.round(e.pageY / _config.scale),
// 屏幕缩放比例, scale一般是等于0.5, 但是这里直接换算好了, 所以给1
r: 1,
}
// 有设置了daid才上报
var _daid = _findParentWithDaid(e && e.srcElement)
if (_daid) {
console.log('report click event', _daid)
_reportEvent.reportEvent('click', 'logReportEvent', {
elemId: _daid,
touch: _touch,
})
}
}
// 初始化点击事件上报
var _initClickReport = function () {
// 监听页面的所有click事件
// 互斥锁, 避免tap和click同时触发, tap优先
var _tapState = false
_$('body')[0].addEventListener('tap', function (e) {
console.log('tap', e)
_tapState = true
_clickReport(e)
})
_$('body')[0].addEventListener('click', function (e) {
console.log('click', e)
// tap已经触发了就不再触发click
if (!_tapState) {
_clickReport(e)
}
})
}
// 页面关闭事件需要特殊处理, 因为页面关闭有可能让ajax发送失败
// beforeunload, unload, pagehide做的是相同的事, 只处理一次即可
var _unloadFlag = false
var _unloadReport = function (_name) {
if (!_unloadFlag) {
_unloadFlag = true
var _pageStayTime = _getPageStayTime() || {}
var _data = _buildData('onUnload', 'leavePage', {
trigger: _name,
url: location.href,
options: _config.options,
ua: navigator.userAgent,
pageStayTimeMS: _pageStayTime.pageStayTimeMS || 0,
})
if (_data && _data.body) {
_sendBeacon(_data)
}
}
}
// 初始化全部事件
var _initEvent = function () {
_initClickReport()
// addEventListener这种方式注册事件允许同时有多个回调, 这样就不会影响页面本身的事件
window.addEventListener('error', function (_err) {
console.log('error', _err)
!_err && (_err = {})
var _fileName = _err.filename || ''
var _lineNum = (_err.lineno || '') + ':' + (_err.colno || '')
var _msg = (_err.error && _err.error.message) || ''
var _stack = (_err.error && _err.error.stack) || ''
var _reg = new RegExp(_fileName, 'g')
var _errInfo = {
fileName: _fileName,
lineNum: _lineNum,
msg: _msg,
stack: _stack.replace(_reg, ''),
}
_reportEvent.reportEvent('custom', 'onError', {
errInfo: _errInfo,
url: location.href,
options: _config.options,
ua: navigator.userAgent,
})
})
// beforeunload, unload, pagehide做的是相同的事, 为了提高上报成功率, 所以多做冗余
window.addEventListener('beforeunload', function (event) {
_unloadReport('beforeunload')
})
window.addEventListener('unload', function (event) {
_unloadReport('unload')
})
window.addEventListener('pagehide', function (event) {
_unloadReport('pagehide')
})
}
// 监控灰度js资源错误
var _reportJsError = function () {
var _splitHref = (window.location.href.split('/app')[1] || '').split('/')
if (!_splitHref[0] || !_splitHref[1]) return
var _grayVersion = _cookies.get('app' + _splitHref[0] + '_' + _splitHref[1] + '_gray_version')
_grayVersion &&
_reportEvent.reportEvent('custom', 'reportJsError', {
errorInfo,
ua: navigator.userAgent,
random: _cookies.get('app' + _splitHref[0] + '_' + _splitHref[1] + '_gray_random') | 0,
version: _grayVersion,
})
}
// 格式化数据
var _buildData = function (_event, _name, _params, _cbSuccess, _cbFail) {
if (!_config.ready) {
console.error('需要先调用reportEvent.init方法初始化')
return
}
_updateSessionId()
// _params必须是对象
_params = _params || {}
if (typeof _params !== 'object' || _params.length >= 0) {
console.error('param必须是对象', _params)
return
}
// evnet必须是枚举类型
var _validFlag = false
for (var i = 0; i < _config.events.length; i++) {
if (_config.events[i] == _event) {
_validFlag = true
break
}
}
if (!(_event && typeof _event === 'string' && _validFlag)) {
console.error('事件类型必须是枚举', _config.events)
return
}
// 事件名不能为空
if (!(_name && typeof _name === 'string')) {
console.error('事件名必须是字符串', _name)
return
}
// 如果是click事件一定要有elemId
var _elemId = ''
if (_event == 'click') {
if (!_params.elemId) {
console.error('click事件必须提供elemId', _params.elemId)
return
} else {
_elemId = _params.elemId || ''
delete _params.elemId
}
}
// 如果custom的参数中有elemId也把它提取到头部elemId中
if (_event == 'custom' && _params.elemId) {
_elemId = _params.elemId || ''
delete _params.elemId
}
// 默认日志标签是weblog, 这种情况elk不解析extend
// 需要解析extend的地方可以指定为json_tag
// weblog: 默认标签, json_tag: weblog需要解析json, visible: 可视化回溯专用
var _tag = ''
for (var i = 0; i < _jsonTag.length; i++) {
if (_jsonTag[i].event === _event && _jsonTag[i].msg === _name) {
_tag = 'json_tag'
break
}
}
// 可视化回溯日志固定用visible
if (_name.indexOf('VISIBLE_TRACEBACK_') >= 0) {
_tag = 'visible'
}
var _query = _parseQuery(_pageHis.cur.query) || {}
var _item = {
// 可回溯日志单独存一份, 不影响sequenceId
sequenceID: _tag === 'visible' ? _config.sequenceId : ++_config.sequenceId,
// productCode: '',
// 当前页面的url与query
pageID: _pageHis.cur.url || '',
query: _pageHis.cur.query || '',
event: _event || '',
message: _name || '',
elementID: _elemId,
fromURL: _pageHis.from.url || '',
fromQuery: _pageHis.from.query || '',
toURL: _pageHis.to.url || '',
toQuery: _pageHis.to.query || '',
h5URL: '',
h5Version: '',
// 外部渠道号
wtag: _config.wtagid || '',
// 内部渠道号
channel: _config.channel || '',
extend: {
tag: _tag,
utc: Date.now(),
activeId: (_config.options && _config.options.activeId) || '',
sequenceId0: _config.sequenceId0,
options: _config.options,
params: _params,
weblogVersion: _config.weblogVersion,
},
}
// onLoad事件多上报一个document.referrer
if (_event == 'onLoad') {
_item && _item.extend && (_item.extend.referrer = document.referrer || '')
}
// click事件不需要上报from, to参数
if (_event == 'click') {
_item.fromURL = ''
_item.fromQuery = ''
_item.toURL = ''
_item.toQuery = ''
}
var _body = {
reportTime: _utcToYMD(Date.now()) + new Date().getMilliseconds(),
source: _config.source[1],
scene: _config.scene,
ua: '',
deviceId: _getDeviceId(),
items: [_item],
}
var _rid = ''
// 最终上报数据
var _data = {
requestId: _rid,
// 非微信: h5-normal, 公众号: 默认h5-gzh, 开发者可在初始化时配置覆盖, 小程序: 真实appid
appId: _config.options.appId || 'h5-normal',
// h5没有token, 全用无登录态模式
cmd: 'White',
sessionId: _config.sessionId,
userId: _config.options.openId || '',
token: location.host || '',
version: _config.options.version || '',
body: _body,
}
return _data
}
// 初始化只执行一次
var _initFlag = false
var _reportEvent = {
init: function (_options) {
if (_initFlag) {
return
}
_initFlag = true
if (_options) {
_config.debug = _options.debug || false
// 改成在加载时就更新, 不用等到init, 否则在这之前的env会不准确
// _config.env = _getEnv();
_config.reportUrl = _getReportUrl()
// 如果是prd则不打印日志
if (_config.env == 'prd') {
console.log = function () {}
console.warn = function () {}
console.error = function () {}
}
console.log('-----init-----', _options)
if (_options.h5Type && _options.version) {
// 小程序传过来的参数
// 把query中的这些值存下来 appId, wtagid, channel, openId, sessionId, sequenceId
var _query = _parseQuery()
_config.appId = _query.appId || ''
_config.activeId = _query.activeId || ''
_config.wtagid = _query.wtagid || ''
_config.scene = _query.scene || ''
_config.channel = _query.channel || ''
_config.openId = _query.openId || ''
_config.sessionId = _query.sessionId || ''
_config.sequenceId0 = _query.sequenceId || 0
// 非微信: h5-normal, 公众号: 默认h5-gzh, 开发者可在初始化时配置覆盖, 小程序: 真实appid
// 有传值进来直接用, 没有的话判断是否微信环境
if (!_config.appId) {
if (_ifWeixn()) {
_config.appId = 'h5-gzh'
} else {
_config.appId = 'h5-normal'
}
}
// 初始化配置
_config.options.appId = _options.appId || _config.appId || ''
_config.options.activeId = _options.activeId || _config.activeId || ''
_config.options.openId = _options.openId || _config.openId || ''
_config.options.h5Type = _options.h5Type
_config.options.version = _options.version
_config.options.spa = _options.spa || false
_config.options.debug = _options.debug || false
// 使用sendBeacon在大数据量时可能导致数据丢失, 所以把sendBeacon关掉, 只在页面关闭的几个事件上使用
// (不同浏览器不一样, chrome大概是32k)
// _config.options.sendBeacon = _options.sendBeacon || false;
_config.options.sendBeacon = false
_config.scale = window.innerWidth / 750
// 必须调用了init方法才可以使用日志上报
_config.ready = true
_updatePageHis({
event: 'enterPage',
})
// 如果是SPA就让开发者自己控制上报页面的onshow和onhide事件
if (!_config.options.spa) {
_reportEvent.enterPage()
}
// 初始化上报一次ua及其他选项
_reportEvent.reportEvent('onLoad', 'enterApp', {
url: location.href,
options: _config.options,
ua: navigator.userAgent,
})
// 监测js错误上报
_reportJsError()
// 监听事件
_initEvent()
} else {
console.error('必须指定h5类型与版本号')
}
} else {
console.log('init必须指定参数')
}
},
// 手动触发页面事件, 用于SPA页面
// _url, _query: 当前页面的参数, 可缺省
enterPage: function (_url, _query) {
_query = _stringifyQuery(_query)
_updatePageHis({
event: 'enterPage',
url: _url || '',
query: _query || '',
})
_reportEvent.reportEvent('onShow', 'enterPage')
},
// _toUrl, _toQuery: 目标页面的参数, 缺失不报错, 但不应该缺失
leavePage: function (_toUrl, _toQuery) {
_toQuery = _stringifyQuery(_toQuery)
_updatePageHis({
event: 'leavePage',
toUrl: _toUrl || '',
toQuery: _toQuery || '',
})
_reportEvent.reportEvent('onHide', 'leavePage', _getPageStayTime())
},
// 用户行为数据上报
// event: 事件类型, 枚举: onLoad\onShow\onHide\onUnload\click\custom
// name: 事件名
// params: 额外参数
reportEvent: function (_event, _name, _params, _cbSuccess, _cbFail) {
var _data = _buildData(_event, _name, _params, _cbSuccess, _cbFail)
if (_data && _data.body) {
// 队列有可能在页面关闭时导致更大的数据丢失, 所以h5上暂时不用队列, 直接上报
_request(_data, _cbSuccess)
}
}
}
return _reportEvent
})()
结论
通过创建一个H5埋点上报库,能够轻松跟踪用户行为、记录页面导航、捕获错误以及详细记录各种事件和日志信息,从而更好地理解应用程序的性能和用户体验。