无埋点可视化插件的实现

无埋点可视化插件的实现

功能演示

如图所示,插件的主要功能有:

  1. 打开任意接过埋点sdk的页面,就可以很方便地查看页面及页面元素的pv、uv数据。
  2. 在右侧出现的抽屉中可进行多维度(省份、客户端、用户ID…)的筛选。
  3. 点击率排行、访问趋势、一键跳转神策分析等功能。

介绍

无埋点可视化插件是基于Chrome浏览器插件实现的,可以在完全不改动埋点sdk代码的情况下,将无埋点数据可视化地呈现,满足常规的页面分析需求。

由于是无埋点,每新上一个页面,投入的埋点开发量几乎为零。

技术选型

一开始我分析了growing.io的技术方案,它是通过iframe实现的。但是由于业务页面和埋点平台是跨域的,用户操作是在埋点平台上,绘制可视化数据则一定要在业务页面里做。这就必然需要埋点sdk提供两点支持:1.绘制可视化数据,2.与埋点平台可以双向通信。这会提高sdk的复杂度和耦合度,并且当时sdk还是由其他团队负责维护的,所以我们还是想尽量不改动sdk代码,于是想到了Chrome插件。

经过调研,Chrome插件的content script(内容脚本)是没有跨域问题的,也就是说理论上能实现我们的功能。

1
"所谓content-scripts,其实就是Chrome插件中向页面注入脚本的一种形式(虽然名为script,其实还可以包括css的),借助content-scripts我们可以实现通过配置的方式轻松向指定页面注入JS和CSS,最常见的比如:广告屏蔽、页面CSS定制,等等。"

功能实现

无埋点可视化插件在网上并没有找到类似的产品,下面介绍一下它的实现细节。

数据绘制

无埋点数据可视化是通过热力图绘制原理实现的,常规的热力图是块状的,虽然第一眼看上去美观些,但用处有限,最终还是需要展示具体的pv、uv数据。所以我们就改造了热力图,直接在元素上展示对应的uv数据,再通过色带映射体现uv值的大小(热力图其实也是这个原理)。最后,hover元素时会出现toolTip,我们在toolTip中展示该元素更丰富的数据。

下面介绍下实现的逻辑。

首先通过AppID和当前页面的url就可以查询出该页面所有元素的无埋点数据。数据格式类似这样:

1
2
3
4
5
6
7
const elementPvUvList = [
{
"pv": 1000,
"uv": 20,
"xpath": "//*[@id=\"logo\"]"
}
]

拿到元素的xpath,我们利用content script可以在业务页面中拿到该元素。

1
2
3
4
5
6
7
8
9
10
11
12
const getElmByXPath = (xpath) => {
if (!xpath) {
return null;
}
try {
const result = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
return result.iterateNext();
} catch (e) {
console.error('getElmByXPath err is: ', e.toString());
return null;
}
}

拿到元素后我们就可以获取元素的位置信息进行绘制了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const vWidth  = window.innerWidth,
vHeight = window.innerHeight,
scrollX = document.documentElement.scrollLeft,
scrollY = document.documentElement.scrollTop;

elementPvUvList.forEach((item) => {
const pv = Number(item.pv);
const uv = Number(item.uv);

const elm = getElmByXPath(item.xpath)
const rect = getBoundingClientRect(elm);
// isElementVisible 判断元素是否可见,后面会介绍
if (!isElementVisible(elm, rect, vWidth, vHeight)) {
return;
}
const { left, top } = rect;

// mapPointFillStyle 色带映射,后面会介绍
drawMapCtx.fillStyle = mapPointFillStyle;
const mapPointWidth = String(pv).length * 8.5;
drawMapCtx.fillRect(left + scrollX, top + scrollY, mapPointWidth, 12);
drawMapCtx.fillStyle = '#fff';
drawMapCtx.fillText(pv, left + scrollX, top + scrollY);
});

drawMapCtx是我们的热力图绘制容器canvas的context。

canvas的实现如下,它可以让canvas完全覆盖业务页面(scrollWidth、scrollHeight)、处于页面最顶层(z-index)、没有高清屏上模糊的问题(devicePixelRatio)、不影响页面上的事件触发(pointer-events)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const TCE_HEATMAP_CONTAINER = 'tce-heatmap-container'

