实现一个无埋点sdk

7,929 阅读6分钟

前端阅读室

sdk github地址 github.com/mfaying/web…

无埋点

无埋点实际是全埋点,只要嵌入sdk,就可以自动收集数据。由于不再需要额外的埋点代码,所以也可以称为无埋点。

演示

首先,让我们先来看下sdk的演示效果体验网址 (www.readingblog.cn/track/index…) 父页面(埋点管理页面)嵌入了一个iframe,指向了一个子页面(嵌入sdk的埋点页面),sdk可以自动计算点击元素的唯一标识(XPath),以及元素大小、位置等相关信息,将数据发送给后端。同时,也会将这个数据跨域发送给埋点管理页面,管理页面依据这些数据做可视化埋点工作。图中,管理页面可以获取到了元素的信息(包括大小、位置、XPath等)。

如何使用

sdk的使用方式非常简单 首先,在head标签中引入sdk代码

<script src="https://www.readingblog.cn/lib/web-log-sdk-1.1.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/lib/web-log-sdk-1.1.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上的点击事件(sdk在高版本浏览器中改为了监听事件捕获)。所以页面上的所有点击操作都会发送埋点数据。

_autoClickCollection = () => {
  event.on(doc.body, 'click', this._autoClickHandle);
}

这里就出现了一个问题,虽然这样点击操作能够触发埋点数据发送,但是我们必须确保发送的数据是有价值的。 这里最关键的是我们需要知道是页面中的哪个元素触发了用户的点击操作。由于是自动埋点,我们必须思考一种页面元素的标记方式。虽然元素有class、nodeName等标识,但这对于整个页面来说是无法唯一定位一个元素的。元素的id虽然按照规范是唯一的,但也只有个别元素会标记上id属性。 所以我们想了一种方式,由于整个html的dom结构像一棵树,对于任意元素(节点),我们先找到它的父节点,父节点再找它的父节点,这样一直回溯,就会到html(根节点元素),这样就组成了一条路径,我们将这条路径作为元素的唯一标识。当然了,如果的“XPath”反转一下,由“从父到子”的顺序排列,例如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发送一份给管理页面。这里的数据包括了元素的大小和位置、XPath等等。管理页面只要监听了"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: [],
};
  1. appID 你可以在初始化时注册一个appID,所以相关的埋点都会带上这个标记,相当于对埋点数据做了一层app维度上的管理。
  2. autoClick 默认为true,开启会自动收集点击事件(即点击无埋点)。当然你可以实现页面登录、登出、浏览时间的埋点功能,同时可以在配置中加开关控制,让用户可以有选择地启用这些功能。
  3. debug 默认不开启,开启会将埋点数据打印到控制台,便于调试。
  4. logUrl 接收日志的后端地址
  5. sdk sdk自身信息一些说明
  6. platform 默认会自动获取一些平台参数,你也可以通过配置这个字段覆盖它
  7. optParams 自定义数据

前端阅读室