阅读 3296

手把手教你搭建一个无框架埋点体系

作者:Lilly Jiang, Aiqing Dong

背景

埋点体系构成

一般来说,一个完整的埋点体系由以下三个部分构成:

  • 应用
  • 数据分析平台
  • 数据平台 SDK

埋点上报是将应用层事件上传至上层平台的过程。比方说,在某购物网站上,用户点击了「收藏」按钮,此时,一个点击事件就生成了,这一事件会被上报至一个数据分析平台。这样,相关的数据分析师、产品经理、运营等同学便可以在数据分析平台,通过这些上报的事件数据分析,得出应用中可以优化的方方面面。由此可见,埋点上报是每个产品走向卓越的重要一环。

通过以上描述,我们认识了埋点上报过程的两大主角:应用与数据分析平台。从前端技术的角度来说,我们通常还需要第三个角色的助攻,那就是数据平台 SDK. 这个 SDK 封装了数据分析平台的各种接口,暴露出简单的方法让我们进行调用,实现简易的埋点上传。

两种埋点事件

我们可以把应用层事件分为两大类:

  • 「页面事件」:一种是通用的「页面事件」,比如说用户在应用某个页面的停留及活跃时长,我们希望这种全局的埋点只用在项目初始化时注入一次,不需要在代码中进行维护。
  • 「触发事件」:另一种则是自定义的「触发事件」,比如点击某个特定的按钮,开启某个特定的流程,这种事件需要前端同学在代码中手动注入埋点。

我们为这两种事件分别开发了一套埋点上传 SDK。下面,我们就来详细地讲解一下这两套 SDK 的技术知识。

处理「页面事件」的 SDK - monitor-tracer

monitor-tracer 是一个用来监控页面及组件可见时长和活跃时长的前端 SDK,同时也是 Monitor 前端埋点体系的一个核心组成部分。

背景

为了更好地理解用户对各个业务功能的使用状况,从而进行相应的产品优化和调整:

  • 对于一般的网页应用,我们需要对用户在应用某个页面的停留及活跃时长进行相应的统计;

  • 对于大盘 / 看板 / dashboard 类型的页面(如下图所示),我们希望在页面维度的基础上,更进一步地统计每个组件对用户的可见时长,从而对它们的排列顺序和内容进行优化。

    一个 dashboard 类页面

基于以上需求,我们开发了 monitor-tracer SDK, 旨在实现对 「页面可见、活跃时长」「组件可见时长」 的统计。

名词解释

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

其关系为:

  • 页面活跃时长 ≤ 页面可见时长;
  • 组件可见时长 ≤ 页面可见时长;
  • 一个页面不可见时,则一定不活跃,且其中的所有组件一定也都不可见。

页面可见及活跃时长统计

在我们的设计中,衡量一个页面的停留及活跃时长需要两个重要的指标:

  • 可见性 (visibility)

    • visible - 页面在当前浏览器的 viewport 中,且浏览器窗口未被最小化;
    • invisible - 页面不在当前浏览器的 viewport 中,或因浏览器最小化导致其无法被看到。
  • 活跃性 (activity)

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

只要能获取到这四种状态发生的时间戳,就可以按下图所示方法,累加计算出页面从载入到退出的可见和活跃时长:

计算页面可见和活跃时长

获取页面可见性数据

Web Lifecycle

Google 公司在 2018 年 7 月份提出了一套用来描述网页生命周期的 Page Lifecycle API 规范,本 SDK 便是基于这套规范来监听页面可见性变化的。规范指出,一个网页从载入到销毁的过程中,会通过浏览器的各种事件在以下六种生命周期状态 (Lifecycle State) 之间相互转化。

生命周期状态描述
active网页可见,且具有焦点
passive网页可见,但处于失焦状态
hidden网页不可见,但未被浏览器冻结,一般由用户切换到别的 tab 或最小化浏览器触发
frozen网页被浏览器冻结(一些后台任务例如定时器、fetch 等被挂起以节约 CPU 资源)
terminated网页被浏览器卸载并从内存中清理。一般用户主动将网页关闭时触发此状态
discarded网页被浏览器强制清理。一般由系统资源严重不足引起

生命周期状态之间的转化关系如下图所示:

生命周期状态转化

由以上信息,我们可以得出页面生命周期状态和页面可见状态之间的映射关系:

生命周期状态可见状态
active passivevisible
hidden terminated frozen discardedinvisible

因此,我们只需要监听页面生命周期的变化并记录其时间,就可以相应获取页面可见性的统计数据。

监听页面生命周期变化

在制定 Page Lifecycle 规范的同时,Google Chrome 团队也开发了 PageLifecycle.js SDK, 实现了这套规范中描述的生命周期状态的监听,并兼容了 IE 9 以上的所有浏览器。出于易用性及稳定性的考虑,我们决定使用这个 SDK 来进行生命周期的监听。由于 PageLifecycle.js 本身使用 JavaScript 编写,我们为其添加了类型定义并封装为了兼容 TypeScript 的 @byted-cg/page-lifecycle-typed SDK. PageLifecycle.js 的使用方法如下:

import lifecycleInstance, {
  StateChangeEvent,
} from "@byted-cg/page-lifecycle-typed";

lifecycleInstance.addEventListener("statechange", (event: StateChangeEvent) => {
  switch (event.newState) {
    case "active":
    case "passive":
      // page visible, do something
      break;
    case "hidden":
    case "terminated":
    case "frozen":
      // page invisible, do something else
      break;
  }
});
复制代码

通过 PageLifecycle.js, 我们便可以监听 statechange 事件来在页面生命周期发生变化时获得通知,并在生命周期状态为 activepassive 时标记页面为 visible 状态,在生命周期状态为其他几个时标记页面为 invisible 状态,更新最后一次可见的时间戳,并累加页面可见时间。

PageLifecycle.js 的缺陷

对于我们的需求来说,PageLifecycle.js 本身存在如下两个缺陷。我们也针对这两个缺陷做了一些改进。

  • 无法监控到单页应用 (SPA) 中页面的变化

    在单页应用中,页面通常依靠 history 或 hash 路由的变化来切换,页面本身并不会重新载入,因此 PageLifecycle.js 无法感知页面的切换。为了应对这种情况,我们在 monitor-tracer 中手动添加了针对路由变化事件(popstate replacestate 等)的监听。如果发现页面的路由发生了变化,会认为当前页面进入了 terminated 生命周期,从而进行相应的处理。这里对路由变化进行监听的逻辑复用了我们之前开发的 @byted-cg/puzzle-router SDK, 有兴趣的同学可以参考。

  • 无法捕获 discarded 生命周期

    discarded 生命周期发生在网页被浏览器强制清除时。此时网页已经被销毁并从内存中清理,无法向外传递任何事件,因此 PageLifecycle.js 也就无法推送 discarded 事件。这种情况一旦发生,就会造成被清除的网页统计数据的丢失。为了应对此场景,monitor-tracer 会在页面进入 invisible 状态时,将现有的页面时长统计数据存储使用 JSON.stringify 序列化并储存在 localStorage 当中。如果页面恢复 visible 状态,则会把 localStorage 中的数据清空;而如果页面被清除,则会在下一次进入页面时先将 localStorage 中存储的上一个页面的数据通过事件推送出去。这样就最大程度地保证了页面即使被强制清除,其数据也能被送出而不至丢失。

获取页面活跃性数据

相较于页面可见性,页面活跃性的判断要更加直截了当一些,直接通过以下方法判断页面状态为 active 还是 inactive 即可。

active 判断标准

通过监听一系列的浏览器事件,我们就可以判断用户是否在当前页面上有活动。monitor-tracer 会监听以下的六种事件:

