页面模块化停留时长上报及可视化

背景简介

为了更准确地了解用户行为,将用户行为的统计颗粒度细化到页面模块级别,以便生成更准确的用户画像。现有的埋点技术只能统计到页面的单次曝光和单功能化的点击事件,无法实现更精细的用户行为分析。因此,我们需要实现单模块化在用户访问路径内的停留时长和当前页面在用户侧的停留时长。这样可以更好地了解用户在页面上的行为,为用户提供更好的体验,同时也可以为业务决策提供更准确的数据支持。

模块化停留时长上报的实现

模块化停留时长上报的原理

在 Vue 项目中,您可以通过注册指令的方式,在需要上报停留的模块上进行配置。具体方法是,在需要使用停留上报的模块上添加指令,并填写相应的参数。当模块被添加指令后,它会在 IntersectionObserver 方法中被监听。IntersectionObserver 方法会监听每一个通过指令上报上来的 DOM 模块。

当模块进入可视区域时,会触发回调方法进行上报,而当模块离开可视区域时,会停止上报。通过这种方式,可以实现对模块停留时长的监测和上报,从而更好地了解用户行为和模块使用情况。但需要注意的是,如果在 App 内重复开启多个 WebView堆叠时,即使当前页面模块被其他页面遮挡,IntersectionObserver 方法仍然会认为该模块在可视区域内。因此,建议使用 App 提供的桥接方法来确认当前页面模块是否在可视区域内,以保证上报的准确性。

在上报过程中,我们建议使用 requestAnimationFrame 方法来优化定时上报,以保证上报的准确性,同时不会影响页面性能。requestAnimationFrame 方法可以在不影响页面性能的情况下,实现对上报的定时操作,从而更好地监测模块停留时长和用户行为,提高数据的准确性和有效性。

模块化停留时长上报的实现方式

注册Vue指令,初始化执行IntersectionObserver方法的API

javascript 复制代码
// vue 指令注册
const ObserverSDK = {
  mounted: function (el, binding) {
    createObserver(el, binding)
    document.addEventListener('scroll', () => {
      // 遮罩模块高度超过一屏 滚动时动态修改遮罩高度
      HEIGHT_TARGET_ELEMENT.length &&
        HEIGHT_TARGET_ELEMENT.forEach((key) => {
          const target = document.querySelector(`#${key}`)
          let targetParentNode = target?.parentNode?.offsetHeight ?? 0
          target.style['height'] = targetParentNode + 'px'
        })
    })
  },
  update: function (el, binding) {
    if (JSON.stringify(binding.value) === JSON.stringify(binding.oldValue)) {
      return
    }
    console.log('update', el)
    createObserver(el, binding)
  },
  unmounted: function (el) {
    observerIo.unobserve(el)
    console.log('unmounted', el)
  }
}

注册IntersectionObserver方法以及回调

javascript 复制代码
// 注册IntersectionObserver
const observerIo = new IntersectionObserver(observerCallback, {
  root: null,
  rootMargin: '0px',
  threshold: 0.1
})

// 兼容 无 requestAnimationFrame 方法替代方案
if (!window.requestAnimationFrame) {
  let lastTime = 0
  window.requestAnimationFrame = function (callback) {
    const currTime = new Date().getTime()
    const timeToCall = Math.max(0, 16.7 - (currTime - lastTime))
    const id = setTimeout(function () {
      callback(currTime + timeToCall)
    }, timeToCall)
    lastTime = currTime + timeToCall
    return id
  }
}

// 设置IntersectionObserver 回调方法
// ACTION_OBJ_LIST 模块上报action 缓存
function observerCallback(entries) {
  if (!entries?.length) {
    return
  }
  entries.forEach((entry) => {
    const item = entry.target['_ObserveSDKDirective']['value'] || {}
    if (缺少必传参数时return) {
      return
    }
    if (entry.isIntersecting) {
      // IS_VIEW_STATISTICS => true: 显示页面模块停留时长 false:模块停留上报
      if (IS_VIEW_STATISTICS) {
        handleIntersecting(entry.target, item)
      } else {
        !_animationId && moduleDurStatis()
      }

      ACTION_OBJ_LIST[item.action] = item
    } else {
      delete ACTION_OBJ_LIST[item.action]
      // 清除requestAnimationFrame定时器
      if (!Object.keys(ACTION_OBJ_LIST).length) {
        cancelAnimationFrame(_animationId)
        _animationId = null
      }
    }
  })
}

通过requestAnimationFrame方式进行定时上报

javascript 复制代码
// 通过requestAnimationFrame方式 => 模块统计上报方法
// intervalTime 默认为 2s 也可以动态的通过地址栏修改
function moduleDurStatis() {
  // app内通过桥的方式 确认页面是否隐藏
  if (isApp()) {
    "页面显示" => APP_TIME_PIECE_SWITCH = true
    "页面隐藏" => APP_TIME_PIECE_SWITCH = false
  }
  currentDate = new Date()
  if (Date.parse(currentDate) - Date.parse(prevDate) >= intervalTime * 1000) {
    prevDate = currentDate
    const list = Object.keys(ACTION_OBJ_LIST)
    if (APP_TIME_PIECE_SWITCH && list?.length) {
       console.log("处理埋点上报")
    }
  }
  _animationId = window.requestAnimationFrame(moduleDurStatis)
}

模块化停留时长可视化的实现

模块化停留时长可视化的方式