const body = document.body;
const width = body.scrollWidth;
const height = body.scrollHeight;
const canvas = document.createElement('canvas');
const firstChild = document.body.children[0];
canvas.id = TCE_HEATMAP_CONTAINER;
document.body.insertBefore(canvas, firstChild);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const dpr = window.devicePixelRatio;
canvas.width = dpr * width;
canvas.height = dpr * height;
const drawMapCtx = document.getElementById(TCE_HEATMAP_CONTAINER).getContext('2d');
drawMapCtx.scale(dpr, dpr);
1
2
3
4
5
6
7
8
9
#tce-heatmap-container {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 2000000000;
background-color: rgba(0, 0, 0, 0);
pointer-events: none;
}

mapPointFillStyle就是借鉴热力图的实现原理对uv做的色带映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// getColorPalette.js

let colorPalette = null;

const getColorPalette = () => {
if (colorPalette) {
return colorPalette;
}
const gradientConfig = { 0.25: 'rgb(0,0,255)', 0.55: 'rgb(0,255,0)', 0.85: 'yellow', 1.0: 'rgb(255,0,0)' };
const paletteCanvas = document.createElement('canvas');
const paletteCtx = paletteCanvas.getContext('2d');

paletteCanvas.width = 256;
paletteCanvas.height = 1;

const gradient = paletteCtx.createLinearGradient(0, 0, 256, 1);
for (const key in gradientConfig) {
gradient.addColorStop(key, gradientConfig[key]);
}

paletteCtx.fillStyle = gradient;
paletteCtx.fillRect(0, 0, 256, 1);

colorPalette = paletteCtx.getImageData(0, 0, 256, 1).data
return colorPalette;
};

export default getColorPalette;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import getColorPalette from '../utils/getColorPalette';

const OPACITY = 0.45;

const uvList = elementPvUvList.map((item) => Number(item.uv));
const maxUV = Math.max(...uvList);
const minUV = Math.min(...uvList);
const colorPalette = getColorPalette();

elementPvUvList.forEach(item => {
const pv = Number(item.pv);
let alpha = Math.ceil((uv - minUV) * 256 / (maxUV - minUV));
alpha = alpha < 1 ? 1 : alpha;
const r = colorPalette[alpha * 4 - 4];
const g = colorPalette[alpha * 4 - 3];
const b = colorPalette[alpha * 4 - 2];
const mapPointFillStyle = `rgba(${r}, ${g}, ${b}, ${OPACITY})`;
})

isElementVisible方法判可以断元素在视口内是否可见,我们只需绘制视口内可见元素的数据,从而获得了性能的提升。它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const visibleDiffX = 1;
const visibleDiffY = 2;
const elementFromPoint = (x, y) => document.elementFromPoint(x, y);
const isElementVisible = (el, rect, vWidth, vHeight) => {
if (
rect.right < 0 ||
rect.bottom < 0 ||
rect.left > vWidth ||
rect.top > vHeight
) return false;

const rectCt = elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
if (el.contains(rectCt)) {
return true;
}

const rectLT = elementFromPoint(rect.left + visibleDiffX, rect.top + visibleDiffY);
if (el.contains(rectLT)) {
return true;
}

const rectRT = elementFromPoint(rect.right - visibleDiffX, rect.top + visibleDiffY);
if (el.contains(rectRT)) {
return true;
}
}

数据的重绘

数据重绘的时机有scroll、resize事件触发,另外还有DOM元素变动,比如有的菜单,只有hover后才会生成实际子菜单元素,这就需要利用MutationObserver监听DOM元素变动,从而触发一次重绘,这样才能拿到子菜单的DOM元素,展示数据。

值得一提的是growing.io的热力图是基于iframe实现的,它没有去解决hover才会出现的元素的数据呈现问题,插件解决该问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const drawMapHandle = _.debounce(() => {
drawMap();
}, 500);

window.addEventListener('scroll', () => {
drawMapHandle();
});
window.addEventListener('resize', () => {
drawMapHandle();
});

const docObserver = new MutationObserver(() => {
drawMapHandle();
});
const options = {
childList: true,
characterData: true,
subtree: true,
};
docObserver.observe(document.body, options);

用户交互

