在React中处理埋点公共参数

前端埋点数据收集及上报方案这篇文章中研究了前端项目的数据埋点方案,描述了埋点字段设计、数据收集和数据上报的一些思路,并给出了在Vue中通过自定义指令和全局Mixin实现声明式数据收集的方法。同时在项目中使用一段时间后,总结了一些可以优化的问题。

最近在一个React项目中,打算实现类似的工作,同时解决之前遗留的问题。

<!--more-->

1. 全局公共参数的问题

在第一个版本的实现中,由于同一个页面的部分事件需要声明公共参数,当时的解决方案是在vuex中通过logExtendlogExtra这两个共享配置项声明公共参数,

在需要设置公共参数的时候,需要手动调用设置公共参数

this.$store.commit('log/setLogExtra', {...})

然后再离开的时候手动清除掉公共参数

this.$store.commit('log/setLogExtend', null})

而所有埋点的地方使用的是全局上报方法trackLog

export function trackLog(page, eventType, eventValue, extend = {}, extra = {}) {
  const commonParams = getCommonLogParams() // 从store中获取获取公共参数,然后与每个上报事件的额外参数进行合并,形成最终的数据

  const data = {
    ...commonParams,
    url: window.location.href,
    page,
    eventType,
    eventValue,
    ...extend,
    extra: Object.keys(extra).length > 0 ? JSON.stringify(extra) : '' // 空的extra不上报
  }

  sendLog(data)
  sendCyaLog(data, extra)
}

通过共享state的方式来配置埋点公共参数并不是明智的选择

  • state是全局的,对齐进行set之后,上一次的公共参数会被覆盖,这就导致setLogExtra需要格外注意调用实际,如果存在延时上报或队列批量上报的情况,则会导致获取到错误的公共参数
  • 某些弹窗组件,是挂载在全局的body节点下面,并没有在当前路由组件树中,不一定能拿到对应的context获取公共参数

目前来看,需要一种更合理的管理公共参数的方式。

2. 基于页面维度来管理公共参数

在整体的埋点方案设计中,是按照页面维度对埋点进行分组的,而在业务中,同一个页面的埋点公共参数基本上也是一致的,比如页面query参数、路由path参数、页面id等等。

基于这个背景,按照页面来管理不同的公共参数应该是可行的,同时也可以解决之前把公共参数放在全局唯一的state中带来的覆盖等问题。

2.1. TrackPageTask属性

将页面的埋点功能进行抽象,通过一个TrackPageTask来隔离每个页面的埋点,包括公共参数、埋点方法等

class TrackPageTask {
  page: string = ''

  extra: object = {}

  extend: object = {}

  constructor(page:string) {
    this.page = page
  }

  getCommonParams() {
    const { extend, extra } = this
    return { extend, extra }
  }

  setCommonExtend(extend) {
    this.extend = extend
  }

  setCommonExtra(extra) {
    this.extra = extra
  }
}

通过实例属性extendextra来维护这个页面上所有事件的公共参数,这样,每个页面的埋点就是互相独立的,不会对其他页面参数造成影响

2.2. TrackPageTask接口

按照设想,在页面初始化时创建新的task实例,通过setCommonExtendsetCommonExtra设置公共参数,在上报埋点的时候就会自动携带这些公共参数了,如何实现呢?

同理,在上报的时候也使用task实例的上报方法,而不是调用之前全局的trackLog方法

class TrackPageTask {
  private mergeCommonParams(e1, e2) {
    const { page, extend, extra } = this
    return {
      extend: { page, ...extend, ...e1 },
      extra: { ...extra, ...e2 },
    }
  }

  track(eventType, eventValue, e1 = {}, e2 = {}) {
    const { extend, extra } = this.mergeCommonParams(e1, e2)
    return trackLog(eventType, eventValue, extend, extra)
  }

  trackPv(extend?, extra?) {
    this.track(EVENT_TYPE.pv, '', extend, extra)
  }