事件描述
keydown用户敲击键盘时触发
mousedown用户点击鼠标按键时触发
mouseover用户移动鼠标指针时触发
touchstart用户手指接触触摸屏时触发(仅限触屏设备)
touchend用户手指离开触摸屏时触发(仅限触屏设备)
scroll用户滚动页面时触发

一旦监听到以上事件,monitor-tracer 就会将页面标记为 active 状态,并记录当前时间戳,累加活跃时长。

inactive 判断标准

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

  • 超过一定的时间阈值(默认为 30 秒,可在初始化 SDK 时自定义)没有监测到表示页面活跃的六种事件;
  • 页面状态为 invisible. 因为如果页面对用户不可见,那么它一定是不活跃的。

页面被标记为 inactive 后,monitor-tracer 会记录当前时间戳并累加活跃时长。

组件可见时长统计

统计组件级别活跃时长需要两个条件,一是拿到所有需要统计的 DOM 元素,二是对这些 DOM 元素按照一定的标准进行监控。

获取需要统计的 DOM 元素

监听 DOM 结构变化

获取 DOM 元素要求我们对整个 DOM 结构的改变进行监控。monitor-tracer 使用了 MutationObserver API, DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,都可以通过这个 API 得到通知。

概念上,它很接近事件,可以理解为 DOM 发生变动就会触发 MutationObserver 事件。但是,它与事件有一个本质不同:事件是同步触发,也就是说,DOM 的变动立刻会触发相应的事件,而 MutationObserver 则是异步触发,DOM 的变动并不会马上触发,而是要等到当前所有 DOM 操作都结束才触发。

这样的设计是为了应对 DOM 变动频繁的特点,节省性能。举例来说,如果文档中连续插入 1000 个 <p></p> 标签,就会连续触发 1000 个插入事件,执行每个事件的回调函数,这很可能造成浏览器的卡顿。而 MutationObserver 完全不同,只在 1000 个标签都插入结束后才会触发,而且只触发一次。

MutationObserver API 的用法如下:

const observer = new MutationObserver(function (mutations, observer) {
  mutations.forEach(function (mutation) {
    console.log(mutation.target); // target: 发生变动的 DOM 节点
  });
});

observer.observe(document.documentElement, {
  childList: true, //子节点的变动(指新增,删除或者更改)
  attributes: true, // 属性的变动
  characterData: true, // 节点内容或节点文本的变动
  subtree: true, // 表示是否将该观察器应用于该节点的所有后代节点
  attributeOldValue: false, // 表示观察 attributes 变动时,是否需要记录变动前的属性值
  characterDataOldValue: false, // 表示观察 characterData 变动时,是否需要记录变动前的值。
  attributeFilter: false, // 表示需要观察的特定属性,比如['class','src']
});

observer.disconnect(); // 用来停止观察。调用该方法后,DOM 再发生变动则不会触发观察器
复制代码

标记需要监听的元素

为了在众多 DOM 元素中找到需要监听的元素,我们需要一个方法来标记这些元素。monitor-tracer SDK 规定,一个组件如果需要统计活跃时长,则需要为其添加一个 monitor-pvdata-monitor-pv 属性。在使用 MutationObserver 扫描 DOM 变化时,monitor-tracer 会将有这两个属性的 DOM 元素收集到一个数组里,以供监听。例如下面两个组件:

<div monitor-pv='{ "event": "component_one_pv", "params": { ... } }'>
  Component One
</div>
<div>Component Two</div>
复制代码

Component One 因为添加了 monitor-pv 属性,会被记录并统计可见时长。而 Component Two 则不会。

babel-plugin-tracer 插件

如果需要监控的组件是通过一些组件库(例如 Ant Design 或 ByDesign)编写的,那么为其添加 monitor-pvdata-monitor-pv 这样的自定义属性可能会被组件自身过滤从而不会出现在最终生成的 DOM 元素上,导致组件的监控不生效。为了解决类似的问题,我们开发了 @byted-cg/babel-plugin-tracer babel 插件。此插件会在编译过程中寻找添加了 monitor-pv 属性的组件,并在其外层包裹一个自定义的 <monitor></monitor> 标签。例如:

import { Card } from 'antd';

const Component = () => {
  return <Card monitor-pv={{ event: "component_one_pv", params: { ... } }}>HAHA</Card>
}
复制代码

如果不添加插件,那么最终生成的 DOM 为:

<div class="ant-card">
  <!-- ... Ant Design Card 组件 -->
</div>
复制代码

可见 monitor-pv 属性经过组件过滤后消失了。

而安装 babel 插件后,最终编译生成的 DOM 结构为:

<monitor
  is="custom"
  data-monitor-pv='{ "event": "component_one_pv", "params": { ... } }'
>
  <div class="ant-card">
    <!-- ... Ant Design Card 组件 -->
  </div>
</monitor>
复制代码

monitor-pv 属性得到了保留,并且插件自动为其添加了 data- 前缀,以应对 React 16 之前版本仅支持 data- 开头的自定义属性的问题;同时将传入的对象使用 JSON.stringify 转换成了 DOM 元素 attribute 唯一支持的 string 类型。

由于自定义标签没有任何样式,所以包裹该标签也不会影响到原有组件的样式。monitor-tracer SDK 在扫描 DOM 元素后,会同时收集所有 <monitor></monitor> 标签中的元素的信息,并对其包裹的元素进行监控。

判断 DOM 元素可见性

对组件可见性的判断可分为三个维度:

  • 组件是否在浏览器 viewport 中 - 使用 IntersectionObserver API 判断;
  • 组件样式是否可见 - 根据元素 CSS 的 display visibilityopacity 样式属性判断;
  • 页面是否可见 - 根据页面可见性判断。

判断组件是否在浏览器 viewport 中

这里我们使用了 IntersectionObserver API. 该 API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。

过去,相交检测通常要用到事件监听,并且需要频繁调用 Element.getBoundingClientRect 方法以获取相关元素的边界信息。事件监听和调用 Element.getBoundingClientRect 都是在主线程上运行,因此频繁触发、调用可能会造成性能问题。这种检测方法极其怪异且不优雅。

IntersectionObserver API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 viewport),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行。这样,我们网站的主线程不需要再为了监听元素相交而辛苦劳作,浏览器会自行优化元素相交管理。

IntersectionObserver API 的用法如下:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(function (entry) {
      /**
     entry.boundingClientRect // 目标元素的矩形区域的信息
     entry.intersectionRatio // 目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为 1,完全不可见时小于等于 0
     entry.intersectionRect // 目标元素与视口(或根元素)的交叉区域的信息
     entry.isIntersecting // 标示元素是已转换为相交状态 (true) 还是已脱离相交状态 (false)
     entry.rootBounds // 根元素的矩形区域的信息, getBoundingClientRect 方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回 null
     entry.target // 被观察的目标元素,是一个 DOM 节点对象
     entry.time // 可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
     **/
    });
  },
  {
    threshold: [0, 0.25, 0.5, 0.75, 1], //该属性决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为 [0],即交叉比例 (intersectionRatio) 达到 0 时触发回调函数
  }
);

observer.observe(document.getElementById("img")); // 开始监听一个目标元素

observer.disconnect(); // 停止全部监听工作
复制代码

如果一个组件和 viewport 的相交比例小于某个值(默认为 0.25),那么这个组件就会被标记为 invisible. 反之,如果比例大于某个值(默认为 0.75),那么这个组件就会被标记为 visible

判断组件 CSS 样式是否可见

如果元素的 CSS 样式设为了 visibility: hiddenopacity: 0,那么即使其与 viewport 的相交比例为 1,对用户来说也是不可见的。因此我们需要额外判断目标元素的 CSS 属性是否为可见。