用户交互的部分(右侧的抽屉)实现比较容易

1
2
3
4
const container = document.createElement('div');
container.id = TCE_CONTAINER_ID;
document.body.appendChild(container);
render(<App />, document.getElementById(TCE_CONTAINER_ID));

你可以使用react在<App />中自由地编写交互功能,我们使用的是react+ant design。当然你也可以使用其他技术栈比如vue去开发插件,插件开发并不会限制框架,只要最终可以编译输出js、css就可以了。

AppID获取

我们有个类似于AppID参数,只有获取到这个参数才好进行无埋点数据的查询。这个参数从业务页面sdk封装的方法上是可以获取到的,然而content script虽然可以操作业务页面的DOM,可无法操作业务页面的js(window也不是同一个,js运行环境是隔离的),所以无法直接获取AppID。

1
content-script有一个很大的“缺陷”,虽然它可以操作DOM,但是无法访问页面中的JS。而且页面DOM也不能调用它,也就是无法在DOM中通过绑定事件的方式调用content-script中的代码

通过inject script到是可行,在content-script中可以通过DOM操作向页面注入inject-script:

1
2
3
4
5
6
7
8
9
10
11
12
function injectJs(jsPath)
{
const jsPath = jsPath || 'js/inject.js';
const script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.src = chrome.extension.getURL(jsPath);
script.onload = () => {
// 执行完移除掉
script.remove()
};
document.head.appendChild(script);
}

jsPath指向的就是inject-script,它可以操作页面的js,和写在页面里的js没有什么区别。这样它就可以调用sdk封装的方法拿到AppID了。拿到AppID后,可以利用DOM作为媒介(比如将AppID放到某个DOM的属性上),这样content script就可以从DOM上拿到AppID。

这样显然是比较绕的,而且content script何时能拿到AppID的时机也不好确定,并且还有个问题是不同版本的sdk获取AppID的方式不尽相同。所以最终没有采用这种方式,而是通过拦截http请求,从埋点请求的body中获取AppID(body中AppID的位置在不同版本的sdk中是一致的)。

chrome插件也提供了拦截浏览器发出的请求的能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
chrome.webRequest && chrome.webRequest.onBeforeRequest.addListener(
(details) => {
const bytes = details.requestBody.raw[0].bytes;
const reader = new FileReader();
reader.readAsText(new Blob([bytes]), 'utf-8');
reader.onload = () => {
// log就是http body里的内容,从中可以获取到AppID
const log = JSON.parse(reader.result);
reader.abort();
}
},
{
urls:[...TRACK_LOG_URL_LIST],
},
['requestBody']
)

跨域传递Cookie失效

插件查询无埋点数据的接口在content script中必须跨域传递Cookie才能通过鉴权,然而不知道什么原因,同一份代码,有的人可以传递Cookie,而有的人不行。

后来我采用Chrome插件的background(后台)来解决的这个问题,在background中发出的请求,Cookie都可以顺利传递。

1
"background的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),而且它可以无限制跨域,也就是可以跨域访问任何网站而无需要求对方设置CORS。"

不过,使用background增加了代码的复杂度。因为我们的主要功能都是在content script中实现的,这就造成了请求的执行必须由content script先发送消息给backgroud,告诉说我要执行一个请求(比如queryElementPvUv),backgroud监听content script的消息,执行queryElementPvUv请求,拿到数据后再发送消息给content script。content script监听消息,拿到backgroud返回的数据,最后才能执行相应的操作。

请求流程实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// content script

const sendMsg = (msg) => {
chrome.runtime.sendMessage(msg);
}