在模块化停留上报能力的基础上,我们可以通过URL特殊参数进行监听。当URL上存在特殊参数时,我们不会执行模块上报方法,而是调用停留时长可视化渲染方法,根据接口返回的模块action,将遮罩异步的渲染到页面上对应的模块。这样的优化可以提高用户体验,让页面更加流畅。

模块化停留时长可视化的实现方式

显示页面模块停留时长方法

javascript 复制代码
// 显示页面模块停留时长方法
async function handleIntersecting(el, item) {
  // 请求接口
  await featchGetModuleData(item)
  // 异步渲染遮罩
  setTimeout(() => {
    _visualizationModuleStat(el, item)
  }, 3000)
}
// 请求接口渲染模块时长
// batch_time => 查询时长 默认为1天 可从url获取
// reload => 数据做了缓存处理,reload默认开启缓存
// BASE_URL => 项目内设置env 
async function featchGetModuleData(item) {
  try {
    const { batch_time = 1, reload = false } = getQueryArgs()

    const sessionName = "缓存到session内的key"
    if (!sessionStorage.getItem(sessionName) || reload) {
      const params = '上送参数'
      const res = await _ajax({
        method: 'post',
        url: '后端接口',
        data: JSON.stringify(params)
      })
      console.log(JSON.parse(res))
      const { returncode, message, result } = JSON.parse(res)
      if (returncode == 0 && result?.length) {
        const objValue = {}
        result.forEach((val) => {
          objValue[val.moduleId] = val.duration
        })
        sessionStorage.setItem(sessionName, JSON.stringify(objValue))
      }
    }
  } catch (error) {
    console.log(error)
  }
}

// dom 结构挂载遮罩
function _visualizationModuleStat(targetElement, { 扩展参数 }) {
  const { batch_time = 1 } = getQueryArgs()

  const sessionName = '缓存到session内的key'
  const result = JSON.parse(sessionStorage.getItem(sessionName))
  if (!result) {
    return
  }
  const targetId =
    targetElement.tagName === 'IMG'
      ? document.querySelector(`#${actionId}`)
      : targetElement.querySelector(`#${actionId}`)

  console.log('targetId', targetElement)

  if (targetId) {
    return
  }

  if (!targetElement.style.position || targetElement.style.position == 'static') {
    targetElement.style.position = 'relative'
  }
  if (targetElement.offsetHeight >= window.innerHeight && overlay) {
    HEIGHT_TARGET_ELEMENT.push(actionId)
  }
  const newElement = document.createElement('div')
  const w = `${targetElement.offsetWidth}px`
  const h = overlay ? `${targetElement.offsetHeight}px` : '100px'
  const styles = window.getComputedStyle(targetElement)
  let top = targetElement.tagName === 'IMG' ? `${targetElement.getBoundingClientRect()?.top || 0}px` : 0
  const styleString = `position:absolute; width: ${w}; height: ${h}; top: ${top}; left: 0; transform: ${
    styles['transform']
  };z-index: ${Z_INDEX++}; color: #ffffff; line-height: 20px; font-size: 16px; background-color: rgb(0, 0, 0, 0.3); text-align: center; padding-top: 5px; box-sizing: border-box`
  newElement.setAttribute('style', styleString)
  newElement.id = actionId
  let t = result[actionId] ? result[actionId] : 0
  newElement.innerHTML = `${modulesName} - ${batch_time}天<br>平均停留时长:${t}s`

  // 判断 img 是否为 img 标签
  if (targetElement.tagName === 'IMG') {
    document.body.appendChild(newElement)
  } else {
    targetElement.appendChild(newElement)
  }
}

模块化停留时长上报及可视化的使用

模块化停留时长上报使用规则

Vue初始化后注册指令

javascript 复制代码
import { createApp } from 'vue'
import { ObserverSDK } from '@/utils/index'
const app = createApp(App)
app.directive('observer-sdk', ObserverSDK)

DOM元素上挂载指令

javascript 复制代码
// 指令
v-observer-sdk="setModuleObserverData('埋点标识', '动态模块')"
// 方法
const setModuleObserverData = () => {
  return "上报参数"
  }
}

模块化停留时长可视化使用规则

项目环境变量配置(请求接口)

ini 复制代码
// 测试环境
VITE_APP_PAGE_URL = "//测试环境域名"
// 线上环境
VITE_APP_PAGE_URL = "//线上环境域名"

页面地址配置参数:

URL:xxxx(项目地址)?是否展示可视化=true&查询时间=15&是否缓存数据=true

总结与反思

为了实现更精细的用户行为分析,我们可以利用IntersectionObserver和requestAnimationFrame等原生API来实现模块化停留上报能力。当URL上存在特殊参数时,我们可以通过调用停留时长可视化渲染方法,根据接口返回的模块action,将遮罩渲染到页面上对应的模块。这样可以提高用户体验,让页面更加流畅,并且为业务决策提供更准确的数据支持。同时,我们也需要注意用户隐私保护,确保数据采集的合法性和合理性。

在开发的过程中有以下几点反思:

  • 在整个脚本开发过程中没有完全的独立,脚本中还在引入外部包。
  • 目前没有做完整地兼容,暂时只支持Vue项目。并且只支持在之前APP中使用。
  • 通过本次开发中发现对IntersectionObserver以及requestAnimationFrame这种原生的API使用还是有一些陌生
  • 可视化通过URL安全问题,最好可以请求接口做鉴权
相关推荐
GIS程序媛—椰子42 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0011 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端1 小时前
Content Security Policy (CSP)
前端·javascript·面试
木舟10091 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安2 小时前
前端第二次作业
前端·css·css3
啦啦右一2 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落2 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt