前端组件化埋点方案与实现

背景

埋点,是收集产品的数据的一种方式,其目的是上报相关行为数据(PV/UV/时长/曝光/点击等),由相关人员以此分析用户的使用习惯,助力产品不断迭代和优化。对于开发来说,通常不仅仅需要完成基础的业务需求,还需要完成埋点需求,所以,追求的是简单快捷的埋点工作。而一个完整的埋点体系由以下三个部分构成:

目前,前端埋点存在的痛点一般是:

  1. 埋点字段的手动拼接,存在出错风险;
  2. 复杂场景的曝光埋点实现繁琐:分页列表、虚拟列表等;

  3. 埋点代码的侵入性:尤其是曝光代码导致逻辑复用困难。

埋点类型一般有:

市场上常见的埋点方案:

  1. 全埋点(无埋点):由前端自动采集全部事件并上报,前端也就没有埋点成本,由数据分析平台或后端过滤有用数据,优点是数据全面,缺点是数据量大,噪声数据多。
  2. 可视化埋点:由可视化工具进行配置采集指定元素——查找 dom 并绑定事件,优点是简单,缺点是准确性较低,针对性和自定义埋点能力较弱。
  3. 代码埋点:用户触发某个动作后手动上报数据,优点时准确性高,能满足自定义的场景,缺点是埋点逻辑容易与业务逻辑耦合(命令式埋点),不利于维护与复用。

综上,需要的是一种简单快速且准确,同时埋点逻辑与业务逻辑解耦的埋点方案,也就是本文分析的声明式的组件化埋点方案。

声明式的组件化埋点方案

名词解释

  • 页面 (Page):在浏览器中打开的网页,不同页面以路径 location.pathname 来作区分;
  • 页面可见时长:一个页面对用户可见的累计时长;
  • 页面活跃时长:用户在页面上进行有效的鼠标、键盘及触控活动的累计时长;
  • 组件 (Component):DOM 元素的集合,是页面的组成部分。一个页面内可包含多个组件;
  • 组件可见时长:一个组件对用户可见的累计时长。
  • 可见性(visiability)

    • visible:页面 viewport 中且位于前台;
    • invisible - 页面不 viewport 中,或处于后台。
  • 活跃性 (activity)

    • active - 用户在网页中有活动(例如鼠标、键盘活动及页面滚动等);
    • inactive - 用户在网页中没有任何活动。

前端组件化埋点方案与实现根据概念可知,一个页面不可见时,则一定不活跃,且其中的所有组件一定也都不可见;页面活跃时长 ≤ 页面可见时长;组件可见时长 ≤ 页面可见时长,

原理与思路

该方案总体思路如下: 

  1. 对于通用字段进行统一处理,既不容易出错,也方便后期拓展。对于运行时字段(异步),支持 extra 进行传入。
  2. 对于页面级事件,埋点库初始化后自动注册关于页面级曝光的相关事件,不需要在代码中进行维护。
  3. 考虑到存在高频场景,设置上报缓冲队列 pendingQueue,通过定时任务分批次上报数据,支持设置上报频率。根据实践,点击类上报频率 1000ms,曝光类 3000ms。
  4. 考虑到埋点 sdk 没初始完,上报行为就已经产生了,设置 unInitQueue 来存储。
  5. 以页面为维度来管理埋点配置,便于维护和迁移。

考虑到埋点 sdk 没初始完,上报行为就已经产生了,比如曝光,新增如果这时候生成对应的点进入缓冲队列,就是属于无效的点因为没有加载到坑位信息、配置参数等,所以针对这种场景下产生的点位信息,我们新开一个队列存储,等到初始化完成再去处理;

前端组件化埋点方案与实现

因此,埋点上报总体流程为:埋点 sdk 接受返回埋点的函数,将其返回值上报,支持上报多个埋点;埋点事件由应用发送给埋点 sdk 后,埋点 sdk 首先会对数据进行处理,再调用数据平台暴露的方法, 将埋点事件上报给数据平台。 

具体实现

判断页面可见性

前端组件化埋点方案与实现

虽然 Page Visibility API 的浏览器兼容情况不错,但对于Android、iOS 和最新的 Windows 系统可以随时自主地停止后台进程,及时释放系统资源。因此,基于 Google 描述网页生命周期的 Page Lifecycle API 兼容库 PageLifecycle.js 来监听页面可见性变化——一个网页从载入到销毁的过程中,会通过浏览器的各种事件在以下六种生命周期状态 (Lifecycle State) 之间相互转化,通过监听页面生命周期的变化并记录其时间,就可以相应获取页面可见性的统计数据:

前端组件化埋点方案与实现

前端组件化埋点方案与实现由此可得,页面生命周期状态和页面可见状态之间的映射关系为

因此,通过监听 statechange 来识别页面可见状态的改变,在生命周期状态为 activepassive 时标记页面为 visible 状态,在生命周期状态为其他几个时标记页面为 invisible 状态,更新最后一次可见的时间戳,并累加页面可见时间。PageLifecycle.js 是无法推送 discarded 事件的,因为网页已经被销毁并从内存中清理,无法向外传递任何事件——解决方案是需要在页面进入 invisible 状态时,对数据使用 JSON.stringify 序列化并储存在 localStorage 中,若页面后续转为 visible 状态即将其清空,否则在页面被强制清除后,在下一次初始进入页面时先将 localStorage 中的数据通过事件推送出去。另外, PageLifecycle.js 无法感知单页面应用的history 或 hash 路由切换,需要在埋点 sdk 中额外添加对路由变化事件(popstate/replacestate)的监听,等同于进入 terminated 生命周期:

lifecycleInstance.addEventListener("statechange", (event: StateChangeEvent) => {
  const { newState } = event;
  if (["active", "passive"].includes(newState)) {
    // page visible, do something
    return;
  }
  if (["hidden", "terminated", "frozen"].includes(newState)) {
    // page invisible, do something else
  }
})

判断页面活跃性

通过监听以下的六种浏览器事件,就可以判断用户是否在当前页面上有活动,此时页面标记为 active 状态,并记录当前时间戳,用于累加活跃时长。:

  1. keydown:用户敲击键盘时触发;
  2. mousedown:用户点击鼠标按键时触发;
  3. mouseover:用户移动鼠标指针时触发;
  4. touchstart:用户手指接触触摸屏时触发(仅限触屏设备);
  5. touchend:用户手指离开触摸屏时触发(仅限触屏设备);
  6. scroll:用户滚动页面时触发。

而页面被标记为 inactive 状态,有以下两种情况:

判断组件可见性

首先需要获取需要统计的所有 DOM 元素,并指定埋点的标准。MutationObserver API 提供了 DOM 节点增减以及属性变化检测的能力。该 API 是异步触发,即要等到当前所有 DOM 操作都结束才触发,避免DOM频繁变动造成性能损耗,因此,可以用来监听 DOM 结构变化。

const mutationObserver = new MutationObserver(function (mutations, observer) {
  mutations.forEach(function (mutation) {
    console.log(mutation.target); // target: 发生变动的 DOM 节点
  });
});
// 观察整个文档
mutationObserver.observe(document.documentElement, {
  childList: true, // 子节点的变动(指新增,删除或者更改)
  attributes: true, // 属性的变动
  characterData: true, // 节点内容或节点文本的变动
  subtree: true, // 表示是否将该观察器应用于该节点的所有后代节点
  attributeOldValue: false, // 表示观察 attributes 变动时,是否需要记录变动前的属性值
  characterDataOldValue: false, // 表示观察 characterData 变动时,是否需要记录变动前的值。
  attributeFilter: false, // 表示需要观察的特定属性,比如['class','src']
});
mutationObserver.disconnect(); // 用来停止观察。调用该方法后,DOM 再发生变动则不会触发观察器

前端组件化埋点方案与实现

其中, mutations 是所有被触发改动的 MutationRecord 对象数组。 

考虑到可能存在被监控组件是第三方库的,自定义属性 data-tracking-pv 会被过滤,为了统一,利用babel 插件在编译过程中寻找添加了 data-tracking-pv 属性的组件,并在其外层包裹一个自定义的 <tracking></tracking> 标签,自定义标签的优点是没有任何样式,所以包裹该标签也不会影响到原有组件的样式。埋点 sdk 收集这些元素供 MutationObserver 监听 DOM 变化。 

const Component = () => {
  <div data-tracking-pv='{ "event": "component_custom_pv", "params": { ... } }'>
   component_custom
  </div>
}
const Component = () => {
  return <Button data-tracking-pv={{ event: "component_antd_pv", params: { ... } }}>点击按钮</Button>
}
// 插件最终生成的组件为
</tracking
  data-tracking-pv='{ "event": "component_custom_pv", "params": { ... } }'
>
  // ...真实组件
</tracking>

组件可见性,即曝光的三个判断标准:

  • 是否处于 viewport 中:使用 IntersectionObserver API 判断, 该 API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法;我们知道,用户的感兴趣程度 = 点击率(点击次数/曝光次数),考虑曝光的有效性,即需要判断组件出现在 viewpoint 内的达到一定的比例(0.5 或 0.75 或 1.0)和时长(3s)以及次数(是否重复曝光)标准;