如果一个组件的样式被设置为了以下之一,那么它就会被标记为 invisible.

  • visibility: hidden
  • display: none
  • opacity: 0

判断页面是否可见

当页面不可见时,所有组件自然都不可见,因此在页面为 invisible 的状态下,monitor-tracer 会将需监控的所有组件的状态也标记为 invisible

处理「触发事件」的 SDK - monitor

monitor SDK 的定位

数据平台 SDK 的单一的埋点上报方式,无法满足我们开发中对 clean code 的极致追求

数据平台的 SDK 往往只提供了上报埋点的函数式方法,虽然可以满足我们的日常开发需求,但是并不能解决我们在写埋点代码时的两大痛点:

  • 只能逐个进行埋点上报
  • 埋点逻辑与业务逻辑的耦合

我们希望埋点代码可以轻易地添加、修改与删除,并且对业务代码没有影响。因此,我们基于 TypeScript 开发对框架无感的 monitor SDK. 它支持逐个上传多个埋点,并且接受返回埋点的函数,将其返回值上报;它提供了三种方式注入埋点,覆盖了所有场景,将埋点与业务代码完全分离。

可以看出,monitor 既是一个数据处理器,又是一个方法库。更直观一些,使用 monitor 后,我们的应用在上报埋点时的流程如下:

埋点上报流程

埋点由应用层发送给 monitor 后,monitor 首先会对数据进行处理,再调用数据平台 SDK, 将埋点事件上报给数据平台。

在对 monitor 有了初步了解后,这篇文章将主要讲解 monitor 是如何通过以下三种埋点注入的方式,解耦业务逻辑与埋点逻辑的。

下面我们来看一下 monitormonitor-tracer SDK 具体的技术设计及实现方法。

三种埋点注入方式

类指令式

monitor 提供了类指令方式注入埋点。例如,下段代码用 monitor-click 指令注入了埋点。在此按钮被点击 (click) 时,monitor-click 所对应的值,即一个事件,就会被上报。

// 指令式埋点示例
<Button
  monitor-click={JSON.stringify({
    type: "func_operation",
    params: { value: 3 },
  })}
>
  Click Me
</Button>
复制代码

这是如何实现的呢?为什么仅仅给组件加了一个 monitor-click 属性,monitor 就会在这个按钮被点击时上报埋点了呢?

实现与原理

其实,monitor SDK 在初始化时,会给当前的 document 对象加上一系列 Event Listeners, 监听 hover click input focus 等事件。当监听器被触发时,monitor 会从触发事件的 target 对象开始,逐级向上遍历,查看当前元素是否有对应此事件的指令,如果有,则上报此事件,直至遇到一个没有事件指令的元素节点。以下示意图展示了类指令式埋点的上报流程:

类指令上报流程

逐级上报过程

以如下代码为例,当光标 hover 到 Button 时,document 对象上所安装的监听 hover 事件的函数便会执行。这个函数首先在 event.targetButton 上查找是否有与 hover 事件相关的指令(即属性)。Buttonmonitor-hover 这个指令,此时函数便上传此指令所对应的事件,即 { type: 'func_operation', params: { value: 1 }}

接下来,函数向上一层,到了 Button 的父元素,即 div, 重复上述过程,它找到了 data-monitor-hover 这个指令,便同样地上报了对应的埋点事件。而到了 section 这一层,虽然其有 data-monitor-click 指令,但此指令并不对 hover 事件进行响应,因此,这个逐级上报埋点的过程结束了。

// 指令式埋点实现逐级上报
<section
        data-monitor-click={JSON.stringify({
        type: 'func_operation',
params: { value: 3 },
})}
>
<div
        data-monitor-hover={JSON.stringify({
        type: 'func_operation',
params: { value: 2 },
})}
>
<Button
        monitor-hover={JSON.stringify({
        type: 'func_operation',
params: { value: 1 },
})}
>
Click Me
</Button>
</div>
</section>
复制代码

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

装饰器式

