Skip to main content

前端埋点方式

只有了解用户,了解用户的行为,了解用户在网站中做了什么,呆了多久,我们才能服务好用户。

如何去了解用户呢?这就涉及到前端的埋点了。

所谓埋点,实际上是对特定事件或者行为的数据监控和上报,比如用户某个 icon 点击次数、观看某个视频的时长等等。

以下是我所了解的前端常见的埋点方式和埋点行为。

埋点方式

基于 ajax 的埋点上报

因为埋点实际上是对关键节点的数据进行上报是和服务端交互的一个过程,所以我们可以和后端约定一个接口通过 ajax 去进行数据上报。

function buryingPointAjax(data) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest() // 创建ajax请求
xhr.open('post', '/buryingPoint', true) // 定义请求接口
xhr.send(data) // 发送数据
})
}

使用时,直接调用即可

let info = {} // 上报信息
buryingPointAjax(info) // 这样就成功上报了info的对象

但是不推荐使用 ajax 方式埋点,缺点如下:

  1. 一般而言,埋点域名并不是当前域名,因此请求会存在跨域风险
  2. 如果 ajax 配置不正确可能会浏览器拦截
  3. 在页面卸载时,ajax 有可能没上报完,页面就卸载了导致请求中断

基于 img 的埋点上报

因为数据上报前端主要是负责将数据传递到后端,并不过分强调前后端交互。因此我们可以通过一些支持跨域的标签去实现数据上报功能。

当我们使用 script 和 link 标签进行埋点上报时,需要挂载到页面上才会发起请求,而反复操作 dom 会造成页面性能受影响,而且载入 js/css 资源还会阻塞页面渲染,影响用户体验,因此对于需要频繁上报的埋点而言,script 和 link 标签并不合适。

使用 img 标签去做埋点上报,img 标签加载并不需要挂载到页面上,不会阻塞 html 的解析(但 img 加载后并不渲染,它需要等待 Render Tree 生成完后才一起渲染出来),直接 new image(),设置其 src 之后就可以直接请求图片。

const img = new Image()
img.src = 'https://xxx.aaa.com/obj/static/xitu_juejin_web/img/MaskGroup.13dfc4f1.png'

这样就发起了请求。

注:通常埋点上报会使用 gif 图,合法的 gif 只需要 43 个字节

但仍然有些缺点:

  1. 数据传输上可传输资源类型少
  2. 不是很规范

基于 Navigator.sendBeacon 的埋点上报

Navigator.sendBeacon 是目前通用的埋点上报方案,使用简单。该方法接受两个参数,第一个参数是目标服务器的 URL,第二个参数是所要发送的数据(可选),可以是任意类型(字符串、表单对象、二进制对象等等)。

navigator.sendBeacon(url, data)

navigator.sendBeacon() 方法以 POST 请求向指定的 URL 发送一些数据,并且不会等待响应。在页面卸载或关闭后仍然可以继续向服务器发送数据,以避免传统的同步或异步请求可能会中断或被取消的问题。

因此目前最合适的方案是 navigator.sendBeacon,不仅是异步的,而且不受同域限制,而且作为浏览器的任务,可以保证会把数据发出去,不影响页面卸载。

无埋点的埋点上报

上面的埋点方式都需要前端开发在代码里埋点,无埋点就是用可视化工具配置页面中需要被监测的元素,并设置这个元素产生行为的时候需要上报的数据。但是要让“无埋点”工作起来,页面里面还是必须嵌入了一段 JS SDK 的基础代码,只是不需要再去调用 SDK 具体的数据上报接口罢了。

Mixpanel

上面是 Mixpanel 平台的可视化工具的截图。在这个工具里,需要首先输入页面的 url,页面加载完成后,会出现可视化配置的工具条。点击创建事件,就可以进入元素选择模式,用鼠标点击页面上的某个元素(例如 button、a 这些 element),就可以在弹出的对话框里面,设置这个事件的名称(比如叫 TEST)。保存这个配置之后,如果页面在浏览器中被浏览,刚才配置的那个按钮发生点击时,就会向后台上报一个 TEST 事件。还可以设置上报 TEST 事件的时候,带上一些属性(properties),这些属性同样也是在页面中用鼠标去选择,然后保存起来的。

SDK 基础代码

和代码埋点一样,要让“无埋点”工作起来,网页里也必须有一段“基础代码”。