前端组件化埋点方案与实现

const intersectionObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach(function (entry) {
      /**
     entry.boundingClientRect // 返回包含目标元素的边界信息。 边界的计算方式与 Element.getBoundingClientRect() 相同。
     entry.intersectionRatio // 目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为 1,完全不可见时小于等于 0
     entry.intersectionRect // 目标元素与视口(或根元素)的交叉区域的信息
     entry.isIntersecting // 返回一个布尔值, 如果根与目标元素相交(即从不可视状态变为可视状态),则返回 true。如果返回 false, 变换是从可视状态到不可视状态。
     entry.rootBounds // 根元素的矩形区域的信息, getBoundingClientRect 方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回 null
     entry.target // 被观察的目标元素,是一个 DOM 节点对象
     entry.time // 可见性发生变化的高精度时间戳,单位为毫秒
     **/
    });
  },
  {
    root: document.querySelector('#root'), // 根 dom 元素, 默认 null 为 viewport
    rootMargin: '0px', // root元素的外边距
    threshold: [0, 0.25, 0.5, 0.75, 1], // Number 或 Number 数组,该属性决定了什么时候触发回调函数。默认为 0.0;
  }
);
intersectionObserver.observe(document.getElementById("item")); // 开始监听一个目标元素
intersectionObserver.disconnect(); // 停止全部监听工作

兼容性方面有对应的 intersection-observer-polyfill,前端组件化埋点方案与实现

前端组件化埋点方案与实现

  • 样式是否可见:使用 CSS 的 display/ visibility / opacity 样式属性判断;以下是会被标记为 invisible 的情况:
    • visibility: hidden
    • display: none
    • opacity: 0
  • 页面是否可见:使用页面可见性判断。页面 invisible 状态下,所有组件的状态也标记为 invisible。 

使用 requestIdleCallback 方法,浏览器会在空闲时执行传入的埋点函数,避免埋点影响主业务。其浏览器兼容情况如下:

前端组件化埋点方案与实现

 可以使用 requestIdleCallback shim/polyfill,以下是简化版:

const requestIdleCallback =
  window.requestIdleCallback ||
  function(callback, options) {
    var options = options || {};
    var relaxation = 1;
    var timeout = options.timeout || relaxation;
    var start = performance.now();
    return setTimeout(function() {
      callback({
        get didTimeout() {
          return options.timeout
            ? false
            : performance.now() - start - relaxation > timeout;
        },
        timeRemaining: function() {
          return Math.max(0, relaxation + (performance.now() - start));
        }
      });
    }, relaxation);
  };

MutationObserver + IntersectionObserver + requestIdleCallback 曝光埋点实现