装饰器本质上是一个高阶函数。它接受一个函数,返回另一个被修饰的函数。因此,我们很自然地想到用装饰器将埋点逻辑注入到业务函数,既实现了埋点与业务代码的分离,又能够适应于复杂的埋点场景。

下面的代码使用了 @monitorBefore 修饰器。@monitorBefore 所接收的函数的返回值,即是要上报的事件。在 handleSearch 函数被调用的时候,monitor 会首先上报埋点事件,然后再执行 handleSearch 函数的逻辑。

// @monitorBefore 使用示例
@monitorBefore((value: string) => ({
    type: 'func_operation',
    params: { keyword: value },
}))
handleSearch() {
    console.log(
        '[Decorators Demo]: this should happen AFTER a monitor event is sent.',
    );
}

return (
    <AutoComplete
        onSearch={handleSearch}
/>
)
复制代码

@readonly 理解装饰器原理

装饰器是如何实现将埋点逻辑和业务逻辑相整合的呢?在我们详细解读 @monitorBefore 之前,让我们先从一个常用的装饰器 @readonly讲起吧。

装饰器应用于一个类的单个成员上,包括类的属性、方法、getters 和 setters. 在被调用时,装饰器函数会接收 3 个参数:

  • target - 装饰器所在的类
  • name - 被装饰的函数的名字
  • descriptor - 被装饰的函数的属性描述符
// @readonly装饰器的代码实现
readonly = (target, name, descriptor) => {
  console.log(descriptor);
  descriptor.writable = false;
  return descriptor;
};
复制代码

上述代码通过 console.log 输出的结果为:

代码输出结果

以我们常见的 @readonly 为例,它的实现方法如上。通过在上述代码中 log 出来 descriptor, 我们得知 descriptor 的属性分别为:

  • writable - 被装饰的函数是否能被赋值运算符改变;
  • enumerable - 被装饰的函数是否出现在对象的枚举属性中;
  • configurable - 被装饰的函数的描述符是否能够被改变,是否能够从对象上被删除;
  • value - 被装饰的函数的值,即其对应的函数对象。

可见,@readonly 装饰器将 descriptorwritable 属性设置为 false, 并返回这个 descriptor, 便成功将其装饰的类成员设置为只读态。

我们以如下方式使用 @readonly 装饰器:

class Example {
  @readonly
  a = 10;

  @readonly
  b() {}
}
复制代码

@monitorBefore 的实现

@monitorBefore 装饰器要比 @readonly 复杂一些,它是如何将埋点逻辑与业务逻辑融合,生成一个新的函数的呢?

首先,我们来看看下面这段代码。monitorBefore 接收一个埋点事件 event 作为参数,并返回了一个函数。返回的函数的参数与上面讲过的 @readonly 所接收的参数一致。

// monitorBefore 函数源代码
monitorBefore = (event: MonitorEvent) => {
  return (target: object, name: string, descriptor: object) =>
    this.defineDecorator(event, descriptor, this.before);
};
复制代码
// @monitorBefore 使用方式
@monitorBefore((value: string) => ({
    type: 'func_operation',
    params: { keyword: value },
}))
handleSearch() {
    console.log(
        '[Decorators Demo]: this should happen AFTER a monitor event is sent.',
    );
}

return (
    <AutoComplete
        onSearch={handleSearch}
/>
)
复制代码

在编译时,@monitorBefore 接收了一个 event 参数,并返回了如下函数:

f = (target: object, name: string, descriptor: object) =>
  this.defineDecorator(event, descriptor, this.before);
复制代码

而后,编译器又调用函数 f, 把当前的类、被装饰的函数名称与其属性描述符传给f. 函数 f 返回的 this.defineDecorator(event, descriptor, this.before) 会被解析为一个新的 descriptor 对象,它的 value 会在运行时被调用,也就是说会在 onSearch 被触发时所调用。

现在,让我们详细解读 defineDecorator 函数是如何改变生成一个新的 descriptor 的吧。