  trackExposure(key, extend?, extra?) {
    this.track(EVENT_TYPE.exposure, key, extend, extra)
  }

  trackClick(key, extend?, extra?) {
    this.track(EVENT_TYPE.click, key, extend, extra)
  }

  trackDuration(eventVal, extend?, extra?) {
    this.track(EVENT_TYPE.duration, eventVal, extend, extra)
  }
}

通过mergeCommonParams参数,可以将页面的公共参数也当前事件的参数进行合并,这样就达到了同一个页面所有埋点事件共享埋点参数的功能。

来看看使用方法

let task1 = new TrackPageTask('page1')

task1.setCommonExtra({x:1})

task1.trackClick('btn-1', {}, {y:2}) // 最后上报的extra是{x:1,y:2}
task1.trackClick('btn-2', {}, {z:3}) // 最后上报的extra是{x:1,z:3}

let task2 = new TrackPageTask('page2')
task1.trackClick('btn-3', {}, {y:2}) // 最后上报的extra是{y:2},跟task1无关

回头来看,TrackPageTask的设计十分简单,就是一个通过类封装来隔离每个页面的公共参数,解决之前全局公共参数、全局API方法导致公共参数无非被隔离的问题。

由于页面的所有埋点都需要通过task实例来上报,接下来就得考虑如何在页面上共享TrackPageTask实例

3. 同一页面下的组件共享埋点实例

我们可以简单地认为某个路由组件下面的所有子组件及后代组件都属于这个路由组件对应的页面。

在初始化路由组件的时候,我们就可以同时初始化task实例,那么如何在所有后代组件共享task实例呢?

最直观的方式就是将task实例从路由组件通过props一层层透传进去。当然,在实际操作中这样肯定是不行的,尤其是在React等可能会嵌套多层HOC的情况下。

跨层传递数据,最简单的方式就是context(在Vue中可以使用provideinject)。

3.1. context共享TrackPageTask

const defaultTask = new TrackPageTask('default')
const TrackPageContext = createContext<TrackPageTask>(defaultTask)

// 通过一个HOC包裹路由组件

interface trackPageConfig {
  name:string,
}

function pageWithTrack(Comp, { name }: trackPageConfig) {
  return (props) => {
    const task = new TrackPageTask(name)
    return <TrackPageContext.Provider value={task}>
      <Comp {...props}/>
    </TrackPageContext.Provider>
  }
}

然后在后代组件,就可以通过context获取task实例了

function useTrackContext() {
  const task = useContext(TrackPageContext)
  return { trackTask: task }
}

简单使用示例

const Demo = ()=>{
  const {trackTask} = useTrackContext()

  task1.setCommonExtra({x:1})

  const onClick = ()=>{
    trackTask.trackClick('btn-1') // 同时携带公共参数{x:1}
  }

  return (<button onClick={onClick}>click me</button>)
}

同时在pageWithTrack这个HOC中,我们可以把之前在Vue中通过mixin实现自动上报pv和停留时间的功能也实现了

interface trackPageConfig {
  name:string,
  pv:boolean, // 是否上报pv
  duration:boolean, // 是否上报停留时间
  async: boolean // 是否是异步上报
}
function pageWithTrack(Comp, { name, pv, duration } :trackPageConfig) {
  return (props) => {
    const task = new TrackPageTask(name)

    // 路由组件每次变化或更新都处理处理上报

    useEffect(() => {
      const start = +new Date()

      const reportPv = () => {
        if (!pv) return
        task.trackPv()
      }

      const reportDuration = () => {
        if (!duration) return
        const now = +new Date()
        task.trackDuration(now - start)
      }

      reportPv()

      window.addEventListener('unload', reportDuration)

      return () => {
        reportDuration()
        window.removeEventListener('unload', reportDuration)
      }
    })

    return <TrackPageContext.Provider value={task}>
      <Comp {...props}/>
    </TrackPageContext.Provider>
  }
}

对于异步上报pv的情况(比如需要先校验权限或者查询某些接口之后才上报pv),在之前的Vue实现中,需要使用mixin上的onReady方法手动通知页面ready并上报pv,现在也可以把这个功能放在TrackPageTask中很方便地实现

class TrackPageTask {
  // ... 其他接口

  isPageReady: boolean = false

  pageReady(extend?, extra?) {
    if (this.isPageReady) return
    this.isPageReady = true
    this.trackPv(extend, extra)
  }
}

在路由组件中,当页面准备就绪后,可以调用task.pageReady手动上报pv,比起mixin来说,会减少很多侵入代码,逻辑也清晰不少。

3.2. 注意事项

上面这种基于Context注入task的方式,需要保证调用useTrackContext都在路由组件树上(实际上是得在TrackPageContext.Provider)下。

因此对于全局弹窗等组件,如果是类似于下面这种实现方式

const Content = ()=>{
  return (<div>this is dialog content</div>)
}
Dialog.show(Content) 

如果Dialog.show方法的实现是将content插入到非当前组件树下,则在Content组件中使用useTrackContext则无法获取从路由组件注入的task实例,导致上报出现异常。

对于这种情况,可以通过Hooks等方式将vnode放在Context组件树上,从而实现context的注入

下面是一种弹窗组件的简易实现

const useDialog = (content)=>{
  const [visible, setVisible] = useState<boolean>(false)
  const dialog = visible ? <div>{content}</div> : null

  return {
    dialog,
    showDialog(){
      setVisible(true)
    },
    hideDialog(){
      setVisible(false)
    }
  }
}

const useRuleDialog = ()=>{
  // 在`TrackPageContext.Provider`下的后代组件都可以获取得到trackTask
  const {trackTask} = useTrackContext()

  const onClick = ()=>{
    trackTask.trackClick('btn-1')
  }

  const ruleContent = (
    <div>
      <button onClick={onClick}>click me</button>
    </div>
  )

  const {dialog, showDialog} = useDialog(ruleContent)

  return {
    dialog,
    showDialog
  }
}

const Demo = ()=>{

  const {dialog, showDialog} = useRuleDialog(ruleContent)

  return (<div>
    <button onClick={showDialog}>show dialog</button>
    {dialog}
  </div>)
}

4. 路由组件配置

既然实现了pageWithTrack这个HOC,那么,只需要在配置路由时将路由组件装饰一下就可以了

const createTrackConfig = (name, pv, duration, async) => ({
  name, pv, duration, async,
})

type Route = {
    path: string,
    exact?: boolean,
    meta?: RouteMeta,
    name?: string,
    component: any
}
const list: Array<Route> = [
  {
    path: '/test',
    exact: true,
    name: 'test',
    component: Test,
    meta: {
      log: createTrackConfig('test', true, false, false),
    },
  },
]

export const routes = list.map((route) => {
  // @ts-ignore
  const { meta } = route
  // 根据meta添加一些装饰器
  if (meta) {
    if (meta.log) {
      route.component = pageWithTrack(route.component, meta.log)
    }

    // 其他参数,如需要登录鉴权等,可以放在这里一并处理
    if (meta.requiresAuth) {
      route.component = pageWitchAuth(route.component)
    }
  }

  return route
})

然后就可以使用诸如react-router-config等工具库将routes配置项更新到组件树中了。这一部分跟之前在Vue Router中的配置基本一致,通过路由配置声明埋点的基本参数,如pageName、上报类型等。

5. 小结

本文在之前前端埋点数据收集及上报方案这篇文章的基础上,优化了处理公共参数的逻辑,并实现了在React中借助contetxt注入埋点任务实例的方法。

现在回头去看之前在Vue中通过mixin实现声明埋点的设计,确实存在一些问题。不得不说,React的灵活性可以带来不少有趣的实现方式(挺长一段时间没写React了),等后面在项目中使用一段时间这种方式的埋点再看看。