无埋点可视化插件的实现

时间:2021-1-8 作者:admin

功能演示

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

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

介绍

无埋点可视化插件是基于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组件库,它会带来两个问题:

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

  2. 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);
    });
  }
});

参考

  1. 【干货】Chrome插件(扩展)开发全攻略
  2. heatmap.js
声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。