// defineDecorator 函数源代码
before = (event: MonitorEvent, fn: () => any) => {
  const that = this;
  return function (this: any) {
    const _event = that.evalEvent(event)(...arguments);
    that.sendEvent(_event);
    return fn.apply(this, arguments);
  };
};

defineDecorator = (
  event: MonitorEvent,
  descriptor: any,
  decorator: (event: MonitorEvent, fn: () => any) => any
) => {
  if (isFunction(event) || isObject(event) || isArray(event)) {
    const wrapperFn = decorator(event, descriptor.value);

    function composedFn(this: any) {
      return wrapperFn.apply(this, arguments);
    }

    set(descriptor, "value", composedFn);
    return descriptor;
  } else {
    console.error(
      `[Monitor SDK @${decorator}] the event argument be an object, an array or a function.`
    );
  }
};

monitorBefore = (event: MonitorEvent) => {
  return (target: object, name: string, descriptor: object) =>
    this.defineDecorator(event, descriptor, this.before);
};
复制代码

defineDecorator 函数接收三个参数:

  • event - 需要上报的埋点;
  • descriptor - 被装饰的函数的属性描述符;
  • decorator - 一个高阶函数。它接收一个埋点事件与一个回调,返回一个函数,用来进行埋点上报,并而后执行回调。

decorator 首先返回了一个函数 wrapperFn. 在被调用时,wrapperFn 会先上报埋点,然后执行 descriptor.value 的逻辑,即被装饰的函数。

defineDecoratorcomposedFn 中, 我们用 wrapperFn.apply(this, arguments) 将调用被装饰的函数时传入的参数透传给 wrapperFn

最后,我们将 composedFn 设为 descriptor.value, 这样,我们就成功生成了一个融合了埋点逻辑与业务逻辑的新函数。

装饰器埋点注入方式十分整洁,能够清晰地与业务代码区分。不论是增添、修改还是删除埋点,都无需顾虑会对业务代码造成改动。但是其局限性也是显而易见的,装饰器只能用于类组件,现在我们常用的函数式组件是无法使用装饰器的。

React 钩子

为了能够在函数式组件中,实现装饰器埋点带来的功能,我们还支持了埋点钩子 useMonitor. 与装饰器的原理相同,useMonitor 接收一个埋点函数,一个业务函数,返回一个新的函数将二者融合,既实现了代码层面上的清晰分离,又覆盖了全场景的埋点注入。

// useMonitor 源代码
useMonitor = (fn: () => any, event: MonitorEvent) => {
  if (!event) return fn;
  const that = this;

  return function (this: any) {
    const _event = that.evalEvent(event)(...arguments);
    that.sendEvent(_event);

    return fn.apply(this, arguments);
  };
};
复制代码

useMonitor 的实现较为简单,只是一个高阶函数,不像装饰器需要语法解析。它返回了一个函数,在被调用时会先上传埋点事件,在执行业务逻辑。其使用方式如下:

// useMonitor 使用示例
const Example = (props: object) => {
  const handleChange = useMonitor(
    // 业务逻辑
    (value: string) => {
      console.log("The user entered", value);
    },
    // 埋点逻辑
    (value: string) => {
      return {
        type: "func_operation",
        params: { value },
      };
    }
  );

  return <Search onSearch={handleChange} />;
};
复制代码

小结

上述三种埋点方式,覆盖了所有使用场景。不论你是用 React, Vue, 还是原生 JavaScript, 不论你是使用类组件,还是函数式组件,不论你的埋点是否需要复杂的前置逻辑,monitor SDK 都提供了适合你的场景的使用方式。

技术栈

开发人员 Credits

  • monitor SDK - [Lilly Jiang]
  • monitor-tracer SDK
    • 网页可见及活跃时长、babel 插件 - [Aiqing Dong]
    • 组件可见时长 - [Yuling Chen]
文章分类
前端
文章标签