美文网首页
实现一个H5埋点上报库

实现一个H5埋点上报库

作者: vincent_z | 来源:发表于2023-09-02 23:41 被阅读0次

在现代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. 页面导航

库需要处理页面导航,包括进入新页面和离开当前页面。我们使用enterPageleavePage方法来记录页面切换事件。

_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埋点上报库,能够轻松跟踪用户行为、记录页面导航、捕获错误以及详细记录各种事件和日志信息,从而更好地理解应用程序的性能和用户体验。

相关文章

  • 前端埋点上报

    本文所说的埋点上报,只包含两种:点击上报(click)、曝光上报(show)。 整体思路: 点击上报: 使用 wi...

  • Flutter 监听页面跳转实现埋点

    Flutter中埋点的实现 页面切换的埋点实现 首先需要实现一个NavigatorObserver的子类, 并在该...

  • 上报用户行为埋点日志

    一、上报流程 二、技术应用 支持网络请求上报的压缩机制支持接口请求的版本控制,如根据App版本控制不同版本的配置获...

  • 2022-05-31

    n snumberformat: nslocal打包后出的问题,埋点,或者bugly上报

  • 数据平台笔记

    数据生产:接入流程、上报地址API对接、埋点规范、埋点内容、数据测试、业务DB 数据采集:Flume日志, Kaf...

  • Android埋点技术总结

    1.埋点技术的分类 1.1 代码埋点:代码埋点是指在某个事件发生时调用数据发送接口上报数据。例如开发人员按照产品/...

  • 数据埋点(浅谈埋点方式与上报收集)

    由于工作安排原因,有幸二次接触产品运营的埋点任务。二次埋点发现自身对埋点机理未彻底弄清、明晰,因此整理了部分工作中...

  • GIF 方式实现埋点数据上报

    优点:1.gif图片格式体积小, 可使用1px*1px的空白gif图片2.图片请求方式不会出现跨域问题

  • **JS**实现监控微信小程序

    原理:通过劫持原始方法,获取需要上报的数据,最后再执行原始方法,这样就能实现无痕埋点。使用模块化工具打包自己开发的...

  • web前端埋点及数据上报

    一、简介 前端埋点即在产品客户端获取用户行为和使用情况的一种监控方式。通过埋点可以获取到用户行为数据,借助这些数据...

网友评论

      本文标题:实现一个H5埋点上报库

      本文链接:https://www.haomeiwen.com/subject/xuuxvdtx.html