<!-- start Mixpanel --><script type="text/javascript">(function(e,a){if(!a.__SV){var b=window;try{var c,l,i,j=b.location,g=j.hash;c=function(a,b){return(l=a.match(RegExp(b+"=([^&]*)")))?l[1]:null};g&&c(g,"state")&&(i=JSON.parse(decodeURIComponent(c(g,"state"))),"mpeditor"===i.action&&(b.sessionStorage.setItem("_mpcehash",g),history.replaceState(i.desiredHash||"",e.title,j.pathname+j.search)))}catch(m){}var k,h;window.mixpanel=a;a._i=[];a.init=function(b,c,f){function e(b,a){var c=a.split(".");2==c.length&&(b=b[c[0]],a=c[1]);b[a]=function(){b.push([a].concat(Array.prototype.slice.call(arguments,0)))}}var d=a;"undefined"!==typeof f?d=a[f]=[]:f="mixpanel";d.people=d.people||[];d.toString=function(b){var a="mixpanel";"mixpanel"!==f&&(a+="."+f);b||(a+=" (stub)");return a};d.people.toString=function(){return d.toString(1)+".people (stub)"};k="disable time_event track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user".split(" ");for(h=0;h<k.length;h++)e(d,k[h]);a._i.push([b,c,f])};a.__SV=1.2;b=e.createElement("script");b.type="text/javascript";b.async=!0;b.src="undefined"!==typeof MIXPANEL_CUSTOM_LIB_URL?MIXPANEL_CUSTOM_LIB_URL:"file:"===e.location.protocol&&"//cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\/\//)?"https://cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js":"//cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js";c=e.getElementsByTagName("script")[0];c.parentNode.insertBefore(b,c)}})(document,window.mixpanel||[]);mixpanel.init("46042714e64a7536dde6f02af1aec923");</script><!-- end Mixpanel -->

上面是 Mixpanel 平台的基础代码,不同平台家的这段基础代码,大同小异,都是一段 IIFE 形式的、压缩过的 js 代码,执行完成之后,在 head 里面插入了一个新的 script 标签,异步去下载真正的核心 SDK 代码下来工作。

所以并不是基础代码可以根据配置上报行为,而是基础代码会下载一段“更大”的 SDK 核心代码,这段代码才是 SDK 真正的功能实现。

这样子做的好处是,基础代码很短,加载的时候不会影响到网页的性能,而且核心 SDK 代码的更新也不需要用户去更新这段基础代码。

页面的唯一标识

在配置元素行为的时候,需要唯一标识一个页面,这样才能保证 A 页面的配置,不会下发给在 B 页面,不会导致 B 页面产生出 A 页面里配置的行为。

在 Web 里面标识页面靠的是 url,url 由 protocol、domain、port、path 和参数组成,存储配置的时候要将 url 的参数提出来再存。

元素的唯一标识

唯一标识页面后,接下来就要唯一标识页面里面的元素,这样才能保证 A 页面中配置的元素 A1 可以被 SDK 找到,从而监听它产生的事件。

在 html 里面,元素是以 DOM Tree 组织的,如果沿着元素 A1 出发,一直向上记录它的 parent 和它在 parent 中的 index,直到根节点 body,那么就可以得到元素 A1 在 DOM Tree 中的唯一路径。

也可以通过元素 css、 class、id 等属性来定位元素。

此外,还有平台在标识元素的时候,采用了 xpath,这也是一个思路。

Mixpanel 的可视化工具在配置元素的时候,使用的是 https://github.com/Autarc/optimal-select 这个库来生成 element 的唯一标识的。 而 Github 上还有 https://github.com/rowthan/whats-element 这样的库,也可以生成元素在 DOM Tree 中的唯一标识。

查找元素

上面说到元素可以有唯一标识,那么有了唯一标识,就可以利用它的原理,找到这个元素。

可以通过 document.querySelector() 、document.getElementById()、document.getElementByName() 等方法实现元素的查找。

需要注意的事

如果页面在配置完成之后又发生了修改,导致 DOM Tree 发生变化,此时需要被监测的元素的唯一标识可能也会发生改变。很可能导致根据之前的配置无法找到该元素了,或者找到的并不是我们希望监测的元素,从而导致产生的事件数量发生比较明显的变化。

为了数据的稳定性和准确性,应该设有相应的监测告警处理这种 case,并提示用户去重新配置页面。

Mixpanel 平台的 Codeless Tracking,实际上采集了页面中所有页面的点击事件上报,然后在后台再去根据用户的配置计算转化数量。

这样做的好处就是如果页面变化后,用户接到告警,修改了配置,那么用于数据上报方案是全量的,所以平台是有能力将过去的数据回溯出来的。

可视化交互实现

做为一款可视化工具,肯定需要有标记元素时的高亮效果、鼠标移动到元素上时的类 hover 效果、点击元素后弹出一个对话框让用户输入配置信息等交互效果,那么如何实现这些效果呢?

如果采用向页面中动态添加元素的方式去实现可视化工具的交互界面,有可能会破坏掉页面原来的 DOM Tree 结构,从而导致生成元素唯一标识的时候出现误差,所以必须要好好小心处理。

如果可视化工具实现做的很轻,比如只是将用户的网页放在一个 iframe 里面,大部分交互都交给 iframe 的 parent 页面去处理,那也可以在配置的时候,最小程度的破坏原网页了。

Mixpanel 采用了 CustomElement 和 ShadowDOM,把可视化工具所有的功能都用自定义的 Web Component 实现了,这样自定义的元素和交互不会对用户的网页 DOM 产生影响。

触发页面原有的行为

当进入可视化配置状态时,用户点击一个元素,然后弹一个对话框,让用户对这个元素进行配置。此时如果这个元素本身的 click 行为是页面跳转,用户也可能是想进行页面跳转。或者用户要在原页面中弹出一个对话框,对话框里面有一个按钮,用户要监测这个按钮,对它做配置。

因此在可视化配置工具中,应该有两种基本交互操作。一种是让用户选中某一个元素,进行配置,另一种,是让用户可以触发页面原有的行为。

所以除了点击,需要再设计一种交互来支持用户网页中原有的点击行为。用“右键点击”或者“按住 shift+点击”之类都可以。反正不要再和网页默认的交互很容易产生冲突的方式就行。

如何能够能防止用户点击的时候页面产生跳转

DOM 的事件流分三个阶段:捕获、目标、冒泡。所以为了避免用户的点击产身页面跳转,给 document 在捕获阶段加一个 listener,拦截掉这个事件的继续分发就行了。

document.addEventListener(
'click',
e => {
// 如果是按住 shift 的点击,那么保持原有的行为
if (e.shiftKey) {
return
} // 如果是单纯的点击,那么拦截分发
e.preventDefault()
e.stopImmediatePropagation()
// 获取元素的唯一标识,然后让用户进行配置等等
this._selectElement(e.target)
},
true // useCapture 必须为 true
)

埋点行为

点击触发埋点

绑定点击事件,当点击目标元素时,触发埋点上报。

function clickButton(url, data) {
navigator.sendBeacon(url, data)
}

页面停留时间上报埋点

路由文件中,初始化一个 startTime,当页面离开时通过路由守卫计算停留时间。

let url = '' // 上报地址
let startTime = Date.now()
let currentTime = ''
router.beforeEach((to, from, next) => {
if (to) {
currentTime = Date.now()
stayTime = parseInt(currentTime - startTime)
navigator.sendBeacon(url, { time: stayTime })
startTime = Date.now()
}
})

错误监听埋点

通过监听函数去接收错误信息。

vue 错误捕获

app.config.errorHandler = err => {
navigator.sendBeacon(url, { error: error.message, text: 'vue运行异常' })
}

JS 异常与静态资源加载异常

window.addEventListener(
'error',
error => {
if (error.message) {
navigator.sendBeacon(url, { error: error.message, text: 'js执行异常' })
} else {
navigator.sendBeacon(url, { error: error.filename, text: '资源加载异常' })
}
},
true
)

请求错误捕获

axios.interceptors.response.use(
response => {
if (response.code == 200) {
return Promise.resolve(response)
} else {
return Promise.reject(response)
}
},
error => {
// 返回错误逻辑
navigator.sendBeacon(url, { error: error, text: '请求错误异常' })
}
)

内容可见埋点

通过交叉观察器去监听当前元素是否出现在页面。

// 可见性发生变化后的回调
function callback(data) {
navigator.sendBeacon(url, { target: data[0].target, text: '内容可见' })
}
// 交叉观察器配置项
let options = {}
// 生成交叉观察器
const observer = new IntersectionObserver(callback)
// 获取目标节点
let target = document.getElementById('target')
// 监听目标元素
observer.observe(target)