无埋点可视化插件的实现
功能演示
如图所示,插件的主要功能有:
- 打开任意接过埋点sdk的页面,就可以很方便地查看页面及页面元素的pv、uv数据。
- 在右侧出现的抽屉中可进行多维度(省份、客户端、用户ID…)的筛选。
- 点击率排行、访问趋势、一键跳转神策分析等功能。
介绍
无埋点可视化插件是基于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 | const elementPvUvList = [ |
拿到元素的xpath,我们利用content script可以在业务页面中拿到该元素。
1 | const getElmByXPath = (xpath) => { |
拿到元素后我们就可以获取元素的位置信息进行绘制了。
1 | const vWidth = window.innerWidth, |
drawMapCtx是我们的热力图绘制容器canvas的context。
canvas的实现如下,它可以让canvas完全覆盖业务页面(scrollWidth、scrollHeight)、处于页面最顶层(z-index)、没有高清屏上模糊的问题(devicePixelRatio)、不影响页面上的事件触发(pointer-events)。
1 | const TCE_HEATMAP_CONTAINER = 'tce-heatmap-container' |
1 | #tce-heatmap-container { |
mapPointFillStyle就是借鉴热力图的实现原理对uv做的色带映射:
1 | // getColorPalette.js |
1 | import getColorPalette from '../utils/getColorPalette'; |
isElementVisible方法判可以断元素在视口内是否可见,我们只需绘制视口内可见元素的数据,从而获得了性能的提升。它的实现如下:
1 | const visibleDiffX = 1; |
数据的重绘
数据重绘的时机有scroll、resize事件触发,另外还有DOM元素变动,比如有的菜单,只有hover后才会生成实际子菜单元素,这就需要利用MutationObserver监听DOM元素变动,从而触发一次重绘,这样才能拿到子菜单的DOM元素,展示数据。
值得一提的是growing.io的热力图是基于iframe实现的,它没有去解决hover才会出现的元素的数据呈现问题,插件解决该问题。
1 | const drawMapHandle = _.debounce(() => { |
用户交互
用户交互的部分(右侧的抽屉)实现比较容易
1 | const container = document.createElement('div'); |
你可以使用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 | function injectJs(jsPath) |
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 | chrome.webRequest && chrome.webRequest.onBeforeRequest.addListener( |
跨域传递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 | // content script |
1 | // backgroud |
1 | // content script |
样式冲突问题
插件样式与业务页面会有样式冲突问题。
插件自定义的css样式可以通过css moudule来解决冲突问题。
但是插件还使用了ant design组件库,它会带来两个问题:
如果业务页面也使用了ant design,样式会相互影响。
ant design会自动引入一份
~antd/lib/style/core/base.less
来初始化页面的样式,这意味着只要开启了插件,即使业务页面没有使用ant design,base.less也会影响业务页面的样式(比如a标签样式)。
问题1的解决方法和css moudule的原理一样,ant design支持自定义样式的class前缀。
在webpack中配置:
1 | new HappyPack({ |
配合ConfigProvider使用
1 | import { |
问题2我是通过改变ant design样式的引入方式实现的。
1 | /* antd-custom-dist.less */ |
1 | import '../../antd-custom-dist.less'; |
1 | // webpack.config.js |
这样即使你开着插件,访问其他页面(比如毫不相关的百度首页),虽然content script会执行,但是base.less并不会加载,页面的样式不会受影响。
缺点就是ant design的样式必须采用全量加载的形式,增加了输出的bundle.js的体积。之前还看到另一种可以按需加载的实现方式,不过还没有实践过。
热更新
热更新的实现逻辑很简单,我们最终输出的是webpack打包后的bundle.js,我们将其上传到cdn上,并带上版本号。
用户每次加载插件的时候需要先调用接口获取版本号,再使用版本号去cdn上获取对应的bundle.js。
所以我们在发布的时候只需要改变一下版本号就可以了,用户代码也会自动更新。
1 | import axios from 'axios'; |