// content script发送消息给backgroud,告诉说我要请求queryElementPvUv接口了。
sendMsg({
type: API_FETCH,
apiList: [{
name: apiNames['queryElementPvUv'],
params: {},
}],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// backgroud

const queryElementPvUv = async (params) => {
return await post(queryElementPvUvUrl, params);
}

const apis = {
queryElementPvUv,
}

const sendMsg = (msg) => {
chrome.tabs.query({windowType: 'normal'}, tabs => {
const postMessage = (tabId) => {
if (tabId > -1) {
const port = chrome.tabs.connect(tabId, {name: TCE_API_CONNECT});
port.postMessage(msg);
}
}

if (tabs.length >= 1) {
tabs.forEach(tab => {
postMessage(tab.id);
})
}
})
}

chrome.runtime && chrome.runtime.onMessage && chrome.runtime.onMessage.addListener(async (request) => {
const { type, apiList } = request;
if (type === API_FETCH && Array.isArray(apiList)) {
// backgroud监听到了content script发过来的消息,执行queryElementPvUv请求
for (const api of apiList) {
const { name, params } = api;
const res = await apis[name](params);
// 拿到请求返回的数据后发送消息给content script
sendMsg({
type: API_FETCH,
payload: {
apiName: name,
...res,
},
});
}
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// content script
chrome.runtime.onConnect.addListener((port) => {
if (port.name === TCE_API_CONNECT) {
port.onMessage.addListener((msg) => {
const { type } = msg;
if (type === API_FETCH) {
const { payload } = msg;
const { apiName } = payload;
if (apiName === apiNames['queryElementPvUv']) {
// content script监听到了backgroud发过来的请求返回数据,执行相应逻辑
apiQueryElementPvUvHandle(msg);
}
}

});
}
});

样式冲突问题

插件样式与业务页面会有样式冲突问题。

插件自定义的css样式可以通过css moudule来解决冲突问题。

但是插件还使用了ant design组件库,它会带来两个问题:

  1. 如果业务页面也使用了ant design,样式会相互影响。

  2. ant design会自动引入一份~antd/lib/style/core/base.less来初始化页面的样式,这意味着只要开启了插件,即使业务页面没有使用ant design,base.less也会影响业务页面的样式(比如a标签样式)。

问题1的解决方法和css moudule的原理一样,ant design支持自定义样式的class前缀。

在webpack中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
new HappyPack({
id: 'less',
loaders: [
'style-loader',
{
loader: 'css-loader'
}, {
loader: 'less-loader',
options: {
modifyVars: {
'ant-prefix': 'tce',
},
javascriptEnabled: true,
},
}
],
threadPool: happyThreadPool,
}),

配合ConfigProvider使用

1
2
3
4
5
6
7
8
9
10
11
import {
ConfigProvider,
} from 'antd';

const App = () => {
return (
<ConfigProvider prefixCls="tce">
...
<ConfigProvider>
)
}

问题2我是通过改变ant design样式的引入方式实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* antd-custom-dist.less */

@import '~antd/lib/style/themes/index.less';
@import '~antd/lib/style/mixins/index.less';

*[class*='tce-'] {
@import '~antd/lib/style/core/base.less';
}

@import '~antd/lib/style/core/iconfont.less';
@import '~antd/lib/style/core/motion.less';

@import '~antd/lib/style/components.less';
1
2
3
4
5
import '../../antd-custom-dist.less';

const App = () => {
...
}
1
2
3
4
5
6
// webpack.config.js
resolve: {
alias: {
'antd/dist/antd.less$': path.resolve(__dirname, '../src/antd-custom-dist.less')
}
},

这样即使你开着插件,访问其他页面(比如毫不相关的百度首页),虽然content script会执行,但是base.less并不会加载,页面的样式不会受影响。

缺点就是ant design的样式必须采用全量加载的形式,增加了输出的bundle.js的体积。之前还看到另一种可以按需加载的实现方式,不过还没有实践过。

热更新

热更新的实现逻辑很简单,我们最终输出的是webpack打包后的bundle.js,我们将其上传到cdn上,并带上版本号。

用户每次加载插件的时候需要先调用接口获取版本号,再使用版本号去cdn上获取对应的bundle.js。

所以我们在发布的时候只需要改变一下版本号就可以了,用户代码也会自动更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import axios from 'axios';
import { CDN_URL, QUERY_VERSION_URL } from '../../../constant';

const env = process.env.NODE_ENV;
const queryVersionUrl = QUERY_VERSION_URL[env];
const heatmapUrl = CDN_URL.heatmap;

axios.get(queryVersionUrl).then((res) => {
res = res.data;
if (res.code === 0) {
const version = res.data;
const versionUrl = `${heatmapUrl}-${version}.js`
axios.get(versionUrl).then((res) => {
eval(res.data);
});
}
});

参考

  1. 【干货】Chrome插件(扩展)开发全攻略
  2. heatmap.js
0%