const observerOptions = {
  childList: true, // 观察目标子节点的变化,是否有添加或者删除
  attributes: true, // 观察属性变动
  subtree: true, // 观察后代节点,默认为 false
}
function callback(mutationList, observer) {
  mutationList.forEach((mutation) => {
    switch (mutation.type) {
      case 'childList':
        collectTargets()
        break
      case 'attributes':
        break
    }
  })
}
function getParams(el) {
  const { exposureTrackerAction, exposureTrackerParams } = el.dataset;
  const Key = exposureTrackerAction;
  const pageEl = document.querySelector('[data-exposure-tracker-page-params]');
  let pageParams;
  if (pageEl?.dataset?.exposureTrackerPageParams) {
    try {
      pageParams = JSON.parse(pageEl?.dataset?.exposureTrackerPageParams);
    } catch (error) {
      console.error('parse pageParams fail');
    }
  }
  let params;
  if (exposureTrackerParams) {
    try {
      params = JSON.parse(exposureTrackerParams);
    } catch (error) {
      console.error('parse params fail');
    }
  }
  return {
    Key,
    ...pageParams,
    ...params
  };
}
const intersectionObserver = new IntersectionObserver(function callback(entries, observer) {
  const list = [];
  entries.forEach(entry => {
    if (
      entry.target.dataset.exposureTrackerExposed !== '1' &&
      entry.intersectionRatio >= 0.5
    ) {
      list.push(entry);
      observer.unobserve(entry.target);
    } else if (entry.intersectionRatio === 0) {
      delete entry.target.dataset.exposureTrackerExposed;
    }
    requestIdleCallback(() => {
      // ...上报
    });
  });
}, options);
collectTargets() {
  const els = Array.from(
    document.querySelectorAll('[data-exposure-tracker-action]')
  ).filter(el => !el.dataset.exposureTrackerTracked);
  if (els.length > 0) {
    // console.log('collectTargets', els);
    els.forEach(el => {
      intersectionObserver.observe(el);
      el.dataset.exposureTrackerTracked = true;
    });
  }
export function getExposureTrackerPageParamsProps(params) {
  return {
    'data-exposure-tracker-page-params': params
      ? JSON.stringify(params)
      : undefined
  };
}
export function getExposureTrackerParamsProps(action, params) {
  return {
    'data-exposure-tracker-action': action,
    'data-exposure-tracker-params': params ? JSON.stringify(params) : undefined
  };
}
export default function App() {
  return (
    <div {...getExposureTrackerPageParamsProps({ pageData: 'some page data' })}>
      <div
        {...getExposureTrackerParamsProps('item_content_expose', {
            itemData: 'xxx'
          })}
        >
          item content
        </div>
    </div>
  );
}

自定义类指令式埋点实现

该方式适合简单的埋点上报,埋点逻辑与业务逻辑清晰分离,埋点 sdk 给 document 对象加上监听 click / hover 事件触发时,从当前触发事件的 target 逐级向上遍历,查看是否有对应此事件的指令。如果有,则上报此埋点事件,直至遇到一个没有事件指令的元素节点。这样也可以在指令中控制是否要继续向上遍历。

// 类指令式埋点实现逐级上报
<section data-tracking-hover={JSON.stringify({ type: 'func_operation', params: { value: 3 }})}>
  <div data-tracking-click={JSON.stringify({ type: 'func_operation', params: { value: 2 }})}>
    <Button data-tracking-click={JSON.stringify({ type: 'func_operation', params: { value: 1 }})}>点击</Button>
  </div>
</section>

但是如果我们需要在上报事件前,对所上报的数据进行处理,那么这种方式就无法满足了。并且,并不是所有的场景都可以被 DOM 事件所覆盖。如果我想在用户在搜索框输入某个值时,上报埋点,那么我就需要对用户输入的值进行分析,而不能在 input 事件每次触发时都上报埋点。

装饰器式埋点实现

装饰器只能用于类组件,@tracking 修饰器接受一个函数形式的参数,其返回值即是要上报的事件。在 handleClick 函数被调用的时候,埋点 sdk 会首先上报埋点事件,然后再执行 handleClick 函数的业务逻辑。

  • target - 装饰器所在的类
  • propertyKey - 被装饰的函数的属性名
  • descriptor - 被装饰的函数的属性描述符
// tracking 函数简化源代码
tracking = (event: TrackingEvent) => {
  return (target: object, propertyKey: string, descriptor: object) => {
    if (isFunction(event) || isObject(event) || isArray(event)) {
        const oldMethod = descriptor.value;
        const _event = this.evalEvent(event)(...arguments);
        const composedFn = () => {
            this.sendEvent(_event);
            oldMethod.apply(this, arguments);
        }
        set(descriptor, "value", composedFn);
        return descriptor;
    }
  }
};
/** 
 * @tracking 使用示例
 */
class Test extends React.component {
    ...
    @tracking((value: string) => ({
        type: 'func_operation',
        params: { keyword: value },
    }))
    handleClick() {
        console.log('执行点击的业务逻辑',);
    }
    render() {
      return (
        <Button onClick={handleClick} />
      )
    }
}

因此,会先上报埋点,然后执行 descriptor.value 的逻辑,即被装饰的函数。

React Hook 埋点实现

与装饰器实现类似,useTracking 接受两个函数:埋点函数和业务函数,返回组合函数。

// useTracking 源代码
useTracking = (fn: () => any /** 业务函数 */, event: TrackingEvent /** 埋点函数 */) => {
  if (!event) return fn;
  return (...args) => {
    const _event = this.evalEvent(event)(...args);
    this.sendEvent(_event);
    return fn.apply(this, args);
  };
};
// useTracking 使用示例
const Example = (props: object) => {
  const handleClick = useTracking(
    // 业务逻辑
    () => {
      console.log('业务逻辑');
    },
    // 埋点逻辑
    () => {
      return {
        type: "func_operation",
        params: { data, props.data },
      };
    }
  );
  return <Button onClick={handleClick} />;
};

 组件化点击埋点实现

使用者只需要把触发的回调(handleClick)绑定到对应的事件上即可,埋点逻辑由 埋点sdk 负责组合上去。

function setClickEvent(ele) {
   // 对于列表,也可以遍历
   return React.cloneElement(ele, {
        onClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
            const originClick = ele.props?.onClick || noop;
            doClickTracking();
            originClick.call(ele, event);
      }
  });
}
<TrackerClick name='button.click'>
{
 ({ handleClickTrack }) => <button onClick={() => { handleClick() /** 业务逻辑 */; handleClickTrack() /** 埋点逻辑 */; }}>点击</button>
}
</TrackerClick>
/**
class TrackerClick {
  constructor(props) {
    super(props);
  }
  handleClick() {
  }
  render() {
    return this.props.children({ handleClick });
  }
}
*/

