近期一直在团队内开发可视化埋点平台,目前已经落地并成功在多个复杂业务中落地使用,在其中也踩了不少坑。故写此篇文章,给想要在团队内落地可视化埋点平台做一份参考,也是对笔者自己实现过程的一个记录。
前端埋点的常见合作模式
笔者见过的团队,埋点大致有几种合作模式。
最原始的方案:wiki 维护埋点信息,产品丢给开发一份 wiki 文件,记录了需要埋点的元素,比如按钮A,按钮B,图片C,同时如果是一个列表,比如一个音乐播放列表的下载按钮,那么产品可能还想要获取下载的音乐名称。开发拿到记录好埋点的 wiki,在点击时上报埋点,携带元素名称,比如按钮A,按钮B,而对于音乐列表的下载按钮,再携带音乐名称。
录入平台方案:基本同上一方案一致,但是产品和开发的交流,通过录入平台来维系。这一模式解决了 wiki 维护带来的后续查找不便和随意性。但是录入平台导致了产品要录入更多的信息,比如该该元素截图、附加信息、所属应用等等。而开发侧,由于平台的存在,每一个埋点与平台间,必然存在一个独一无二的 key 维护,开发上报埋点时将其携带,从而在平台上可以进行关联。
可视化录入方案:开发侧接入可视化埋点脚本,产品侧直接在可视化埋点平台进行圈选(类似 chrome inspector),圈选后埋点自动上报。
全埋点方案:开发侧接入全埋点脚本,用户的所有点击均会上报,但是由于上报的数据太多,对于存储侧的压力较大。同时由于上报的埋点比较零碎,对数据分析的要求较高。
上述的方案1和方案2,可以统称为手动埋点,其优劣如下:
手动埋点:产品侧和开发侧都有不少的繁琐的工作量。但是该方案非常灵活且埋点的准确性非常高。同时对于不同的页面状态的支持非常简单。
可视化录入:开发侧和产品侧都没有什么工作,方便的圈选模式能让产品轻易的进行埋点。但是该方案依赖可视化平台的能力,同时埋点的准确性可能会存在一定的问题。同时,如果页面有不同的状态,此时必须模拟用户进入该状态,才可以进行埋点的圈选工作
全埋点 or 无埋点:开发侧和产品侧无额外工作。但是全埋点数据收集上来之后,怎么对数据分析是一个问题,此处笔者并不了解,不做赘述。
可视化埋点平台的功能介绍
可视化埋点平台功能上的核心,便是提供用户一个可以方便对元素进行圈选的方案。用户可以对目标页面进行元素的选择,从而实现埋点的录入。大致的效果如下:
用户通过选择元素,录入名称,即可完成埋点的录入。同时,为了满足一些高级场景,可以顺带提供一颗同浏览器几乎一样的元素选择器,如下图:
与此同时,可视化埋点平台还需要具备反向圈选的能力,以便给使用者一个正向反馈,可以作为一个埋点是否正确的验证能力。如下图:
可视化埋点平台的架构设计
整体的架构图如下:
图中,共分为4个区域:
- 管理后台 + 圈选 sdk:位于图中的左上角部分。承担了可视化埋点项目维度的创建,圈选逻辑的实现,以及数据可视化等等功能,是用户能够看到的页面
- node 后台服务及数据服务:位于图中的左下角。其承担了项目的常规后台增删查改的部分,以及同已有的埋点数据对接的能力(由于笔者落地的可视化埋点,其埋点数据的存储及格式沿用了已有的平台)。
- c端页面 采集 sdk:位于图中的右上角部分,主要负责根据后台存储的该页面的埋点信息,反查元素,实现埋点数据的上报
- c端后台:位于图中的右下角部分,主要负责根据 c 端页面返回其埋点配置,由于需要应对大流量的问题,node 端单独拉了出来。
其中,2、4部分是 node 端提供的一些接口,相对而言比较常规,本文不做赘述。同时,1中项目管理、数据可视化,也是常规的前端页面,并非本文的重点。
本文将着重讨论1、3部分中,圈选功能和数据采集的实现。
核心圈选能力的设计
从表面上来看,用户的操作路径如下:
- 点击圈选按钮
- 鼠标划入目标页面,在想要埋点的元素上进行点击
- 在唤起的弹窗中录入埋点名称,即可完成埋点的录入。
- 完成录入后,自动对这些元素进行埋点。
而实际的内部核心流程如下(一些边界的场景进行了简化):
- 用户在圈选页面打开圈选开关(需要通知目标页面)
- 目标页面(以 iframe 的形式嵌在平台内)收到打开消息,开启圈选模式,此时鼠标的移动会在元素上增加浮层
- 用户点击,此时需要生成元素的唯一标识,传回给埋点平台
- 埋点平台收到消息,让用户填写埋点名称,即可完成这个埋点的录入流程。
- 真实用户打开页面后,该页面先获取录入的埋点元素标识
- 根据元素标识来反向选择到真实元素
- 监听真实元素的曝光和点击,上报埋点
可以看出: 可视化埋点平台的设计中,存在几个关键点:
- 如何获取元素的唯一标记,并可通过该标记反向选择元素
- 如何随着鼠标的移动,给元素添加浮层
- 频繁的跨域 iframe 通信,该如何简化
- 元素的曝光和点击如何同元素标记匹配上报
元素唯一标记
要实现不人工埋点,那么就需要给每一个 dom 元素进行标记,而这个标记,本文选择的便是 xpath。本质是记录其在整个 dom 树中的位置,这是一项比较久远的知识。说实话,在做可视化埋点平台前,笔者作为一名前端开发,也基本没有听说过 xpath。
举个 xpath 的实例,如下图,一个元素在 dom 树中的位置如下:
那么可以通过右击 -> 复制 -> 复制xpath/复制完整xpath:
// xpath
const xpath = '//*[@id="__next"]/div[1]/div/div/section[1]/article/h1[1]'
// 完整 xpath
const fullXpath = '/html/body/div[1]/div[1]/div/div/section[1]/article/h1[1]'
其本质是从当前元素开始,首先确定当前元素处于兄弟节点中同 tag 元素的第几个,比如上图中,其处于 h1 标签的第一个,便是 h1[1],(如果同级只有一个 h1 元素,那么 chrome 会直接丢弃 [1])。依次往上执行,一直查找到根元素,也就是 html,即可得到元素的完整 xpath。
但是 chrome 本身没有把这个方法暴露出来,故需要开发者自行实现,一个非常简易的获取元素 xpath 的代码如下:
function getXpath(ele) {
let cur = ele;
const path = [];
while (cur.nodeType === Node.ELEMENT_NODE) {
const currentTag = cur.nodeName.toLowerCase();
const nth = findIndex(cur, currentTag);
path.push(`${(cur.tagName).toLowerCase()}${(nth === 1 ? '' : `[${nth}]`)}`);
cur = cur.parentNode;
}
return `/${path.reverse().join('/')}`;
}
// 其中 findIndex 代码如下:
function findIndex(ele, currentTag) {
let nth = 0;
while (ele) {
if (ele.nodeName.toLowerCase() === currentTag) nth += 1;
ele = ele.previousElementSibling;
}
return nth;
}
通过 getXpath 方法,可以拿到元素的唯一标记,此时需要做的,便是根据元素的标记,反选回该元素,浏览器提供了原生的方法,如下:
function getEleByXpath(xpath) {
const doc = document;
const result = doc.evaluate(xpath, doc);
const item = result.iterateNext();
return item;
}
上述方法,可以返回匹配到的第一个元素,即可获得目标元素。
但是在实际的落地过程中,笔者发现了一些有趣的问题:
svg
元素,无法通过上述的getEleByXpath
进行反向选择。且监听元素曝光的 api: intersectionobserver,也不支持监听svg
元素。/div/ul
与/div[1]/ul
,对于/div
的场景,如果 dom 结构中,存在两个div
,如果第一个div
下,不存在ul
元素,而第二个div
下,存在ul
元素,那么此时getEleByXpath('/div/ul')
的第一个元素,便会命中第二个div
,会出现误判- 对于实际业务中非常常见的动态弹窗场景,由于这些动态弹窗基本都是动态挂载在
body
下,且关闭后直接就销毁,那么对于下一个动态弹窗,由于位置一致,此时非常容易判定为同一个元素,带来误判 - 对于实际业务中,常见的三元表达式的场景比如:
show ? <div /> : null
,由于show
变量的存在,导致其后面的兄弟元素的位置,会发生变化,从而导致xpath
不稳定。 - 对于实际业务中,策略组件的场景,也非常容易带来
xpath
的重叠,比如const Comp = comp[type]; return <Comp />
- 圈选功能的元素浮层,是否会对 dom 结构带来干扰,如何避免?
- 列表元素如何标记?
这些问题相对而言比较细节,留待后续文章进行讨论,此处还是以串联整个可视化埋点核心链路为准。
元素圈选功能
有了元素唯一标识,接下来便是对元素的圈选。(备注:此处参考了开源的 dom-inspector,在其基础上对多选、xpath,元素浮层等能力进行了解耦和加强,以满足实际场景的需要。)
圈选的本质,便是在用户鼠标移动的时候,在元素上层出现一个同样大小的浮层,以便用户识别。
获取用户鼠标移动和鼠标移动处的元素,在 body
上监听 mousemove
事件并取其 target
即可获取目标元素,接下来只需要获取元素的 content
大小、padding
、margin
大小及元素的位置,然后根据其位置挂载浮层即可。
获取元素大小及位置的方法很多,本文列举的仅仅是比较常规的方法(有更好的方案可以评论区讨论交流),如下:
// 获取元素的位置
function findPos(ele) {
const computedStyle = getComputedStyle(ele);
const pos = ele.getBoundingClientRect();
// left,right, top 均 不包含 margin
// 减去 margin,可以获取元素加上 margin 后,距离左侧和上侧的真实距离
const x = pos.left - parseFloat(computedStyle['margin-left']);
const y = pos.top - parseFloat(computedStyle['margin-top']);
const r = pos.right - parseFloat(computedStyle['margin-right']);
return {
top: y,
left: x,
right: r,
};
}
// 获取元素大小
export function getElementInfo(ele) {
const result = {};
const requiredValue = [
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'z-index',
];
const computedStyle = getComputedStyle(ele);
requiredValue.forEach((item) => {
result[item] = parseFloat(computedStyle[item]) || 0;
});
// 用 offsetWidth 减去元素的 border 和 padding,来获取内容的宽高
// 不用clientWidth 是因为内敛元素,该属性为0
const width = ele.offsetWidth -
result['border-left-width'] -
result['border-right-width'] -
result['padding-left'] -
result['padding-right'];
const height = ele.offsetHeight -
result['border-top-width'] -
result['border-bottom-width'] -
result['padding-top'] -
result['padding-bottom'];
result.width = width;
result.height = height;
return result;
}
其中,使用 offsetWidth
而不是 clientWidth
是因为 clientWidth
在内联元素中为 0。具体可见 clientWidth vs offsetWidth,而不使用 getBoundingClientRect().width
是因为元素用了 transform
后,该值会随 transform
发生改变,具体可见 offsetWidth vs getBoundingClientRect().width。
当然,上述代码隐藏了 svg 元素的处理,可以使用 getBoundingClientRect().width
或 svg 元素 特有的 getBBox
处理,本文不再赘述。
计算出元素的位置及宽高后,即可在其位置分别添加:元素内容区、元素 padding
区、元素 border
区、元素 margin
区,以及元素的 tag + 宽高。
综上,想要实现一个元素圈选器,那么只需要做如下操作:
body
上监听mousemove
事件(捕获阶段)- 在事件回调中,计算 target 的大小和位置
- 依次为元素添加内容、
padding
等浮层,即可完成一个元素圈选器的功能。
在实际应用中,还需要增加圈选开关来控制圈选器的打开与关闭,以免影响用户的正常点击行为,并可以增加上一次 target
的缓存来减少浮层的销毁与创建的频率等等。
iframe 通信
在圈选的过程中,平台侧需要通知 iframe 内目标页面开启圈选模式,而 iframe 中,当用户选中元素后,需要计算元素的 xpath 反向通信给平台侧。此处为了方便,后续将平台侧成为 main,而 iframe 内目标页面称为圈选 sdk。main侧记录埋点后,为了便于验证,也需要在用户 hover 到埋点上,通知圈选 sdk 进行元素反选,从而验可以对录入的埋点进行反向验证。
下图简单描述了两者之间的一个通信:
可以看出,这中间需要频繁的一个 iframe 通信,同时也是一个双端通信,故笔者结合发布订阅模式抽象了一个通用的 iframe 跨域通信类,main 和 圈选sdk 均基于此类进行上层建设。核心代码如下:
class MsgCenter {
constructor({
sender, // 在 main 中 是iframe.contentWindow || 在圈选 sdk 中则是 window.parent
receiver, // window in main and iframe
source, // current window origin
target, // target window origin
}) {
this.sender = sender;
this.receiver = receiver;
this.target = target;
this.source = source;
this.receiver.addEventListener('message', this._handleMessage.bind(this));
this.listeners = {};
}
/*
data: {
type: '' // must define
ext: {} // 用户自定义数据
source, // 源页面 origin
target, // 目标页面 origin
}
*/
sendMessage(type, ext) {
if (!this.target) return;
this.sender.postMessage(
{
source: this.source,
target: this.target,
type,
ext,
},
this.target
);
}
// 监听type类型消息
on(type, callback) {
if (this.listeners[type]) {
this.listeners[type].push(callback);
} else {
this.listeners[type] = [callback];
}
}
// 取消type类型消息的监听
off(type, callback) {
if (this.listeners[type]) {
this.listeners[type].forEach((cb, index) => {
if (cb === callback) {
this.listeners[type].splice(index, 1);
}
});
}
}
// 内部根据type分发消息
_handleMessage(event) {
const { data, origin } = event;
// 判断消息来源是否是来自于目标的页面
if (origin === this.target) {
if (this.listeners[data.type] && this.listeners[data.type].length) {
this.listeners[data.type].forEach((callback) => callback(data.ext));
}
}
}
}
在上述代码中,借助发布订阅模式,便可简化 main 和圈选 sdk 中的通信流程,具体使用方法如下:
// 在 main 中
const msgCenter = new MsgCenter({
target: iframe.origin, // iframe 页面的 origin
source: window.location.origin, // 当前页面的 origin
receiver: window,
sender: iframe.contentWindow, // 指向 iframe 的 window
});
// 向 iframe 发送消息,比如打开圈选开关等
msgCenter.sendMessage('enableSelector', {
/*
* 可传递自定义数据
*/
});
// 监听来自 iframe 消息
function receiveXpath(data) {
console.log(data.xpath);
}
msgCenter.on('targetSelect', receiveXpath);
// 移除监听
msgCenter.off('targetSelect', receiveXpath);
而圈选 sdk,无非就是 sender 不同,使用伪代码如下:
// 在 iframe 中
const msgCenter = new MsgCenter({
target: main.origin, // 圈选平台 页面的 origin
source: window.location.origin, // 当前页面的 origin
receiver: window,
sender: window.parent, // 指向圈选平台 main 的 window
});
有了上述方法,可以大大简化代码中关于 iframe 双工通信的监听与取消。
至此,整个圈选流程的核心模块即可跑通:
开启圈选时,平台侧 main 向 目标页面 iframe 发送开启圈选消息,iframe 侧收到消息,开启元素圈选功能,选中后,再传递给平台侧,完成元素埋点的录入。同时平台侧 main,在埋点列表被 hover 时,也可向 iframe 发送选中元素消息,iframe 中收到消息,选择对应元素,以便验证埋点对应的元素是否可识别。
采集侧埋点匹配
埋点一共两种类型,元素的曝光和点击,下图是其基本流程:
下面分别对其进行介绍:
首先是元素的曝光,目前基本都采用 intersectionObserver,该 api 兼容性也已经很高,同时也有 polyfill,基本上使用无虞。
对于元素的曝光埋点,理论上仅遍历一次埋点列表的 xpath,通过前文元素唯一标识介绍的方法 getEleByXpath
,找到对应的元素并使用 intersectionObserver
进行监听即可。
但是不出意外的话,意外便发生了。
在实际的应用场景中,存在 dom 树变更的场景,此时需要借助 mutationObserver 来进行 dom 树的监听
- dom 树变化,原本不存在的元素可能就出现了,原本存在的元素消失了,如果不做 unobserver 的话,会存在内存泄漏问题
- 埋点数量较多的话,遍历可能会带来性能的损耗
- dom 树频繁变化,也会带来性能损耗
对于 dom 树变化带来的新旧元素的交替,可以存储一份 xpath -> dom node 的映射,在每次遍历时,对比新旧元素,新元素进行 observer, 旧元素进行 unobserver,来避免内存泄漏,其相关细节具体可参考前文,可视化埋点平台元素曝光采集的思路—intersectionObserver的实战经验。
而对于埋点数量多,遍历可以通过 requireIdleCallback 包裹执行,不过实际并不会存在太大的性能问题。
对于 dom 树频繁变化,一方面可以在 mutationObserver 的初始化时,由于属性的变化不会带来 dom 结构的变化,故可忽略属性的变化,减少 mutationObserver 触发的频率,而另一方面,也可通过消抖节流来减少频率。
元素点击的匹配相对而言会简单一些,只需要在 body 上捕获阶段监听 click 事件,通过计算 event.target 的 xpath,同埋点列表中的 xpath 进行匹配即可判定。但是当 xpath 不完全匹配时,还需要判断点击元素,是否是埋点元素的子元素,如果是子元素,那么也算是匹配,这是因为:
如果用户录入了一个按钮的元素,而该按钮内部有文字,图片等等其他元素,那么此时点击图片、文字,均可触发该按钮的曝光,故需要对父子元素的场景进行判定,认为符合点击条件,进行上报。
小结
本章从元素唯一标识 xpath 讲起,做为圈选侧和采集侧沟通的标记。
接下来介绍了元素圈选的核心实现,通过计算元素大小、位置,在 mousemove 时为元素添加浮层,实现元素的圈选。
同时根据发布订阅模式,抽象了圈选平台页面和 iframe 页面的通信环节,为平台和 圈选 sdk 频繁通信打下基础。
最后介绍了采集侧匹配的方案,从而走通整个可视化埋点的核心链路。
可视化埋点平台系统链路
上文介绍了整体的架构和圈选部分核心的细节,本章主要对整个系统的链路进行串联,以便更好的描述整个平台的逻辑。整体结构如下图:
平台侧的埋点录入页面,配合圈选 sdk,完成埋点的录入及验证(反向圈选),采集侧 sdk 通过对埋点进行匹配,对埋点数据进行收集,最终通过 node 服务对数据聚合后,在埋点平台进行可视化的数据呈现。
还有什么能做的
如上文所述,笔者可视化埋点平台的核心,便在于元素的唯一标记 xpath、iframe 通信及圈选器能力。
该方案能够实现预期的效果,对于大部分的场景,都能够满足,但是也存在一些边界问题:
- 元素唯一标记 xpath 的不稳定性及可能的重叠问题,需要进行改进
- h5 页面依赖 iframe,而 iframe 中,运行环境毕竟是浏览器,故实际情况可能与客户端内运行不一致,且存在登陆问题(iframe 跨域不携带 cookie问题)。
- pc 端页面的支持
其中,iframe 的依赖,可以通过将通信层改造成 websocket,圈选页面可以通过扫码在移动端打开,从而模拟和实际用户一摸一样的环境,也顺带解决了登陆问题。
而对于 xpath 的不稳定性,此处边界场景太多,后续会单独出一篇文章来介绍 xpath 在实际场景中遇到的重叠、不稳定问题,而解决后的 xpath 足以支撑非常复杂的移动端 h5 页面。
最后
整体来讲,可视化埋点平台的搭建,还是比较有趣的一个项目,确实同我们平时的业务开发不同,在此过程中,笔者也熟悉了像 mutationObserver、intersectionObserver,这样的存在已久平时却也不接触的原生 api,也熟悉了出现很久的 xpath,而 dom 树的可视化、列表元素的判定,也是比较有趣的一些开发过程。
同时该平台也确实可以减轻产品/运营、开发埋点的不便,整体的方案及细节相信本文也讲的比较详细了,觉得还不错的话可以点赞鼓励下哦。