共计 5772 个字符,预计需要花费 15 分钟才能阅读完成。
序言
本文结合自身项目中的一些实践,将无埋点及可视化埋点的实现原理部分抽象整理出了一个 sdk。同时也查阅了许多相关资料,发现其实它们在无埋点的实现原理上其实大同小异。
sdk 仅介绍和实现了点击事件的无埋点,其他用户行为的埋点也相类似。
sdk github 地址 https://github.com/mfaying/we…
无埋点
无埋点实际是全埋点,只要嵌入 sdk,就可以自动收集数据。由于不再需要额外的埋点代码,所以也可以称为无埋点。
演示
首先,让我们先来看下 sdk 的演示效果体验网址 (https://www.readingblog.cn/#/…
父页面 (埋点管理页面) 嵌入了一个 iframe,指向了一个子页面(嵌入 sdk 的埋点页面),sdk 可以自动计算点击元素的唯一标识(这里命名为 ”domPath”), 以及元素大小、位置等相关信息,将数据发送给后端。同时,也会将这个数据跨域发送给埋点管理页面,管理页面依据这些数据做可视化埋点工作。图中,管理页面可以获取到了元素的信息(包括大小、位置、domPath 等)。
如何使用
sdk 的使用方式非常简单
首先,在 head 标签中引入 sdk 代码
<script src="https://www.readingblog.cn/web-log-sdk-1.0.0.min.js"></script>
然后,初始化 sdk,在初始化时你可以传入一些自定义参数。初始化完毕后,sdk 就已经在你的页面中工作了,是不是很方便!
new WebLogger.AutoLogger({debug: true,});
这里是一个简单 demo 页面,在浏览器打开这个页面。随意点击,每次点击可以在控制台中看到自动打印出的埋点数据。
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>web-log-sdk</title> | |
<script src="https://www.readingblog.cn/web-log-sdk-1.0.0.min.js"></script> | |
</head> | |
<body> | |
<div> | |
1 | |
<div id='1'> | |
2 | |
<div id="1">3</div> | |
<div>4</div> | |
</div> | |
</div> | |
<div>5</div> | |
<script> | |
new WebLogger.AutoLogger({debug: true,}); | |
</script> | |
</body> | |
</html> |
无埋点的原理
无埋点其实监听了 document.body 上的点击事件。所以页面上的所有点击操作都会发送埋点数据。
_autoClickCollection = () => {event.on(doc.body, 'click', this._autoClickHandle); | |
} |
这里就出现了一个问题,虽然这样点击操作能够触发埋点数据发送,但是我们必须确保发送的数据是有价值的。
这里最关键的是我们需要知道是页面中的哪个元素触发了用户的点击操作。由于是自动埋点,我们必须思考一种页面元素的标记方式。虽然元素有 class、nodeName 等标识,但这对于整个页面来说是无法唯一定位一个元素的。元素的 id 虽然按照规范是唯一的,但也只有个别元素会标记上 id 属性。
所以我们想了一种方式,由于整个 html 的 dom 结构像一棵树,对于任意元素 (节点),我们先找到它的父节点,父节点再找它的父节点,这样一直回溯,就会到 html(根节点元素),这样就组成了一条路径,我们将这条路径作为元素的唯一标识。当然了,如果的“domPath”反转一下,由“从父到子”的顺序排列,例如 html>body>#app。这样我们通过 document.querySelector 就可以唯一选中这个被点击的元素了。
具体实现如下:
const _getLocalNamePath = (elm) => {const domPath = []; | |
let preCount = 0; | |
for (let sib = elm.previousSibling; sib; sib = sib.previousSibling) {if (sib.localName == elm.localName) preCount ++; | |
} | |
if (preCount === 0) {domPath.unshift(elm.localName); | |
} else {domPath.unshift(`${elm.localName}:nth-of-type(${preCount + 1})`); | |
} | |
return domPath; | |
} | |
const getDomPath = (elm) => { | |
try {const allNodes = document.getElementsByTagName('*'); | |
let domPath = []; | |
for (; elm && elm.nodeType == 1; elm = elm.parentNode) {if (elm.hasAttribute('id')) { | |
let uniqueIdCount = 0 | |
for (var n = 0; n < allNodes.length; n++) {if (allNodes[n].hasAttribute('id') && allNodes[n].id == elm.id) uniqueIdCount++; | |
if (uniqueIdCount > 1) break; | |
} | |
if (uniqueIdCount == 1) {domPath.unshift(`#${elm.getAttribute('id')}`); | |
} else {domPath.unshift(..._getLocalNamePath(elm)); | |
} | |
} else {domPath.unshift(..._getLocalNamePath(elm)); | |
} | |
} | |
return domPath.length ? domPath.join('>') : null | |
} catch (err) {console.log(err) | |
return null; | |
} | |
} | |
export default getDomPath; |
代码中我们还做一些处理,比如当有多个 localName 相同的兄弟节点时,常见的例如
<ul> | |
<li>1</li> | |
<li>2</li> | |
<li>3</li> | |
</ul> |
我们通过:nth-of-type 选择器来区分。
如果有 id 属性,为了确保 id 是唯一的(规范要求必须唯一,但开发者也有可能会在无意间赋上重复的 id 属性),我们做了检查,如果是唯一的就使用 id 作为标记,这样可以提高选择器的效率。
确定了元素的唯一标识,接下来的事情就很简单了。我们只需获取所需要的埋点数据,将其发送给后端就可以了。
比如获取元素位置信息
const getBoundingClientRect = (elm) => {const rect = elm.getBoundingClientRect(); | |
const width = rect.width || rect.right - rect.left; | |
const height = rect.height || rect.bottom - rect.top; | |
return { | |
width, | |
height, | |
left: rect.left, | |
top: rect.top, | |
}; | |
} | |
export default getBoundingClientRect; |
获取平台信息
import {ua} from '../common/bom'; | |
import platform from 'platform'; | |
const getPlatform = () => {const platformInfo = {}; | |
platformInfo.os = `${platform.os.family} ${platform.os.version}` || ''; | |
platformInfo.bn = platform.name || ''; | |
platformInfo.bv = platform.version || ''; | |
platformInfo.bl = platform.layout || ''; | |
platformInfo.bd = platform.description || ''; | |
const wechatInfo = ua.match(/MicroMessenger\/([\d\.]+)/i); | |
const wechatNetType = ua.match(/NetType\/([\w\.]+)/i); | |
if (wechatInfo) {platformInfo.mmv = wechatInfo[1] || ''; | |
} | |
if (wechatNetType) {platformInfo.net = wechatNetType[1] || ''; | |
} | |
return platformInfo; | |
} | |
export default getPlatform; |
当前 url、引用 url、title、事件的触发时刻等等信息都可以补充进去。这是我的 sdk 发送的一个埋点数据
{ | |
"eventData": { | |
"et": "click", | |
"ed": "auto_click", | |
"text": "参考:Elasticsear...icsearch 2.x 版本", | |
"nodeName": "p", | |
"domPath": "html>body>#app>section>section>main>div:nth-of-type(5)>div>p>p", | |
"offsetX": "0.768987", | |
"offsetY": "0.333333", | |
"pageX": 263, | |
"pageY": 167, | |
"scrollX": 0, | |
"scrollY": 0, | |
"left": 20, | |
"top": 153, | |
"width": 316, | |
"height": 42, | |
"rUrl": "http://localhost:8080/", | |
"docTitle": "blog", | |
"cUrl": "http://localhost:8080/#/blog/article/74", | |
"t": 1573987603156 | |
}, | |
"optParams": {}, | |
"platform": { | |
"os": "Android 6.0", | |
"bn": "Chrome Mobile", | |
"bv": "77.0.3865.120", | |
"bl": "Blink", | |
"bd": "Chrome Mobile 77.0.3865.120 on Google Nexus 5 (Android 6.0)" | |
}, | |
"appID": "","sdk": {"type":"js","version":"1.0.0"} | |
} |
实现可视化圈选埋点
可视化埋点一般会使用 iframe 将埋点页面嵌入。这时子页面是埋点页面(由 iframe 引入)、父页面是管理页面。由于 iframe 的 src 属性是支持跨域加载资源的,所以任何埋点页面都是可以嵌入的。
但是要实现圈选功能,必须实现埋点页面和管理页面的通信,因为管理页面是不知道埋点信息的。而且由于埋点页面是跨域的,管理页面根本无法操作埋点页面。
这里我们就需要 sdk 实现一种通信机制了,我们采用通用的跨域通信方案 postMessage。
在 sdk 的配置项中增加一个 postMsgOpts 字段用来配置 postMessage 参数,postMsgOpts 的默认值是一个空数组,也就是说它可以允许埋点页面向多个源发送数据,而它的默认配置是不会通过 postMessage 发送数据的。
postMsgOpts 字段配置示例如下:
new AutoLogger({ | |
debug: true, | |
postMsgOpts: [{ | |
targetWindow: window.parent, | |
targetOrigin, | |
}, { | |
targetWindow: window, | |
targetOrigin: curOrigin, | |
}], | |
}); |
这样将要发送的埋点数据也会调用 postMessage api 发送一份。
postMsgOpts.forEach((opt) => {const { targetWindow, targetOrigin} = opt; | |
targetWindow.postMessage({logData: JSON.stringify(logData) }, targetOrigin) | |
}); |
我们回过头来分析演示是如何实现可视化埋点的。首先管理页面的 iframe 加载了埋点页面,由于埋点页面引入了 sdk,所以点击页面中任何元素,都会将埋点数据通过 postMessage 发送一份给管理页面。这里的数据包括了元素的大小和位置、domPath 等等。管理页面只要监听了 ”message” 事件,就可以拿到从子页面 (埋点页面) 传出来的数据了。为了交互友好,根据这些信息管理页面可以圈出 iframe 中选中的元素。当然了,只要管理页面拿到了埋点数据,就可以在这基础上和使用管理页面的用户交互,做一些自主配置同时将附加信息及选中元素的信息传递给后端,这样后端就可以对选中元素做处理了,从而实现可视化埋点。
配置项
最后介绍一下我的 sdk 的配置项,先参考一下默认配置
import getPlatform from '../../utils/getPlatform'; | |
const platform = getPlatform(); | |
export default { | |
appID: '', | |
// 是否自动收集点击事件 | |
autoClick: true, | |
debug: false, | |
logUrl: '', | |
sdk: { | |
// 类型 | |
type: 'js', | |
// 版本 | |
version: SDK_VERSION, | |
}, | |
// 平台参数 | |
platform, | |
optParams: {}, | |
postMsgOpts: [],}; |
appID 你可以在初始化时注册一个 appID,所以相关的埋点都会带上这个标记,相当于对埋点数据做了一层 app 维度上的管理。
autoClick 默认为 true,开启会自动收集点击事件(即点击无埋点)。当然你可以实现页面登录、登出、浏览时间的埋点功能,同时可以在配置中加开关控制,让用户可以有选择地启用这些功能。
debug 默认不开启,开启会将埋点数据打印到控制台,便于调试。
logUrl 接收日志的后端地址
sdk sdk 自身信息一些说明
platform 默认会自动获取一些平台参数,你也可以通过配置这个字段覆盖它
optParams 自定义数据