功能演示
如图所示,插件的主要功能有:
- 打开任意接过埋点sdk的页面,就可以很方便地查看页面及页面元素的pv、uv数据。
- 在右侧出现的抽屉中可进行多维度(省份、客户端、用户ID…)的筛选。
- 点击率排行、访问趋势、一键跳转神策分析等功能。
介绍
无埋点可视化插件是基于Chrome浏览器插件实现的,可以在完全不改动埋点sdk代码的情况下,将无埋点数据可视化地呈现,满足常规的页面分析需求。
由于是无埋点,每新上一个页面,投入的埋点开发量几乎为零。
技术选型
一开始我分析了growing.io的技术方案,它是通过iframe实现的。但是由于业务页面和埋点平台是跨域的,用户操作是在埋点平台上,绘制可视化数据则一定要在业务页面里做。这就必然需要埋点sdk提供两点支持:1.绘制可视化数据,2.与埋点平台可以双向通信。这会提高sdk的复杂度和耦合度,并且当时sdk还是由其他团队负责维护的,所以我们还是想尽量不改动sdk代码,于是想到了Chrome插件。
经过调研,Chrome插件的content script(内容脚本)是没有跨域问题的,也就是说理论上能实现我们的功能。
"所谓content-scripts,其实就是Chrome插件中向页面注入脚本的一种形式(虽然名为script,其实还可以包括css的),借助content-scripts我们可以实现通过配置的方式轻松向指定页面注入JS和CSS,最常见的比如:广告屏蔽、页面CSS定制,等等。"
功能实现
无埋点可视化插件在网上并没有找到类似的产品,下面介绍一下它的实现细节。
数据绘制
无埋点数据可视化是通过热力图绘制原理实现的,常规的热力图是块状的,虽然第一眼看上去美观些,但用处有限,最终还是需要展示具体的pv、uv数据。所以我们就改造了热力图,直接在元素上展示对应的uv数据,再通过色带映射体现uv值的大小(热力图其实也是这个原理)。最后,hover元素时会出现toolTip,我们在toolTip中展示该元素更丰富的数据。
下面介绍下实现的逻辑。
首先通过AppID和当前页面的url就可以查询出该页面所有元素的无埋点数据。数据格式类似这样:
const elementPvUvList = [ { "pv": 1000, "uv": 20, "xpath": "//*[@id=\"logo\"]" } ]
拿到元素的xpath,我们利用content script可以在业务页面中拿到该元素。
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; } }
拿到元素后我们就可以获取元素的位置信息进行绘制了。
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)。
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);
#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做的色带映射:
// 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;
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方法判可以断元素在视口内是否可见,我们只需绘制视口内可见元素的数据,从而获得了性能的提升。它的实现如下:
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才会出现的元素的数据呈现问题,插件解决该问题。
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);
用户交互
用户交互的部分(右侧的抽屉)实现比较容易
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。
content-script有一个很大的“缺陷”,虽然它可以操作DOM,但是无法访问页面中的JS。而且页面DOM也不能调用它,也就是无法在DOM中通过绑定事件的方式调用content-script中的代码
通过inject script到是可行,在content-script中可以通过DOM操作向页面注入inject-script:
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插件也提供了拦截浏览器发出的请求的能力:
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都可以顺利传递。
"background的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),而且它可以无限制跨域,也就是可以跨域访问任何网站而无需要求对方设置CORS。"
不过,使用background增加了代码的复杂度。因为我们的主要功能都是在content script中实现的,这就造成了请求的执行必须由content script先发送消息给backgroud,告诉说我要执行一个请求(比如queryElementPvUv),backgroud监听content script的消息,执行queryElementPvUv请求,拿到数据后再发送消息给content script。content script监听消息,拿到backgroud返回的数据,最后才能执行相应的操作。
请求流程实现如下:
// content script const sendMsg = (msg) => { chrome.runtime.sendMessage(msg); } // content script发送消息给backgroud,告诉说我要请求queryElementPvUv接口了。 sendMsg({ type: API_FETCH, apiList: [{ name: apiNames['queryElementPvUv'], params: {}, }], });
// 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, }, }); } } });
// 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组件库,它会带来两个问题:
-
如果业务页面也使用了ant design,样式会相互影响。
-
ant design会自动引入一份
~antd/lib/style/core/base.less
来初始化页面的样式,这意味着只要开启了插件,即使业务页面没有使用ant design,base.less也会影响业务页面的样式(比如a标签样式)。
问题1的解决方法和css moudule的原理一样,ant design支持自定义样式的class前缀。
在webpack中配置:
new HappyPack({ id: 'less', loaders: [ 'style-loader', { loader: 'css-loader' }, { loader: 'less-loader', options: { modifyVars: { 'ant-prefix': 'tce', }, javascriptEnabled: true, }, } ], threadPool: happyThreadPool, }),
配合ConfigProvider使用
import { ConfigProvider, } from 'antd'; const App = () => { return ( <ConfigProvider prefixCls="tce"> ... <ConfigProvider> ) }
问题2我是通过改变ant design样式的引入方式实现的。
/* 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';
import '../../antd-custom-dist.less'; const App = () => { ... }
// 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。
所以我们在发布的时候只需要改变一下版本号就可以了,用户代码也会自动更新。
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); }); } });
参考