组件化曝光埋点实现

仅向外暴露一个 setRef 用以获取 dom 执行监听工作,其他工作都交给埋点 sdk 来处理,同时支持配置以下曝光规则:

// case1: 直接绑定dom
return (
  <TrackerExposure name='button.exposure' extra={{ data }}>
    {({ setRef }) => <div ref={setRef}>{i + 1}</div>}
  </TrackerExposure>))
);
// case2: 自定义组件
const Test = React.forwardRef((props, ref) => (<div ref={ref} style={{
        width: '150px',
        height: '150px',
        border: '1px solid gray'
    }}>TEST</div>)
)
return (
  <TrackerExposure name="button.exposure" extra={{ data }}>
    {({ setRef }) => <Test ref={setRef} />}
  </TrackerExposure>
)
/**
class TrackerExposure {
  constructor(props) {
    super(props);
  }
  setRef() {
  }
  render() {
    return this.props.children({ setRef });
  }
}
*/

补充--微信小程序曝光埋点

微信小程序的 节点布局相交状态 API (IntersectionObserver),可用于监听两个或多个组件节点在布局位置上的相交状态。这一组 API 常常可以用于推断某些节点是否可以被用户看见、有多大比例可以被用户看见。

/** 创建并返回一个 IntersectionObserver 对象实例。
 * 在自定义组件或包含自定义组件的页面中,
 * 应使用 this.createIntersectionObserver([Object options]) 来代替。
 */
wx.createIntersectionObserver(
 /** 自定义组件实例 */
 [Object component],
 /** 
  * thresholds: 一个数值数组,包含所有阈值。
  * initialRatio: 初始的相交比例,如果调用时检测到的相交比例与这个值不相等且达到阈值,则会触发一次监听器的回调函数。
  * observeAll: 是否同时观测多个目标节点(而非一个),如果设为 true ,observe 的 targetSelector 将选中多个节点(注意:同时选中过多节点将影响渲染性能)
  */
 [Object options]
)

相关概念:

  • 参照节点:监听的参照节点,取它的布局区域作为参照区域。如果有多个参照节点,则会取它们布局区域的 交集 作为参照区域。页面显示区域也可作为参照区域之一。
  • 目标节点:监听的目标,默认只能是一个节点(使用 selectAll 选项时,可以同时监听多个节点)。
  • 相交区域:目标节点的布局区域与参照区域的相交区域。
  • 相交比例:相交区域占参照区域的比例。
  • 阈值:相交比例如果达到阈值,则会触发监听器的回调函数。阈值可以有多个。

IntersectionObserver 一共有四个方法

  1. IntersectionObserver.relativeTo(string selector, Object margins) 使用选择器指定一个节点,作为参照区域之一;
  2. IntersectionObserver.relativeToViewport(Object margins) 指定页面显示区域作为参照区域之一;
  3. IntersectionObserver.observe(string targetSelector, function callback) 指定目标节点并开始监听相交状态变化情况;

    callback: (res) => {};
    res: {
        id	string	节点 ID
        dataset	Record.<string, any>	节点自定义数据属性
        intersectionRatio	number	相交比例
        intersectionRect	Object	相交区域的边界
        boundingClientRect	Object	目标边界
        relativeRect	Object	参照区域的边界
        time	number	相交检测时的时间戳
    }
    
  4. IntersectionObserver.disconnect() 停止监听。回调函数将不再触发。

显然,曝光上报的默认参照是 viewport,所以使用 IntersectionObserver.relativeToViewport 作为参照物,进行曝光埋点:

Page({
  data: {
    list: [
      { value: 1, hadReport: false }, 
      { value: 2, hadReport: false },
      { value: 3, hadReport: false },
    ]
  },
  onLoad() {
    this._observer = this.createIntersectionObserver({ thresholds: [0.5], observeAll: true });
    this._observer.relativeToViewport({ bottom: 0 })
      .observe('.item', (res) => {
        const { index } = res.dataset; // item 下标;
        if (!this.data.list[index].hadReport) {
          console.log(`report ${index}`) 
          this.data.list[index].hadReport = true;
          this.setData({ list: [].concat(this.data.list)})
        }
      })
  },
  onUnload() {
    if (this._observer) this._observer.disconnect()
  }
})