在前端开发中,如果我们对页面上的某个模块很关注,比如:页面上有一个 Button,我们想知道它曝光了多少次,以及这个按钮被点击了多少次。这种时候,我们就需要对这个 Button 进行监控,而监控的方式就是埋点。
一般我们做埋点需求时,会使用到埋点 sdk,sdk 暴露了一些 API 给我们去调用,下面举个例子
<div>
<button onclick="handleClick">按钮</button>
</div>
import {track} from 'sdk.js
const handleClick = () => {
// do something
// 上报数据
const data = {}
track('click',data)
}
在上述例子中,点击按钮会处理某些逻辑(跳转,计算,弹窗等等),然后调用 sdk 提供的 track 方法上报数据,第一个参数是 ‘click’,代表埋点的事件是 click 事件,第二个参数就是要上报的数据,数据结构可根据实际需求定义。
上述埋点方式叫做代码埋点,我们直接在 Button 的点击事件中手动调用 sdk 的 track() 方法来上报数据。这种方法的不好之处就是业务逻辑和埋点代码耦合在一起,如果我后续我想对按钮的曝光事件(按钮出现在视口中,即按钮被用户看到)也进行监控,就得继续加曝光埋点代码,如下:
const handleClick = () => {
// do something
// 上报数据
const data = {}
track('click',data) // 点击事件上报
track('impression',data) // 曝光事件上报
}
那么有没有什么方式可以把埋点逻辑抽离出来,让 handleClick 函数只关注它该做的事情?答案是无痕埋点。
无痕埋点的定义:
无痕埋点是一种在应用程序或网站中实现数据收集和分析的技术手段。传统的数据埋点通常需要在代码中明确地插入埋点代码,但无痕埋点可以通过使用可视化界面、配置文件或 SDK 等方式实现,而不需要修改代码。
下面我们通过设计 sdk 的方式来聊聊无痕埋点。
上述代码埋点例子中,我们想把埋点逻辑从 handleClick 函数中取出来,那么我们的 sdk 内部就必须实现两点:
点击事件监听可以通过 addEventListener(‘click’) 方法,埋点上报数据可以通过 dataset 属性来绑定。代码如下:
<div>
<button
data-id="123"
data-click-data="{pointId: 123,data: '按钮点击了一次'}"
>
按钮
</button>
</div>
<script type="text/javascript">
window.addEventListener('click',(e) => {
if (e.target.dataset.id) {
console.log(e.target.dataset.id)
console.log(e.target.dataset.clickData)
}
})
</script>
上述代码中,我们给 button 绑定了两个 dataset 属性:data-id=‘123’、data-click-data=“{pointId: 123,data: ‘按钮点击了一次’}”。其中 data-id 的值是当前 button 的一个点位 id,一个页面上可能会有很多点位,每个点位有一个点位 id 作为该点位的唯一标识;data-click-data 的值,就是当当前点位触发点击事件时,需要上报的数据。
接下来我们在 script 中监听点击事件,当触发事件的目标元素是需要埋点的元素(data-id属性不为空),处理实际的上报逻辑。
至此,我们简单实现了 button 的点击事件的无痕埋点,对比代码埋点,我们不需要在 button 的 handleClick 函数(如有)中手动调用埋点 sdk 提供的方法上报数据,只需在 button 元素上绑定 sdk 规定的属性和数据格式(这些属性的定义可以根据实际项目中的规范来定义)即可完成埋点需求。
上述点击埋点场景是最基础的埋点,目标元素是一个确定的 button。假设目标元素是动态生成的,怎么办?再假设目标元素是动态生成的,同时触发点击事件的是目标元素的子元素,又怎么办?
场景:比如商城有一个水果类目,每天可售的水果种类是不一样,现在想知道每天水果类目被点击了多少次?
针对这种场景,实际的点击事件是在每种水果上触发的,但我们不可能给每种水果都绑上一个点位,因此我们可以使用事件代理(委托)的方式来实现,把点击事件委托给父元素来处理,这样我们只需给父元素绑定点位即可,代码如下:
<ul
data-id="456"
data-click-data="{pointId: 456,data: '点击了一次'}"
>
<li>苹果</li>
<li>芒果</li>
<li>香蕉</li>
...
</ul>
<script type="text/javascript">
window.addEventListener('click',(e) => {
const parent = e.target.parentNode
if (parent.dataset.id) {
console.log(parent.dataset.id)
console.log(parent.dataset.clickData)
}
})
</script>
上述代码中,我们给父元素 ul 绑定了埋点属性:data-id、data-click-data,由于实际触发点击事件的是子元素 li ,所以在 script 中,我们需要通过 parentNode 来找到父元素 e.target.parentNode。
场景:在上述商城水果场景中,我们添加条件:每种水果下面还有分类,比如:苹果有青苹果和红苹果两种,芒果有海南芒果和本地芒果。
针对这种场景,我们同样是使用事件代理的方式实现。
代码如下:
<ul
data-id="789"
data-click-data="{pointId: 789,data: '点击了一次'}"
>
<li>
苹果
<ul>
<li>青苹果</li>
<li>红苹果</li>
</ul>
</li>
<li>
芒果
<ul>
<li>本地芒果</li>
<li>海南芒果</li>
</ul>
</li>
<li>香蕉</li>
...
</ul>
<script type="text/javascript">
window.addEventListener('click',(e) => {
const parent = e.target.parentNode.parentNode.parentNode
if (parent.dataset.id) {
console.log(parent.dataset.id)
console.log(parent.dataset.clickData)
}
})
</script>
在上述代码中,假设我们点击目标元素的子元素(比如点击青苹果),我们需要多次调用 parentNode 才能找到最外层实际绑定点位的 ul 元素。如果目标元素没有子元素,比如香蕉,这时我们的代码就不能工作了,所以我们需要编写一个额外的函数来递归地向上寻找实际绑定点位的元素 parentNode。此外,我们还需要考虑,目标元素就是实际绑定点位的元素,比如前面讲到的 button 埋点例子。
基于以上考量,我们来重新设计一下 sdk 的代码,如下:
<script type="text/javascript">
let layer = 1
function findParent(target) {
if (layer > 3) {
layer = 1
return
}
// 找到实际绑定点位的祖先元素就返回
if (target?.parentNode && target?.parentNode?.dataset && target?.parentNode?.dataset?.id) {
layer = 1
return target.parentNode
}
layer++
// 递归向上寻找
if (target?.parentNode) {
return findParent(target.parentNode)
}
}
window.addEventListener('click',(e) => {
let targetNode // 实际绑定点位的元素
// 如果当前元素就是实际绑定点位的元素
if (e.target?.dataset?.id) {
targetNode = e.target
} else { // 否则向上寻找
targetNode = findParent(e.target)
}
// 如果存在需要埋点的目标元素,则处理对应的上报逻辑
if (targetNode) {
console.log(targetNode.dataset.id)
console.log(targetNode.dataset.clickData)
}
})
</script>
上述代码中,我们在 click 事件监听器的回调函数里定义了变量 targetNode ,用来保存实际绑定点位的元素,然后判断当前元素是否绑定了点位,如果是,说明已经找到埋点元素,那么就直接赋值 targetNode = e.target,否则调用 findParent() 函数来向上寻找实际绑定点位的元素。
由于我们的 addEventListener() 方法是挂载在 window 对象上的,所以页面上的任何点击事件都会被监听到,假设我们在页面上有一个嵌套很深的元素,它的祖先元素并没有埋点的需求,那么点击这个元素后,findParent() 函数会一直递归,直到 document 元素。这个消耗其实是没有必要的,所以我们定义全局变量 layer(初始为1,即向上找一层) 来控制向上查找的次数,当 layer 超过三层之后还找不到 targetNode ,我们就认为当前点击事件不会触发埋点,直接 return,终止递归,同时也要重置 layer。layer 的限制可根据实际情况设定。当找到 targetNode 后,也需要重置 layer。
埋点的设计必然会包含曝光事件(impression),因为一个点位必须得先曝光(出现在视口中,被用户看到),才可能有后续的操作,比如点击。那么我们怎么知道目标元素是否进入视口呢?最简单的方法是使用浏览器的原生 API :IntersectionObserver,前面我写过一篇关于 IntersectionObserver 文章:如何使用 IntersectionObserver API 来实现数据的懒加载?(以 Vue3 为例),感兴趣的朋友可以看看。
曝光埋点代码如下:
<button
name="impression"
data-id="123"
data-click-data="{pointId: 123,data: '按钮点击了一次'}"
data-impression-data="{pointId: 123,data: '按钮曝光了一次'}"
>
按钮
</button>
<a
href="https://www.baidu.com"
name="impression"
data-id="456"
data-click-data="{pointId: 456,data: '链接了一次'}"
data-impression-data="{pointId: 456,data: '链接曝光了一次'}"
>
链接
</a>
<script type="text/javascript">
const observerCallback = (entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) { // 被观察者进入视口
console.log(entry.target.dataset.impressionData)
}
});
}
// 初始化观察者实例
const observer = new IntersectionObserver(observerCallback)
// 获取需要进行曝光埋点的元素
const observeElement = [...document.getElementsByName('impression')]
// 对每一个元素进行观察
observeElement.forEach((targetElement) => observer.observe(targetElement))
</script>
上述代码中,我们给需要进行曝光埋点的元素(observeElement )加上 name 属性标识:name=“impression”,同时通过dataset 绑定曝光时需要上报的数据(data-impression-data)。在 script 中,我们实例化一个 IntersectionObserver 实例,通过 document.getElementsByName(‘impression’) 获取页面上的 observeElement ,最后循环对每一个元素建立观察。
上面我们简单地分析和实现了怎么对 click 事件和 impression 事件进行无痕埋点的 sdk,但在实际的 sdk 设计过程中,需要考虑的条件还有很多,比如浏览器的兼容性(IntersectionObserver 的兼容性不够好)、sdk 方法的封装(上面我们只是简单写在 script 中)、还需设计页面的 uv、pv 、停留时间的埋点方法等等。这里我就不做展开了,因为我也没实际设计过埋点 sdk(哈哈哈哈哈哈),很多细节也不曾了解,只是基于以前在项目中使用过别人写好的 sdk,做了一些个人的猜测和思考,如有错误,请诸君多多包涵~