1. 背景
今年6月开发了一个前端热力图系统,目前已经应用在公司的主要业务中,一直没时间做个总结,现在梳理一下实现思路。
先说下为什么要做这样一个系统,电商网站的一种常用营销手段,就是配置眼花缭乱的活动页展现给用户,有的用来展示各种商品,有的供用户领取优惠券。但屏幕的展示空间有限,如何配置不同的模块才能最大化利用页面空间,一种比较好的方式就是采集用户点击数据,绘制出热力图,供产品、运营和设计同学参考,不断优化模块配置,有效提升点击转化。
2. 系统架构
先将热力图系统进行子功能拆分,可以得到以下几个部分:
- 用户点击数据采集:包括页面埋点、数据入库;
- 热力图绘制:包括:数据读取、数据加工、热力图绘制;
- 数据查询平台:主要是按日期和活动ID查询自定义区域的点击数。
2.1 数据采集
数据采集部分,主要通过事件代理在body
上绑定click
事件,采集数据主要包括:
x
:点击事件触发相对于 document 的横坐标,主要取自于event.pageX
;y
:点击事件触发相对于 document 的纵坐标,主要取自于event.pageY
;screenWidth
:点击事件触发时屏幕的宽度;screenHeight
:点击事件触发时屏幕的高度;moduleType
:表示当前元素或其父元素是否是一个fixed
元素,如是则取值screen
,否则为floor
;url
:当前活动页面的 URL;date
:当前日期。
此处代码不再赘述。获取到数据后,直接通过前端埋点方案进行数据上报,由数据组对数据进行入库。
2.2 热力图绘制
2.2.1 数据读取
现在要绘制给定 URL (通过活动ID拼接而成)活动页的热力图,需要先从数据库中获取该页面所有点击数据:
1 2 3 4 5 6 7 8 9 10 11 12 |
select x, y, screenWidth as width, screenHeight as height from heatmap where date = '${date}' and url = '${url}' and x is not null and y is not null and cast(cast(y as double) as bigint) <= cast(cast(screenHeight as double) as bigint) * ${VIEW_NUM} limit ${SAMPLE_NUM} |
这里的VIEW_NUM
表示热力图截取的屏数,因为有些页面非常长会超过一屏,所以这里增加一个配置。SAMPLE_NUM
表示样本数量,因为有的页面每天点击数量会达到几十甚至上百万,如果都绘制在热力图上会造成数据过多,且内存消耗过大。而热力图主要反映一个点击趋势,一个合理的样本数量即可达到效果。
2.2.2 数据加工 & 热力图绘制
这里将数据加工和热力图绘制放在一起介绍,主要因为加工是在绘制过程中的一个步骤中实现的,所以先介绍热力图绘制过程。
这里采用 Nightmare(一个浏览器模拟器)渲染页面,并且在页面加载前,注入两个脚本:
heatmap.js
:一个绘制热力图的前端 JS 库,提供热力图绘制工具;${date}-${activity_id}.js
:加工上一步中采集到的数据,并用数据初始化热力图工具示例。
heatmap.js 是一个非常好用的前端热力图绘制工具,有丰富的参数进行配置,有兴趣可以看下源码。绘制工具需要提供 point 数据,这就是第2个脚本做的事:
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 |
var items = ${JSON.stringify(list)}; var heatData = { data: [], min: 0, max: 25, }; /* item[0]: X item[1]: Y item[2]: screenWidth item[3]: screenHeight */ for (var i = 0; i < items.length; i++) { var item = items[i]; // 数据加工 var x = Math.round(item[0] * ${viewWidth} / item[2]); var y = Math.round(item[1] * ${viewHeight} / item[3]); heatData.data.push({ x: x, y: y, value: 1 }); } var config = { container: document.getElementById('container'), radius: 5, maxOpacity: .5, minOpacity: 0, blur: .75, }; var heatmapInstance = h337.create(config); heatmapInstance.setData(heatData); |
其中viewWidth
和viewHeight
分别表示 Nightmare 的宽高,list
是原始数据,item
数组项含义见注释。这里需要注意的是第10、11行代码,对坐标数据进行了加工。因为用户的屏幕尺寸多种多样,而 Nightmare 的尺寸是固定的,所以需要对原始坐标进行一次加工,转换为 Nightmare 中的坐标。对于原始坐标 X、Y,根据以下简单公式:
1 2 |
X / screenWidth = x / viewWidth Y / screenHeight = y / viewHeight |
得到 Nightmare 中的转换坐标 x、y,脚本中的其它配置可以参考 heapmap 的 API 文档。同时,这里需要将转换过的数据按照日期保存到一个 JSON 文件中,供后续热力图平台查数使用,数据格式如下:
1 2 3 4 |
{ "data": [{"x": 10, "y": 24},...], "viewWidth": 9527 } |
然后就是使用 Nightmare 绘制热力图的主要代码:
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 |
nightmare .viewport(viewWidth, viewHeight) .goto(url) .inject('css', './lib/heatmap-fix.css') .inject('js', './lib/heatmap.js') .inject('js', `${SCREENSHOT_PATH}/${name}.js`) .wait(5000) .evaluate(selector => { const el = document.getElementById(selector); return { height: el && el.offsetHeight || 9000, } }, 'floor-content') .then(result => { console.log(result.height); return nightmare .viewport(viewWidth, result.height > viewHeight ? viewHeight : result.height) .screenshot(`${SCREENSHOT_PATH}/${name}.png`) .end() }) .then(() => { resultFileArray.push(name); setTimeout(() => { // 发送数据邮件或其它处理 }, 200); }) .catch(err => { console.log(err); }); |
所有的数据截图和每个截图对应的 JSON 数据文件都保存在SCREENSHOT_PATH
目录下。比如我们的活动页面 URL 有固定的格式,只有 path 的最后一部分不同,表示一个活动 ID。所以在该目录下,每个活动页面对应 3 个文件。比如对应7月4日的 ID 为 6098 的活动页面,有以下几个文件:
2017-07-04-6098.js
2017-07-04-6098.json
2017-07-04-6098.png
第一个 JS 文件就是数据加工后的那段注入脚本,也对其进行了保存,PNG 图片就是最终的热力图,如下:
这里需要注意一点,在普通服务器上,因为普遍没有安装 X Server,导致无法直接执行热力图脚本来通过 Nightmare 跑热力图。可以通过安装 XVFB,执行xvfb -a
命令来执行脚本。
2.2.3 热力图平台
最后,为了让产品等同学快速按天查看对应活动的热力图,自己搭建了一个热力图平台,筛选界面如下:
输入活动 ID,选择对应的日期,即可展示对应热力图。还可以在热力图上任意框选一个范围,系统会弹窗显示框选区域内的实际点击数。主要的处理流程为:
- 用户点击“查询”按钮后,根据活动 ID 和日期参数请求 Node 接口;
- 接口根据参数判断该日期对应的热力图是否已经截取,如果已绘制,则拼接出
SCREENSHOT_PATH
目录下 PNG 截图的访问链接(通过 Nginx 配置),并且读取对应热力图的加工数据文件,一并返回给平台;如果未绘制,则限制性热力图绘制脚本,绘制对应热力图,然后执行上述逻辑; - 平台获取图片链接后进行替换展示,并在图片上方覆盖一个透明遮罩元素,监听该元素上的
mousedown / mousemove / mouseup
3个事件,根据用户框选用 Canvas 绘制半透明区域,并得到该区域的的起始和结束横纵坐标; - 筛选出接口返回坐标数据中,位于框选区域的点,并计算出总数;
- 弹窗提示。
最终弹窗展示效果如下:
浏览器端主要代码如下:
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 |
mask.on('mousedown', function (e) { drawInfo[location].finished = false; drawInfo[location].x = e.offsetX; drawInfo[location].y = e.offsetY; }); mask.on('mousemove', function (e) { if (!drawInfo[location].finished && e.offsetY >= drawInfo[location].y && e.offsetX >= drawInfo[location].x) { context.clearRect(0, 0, img.width(), img.height()); context.beginPath(); context.rect(drawInfo[location].x, drawInfo[location].y, e.offsetX - drawInfo[location].x, e.offsetY - drawInfo[location].y); context.fillStyle = 'rgba(210, 210, 210, 0.5)'; context.fill(); } }); mask.on('mouseup', function (e) { drawInfo[location].finished = true; var info = drawInfo[location]; var endX = e.offsetX, endY = e.offsetY; if (endX - info.x > 5 && endY - info.y > 5) { if (store[location].data.length) { var ret = store[location].data.filter(function(item) { return item.x >= info.x && item.x <= endX && item.y >= info.y && item.y <= endY; }); $('.modal-body').html('所选区域点击数为:<font color="red">' + ret.length + '</font><font class="text-muted">(共采集<font class="text-success">' + store[location].data.length + '</font>个点击样本)</font>'); $('.modal').modal('show'); } else { $('.modal-body').html('该热力图没有分区域点击数据'); $('.modal').modal('show'); } } }); |
Node 层接口的主要代码如下:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
// 限制截图频率 const JOB_LIMIT = 10; let result = { left: '', right: '', }; const urlPrefix = '/screenshot/'; const filePrefix = '/screenshot/'; const body = this.request.body; const params = body.data; let args = ''; for (let key in params) { if (params[key].pageNum) { const name = `${params[key].date}-${params[key].pageNum}`; if (!fs.existsSync(`${filePrefix}${name}.png`)) { args += `${params[key].pageNum} ${params[key].date} `; } else { let data = []; let viewWidth = 375; if (fs.existsSync(`${filePrefix}${name}.json`)) { try { let temp = JSON.parse(fs.readFileSync(`${filePrefix}${name}.json`)) data = temp.data; for (let i = 0; i < data.length; ++i) { let item = data[i]; item.x = Math.round(item.x * body.viewWidth / temp.viewWidth); item.y = Math.round(item.y * body.viewWidth / temp.viewWidth); } } catch (e) { } } result[key] = { url: `${urlPrefix}${name}.png?t=${Date.now()}`, data, }; } } } let screenshot = cmd => { // 限制同时执行的热力图脚本进程,避免内存开销过大 let jobNum = Number(shell.exec('ps aux|grep "xvfb-run -a"|grep picasso|wc -l').stdout.replace('\n', '')) - 1; if (jobNum <= JOB_LIMIT) { // 判断是否已经有相同命令在执行,避免重复跑相同热力图 let inProcess = Number(shell.exec(`ps aux|grep "${cmd}"|wc -l`).stdout.replace('\n', '')) - 2 > 0; if (!inProcess) { shell.cd('/project'); shell.exec(cmd, {async: true}); } } }; if (args.trim()) { const cmd = `/usr/bin/xvfb-run -a /usr/bin/node picasso.js -s ${args}`; screenshot(cmd); } this.body = result; |
其中picasso.js
就是热力图脚本,这里要注意的是在 Node 层,对于同一个热力图,应避免重复跑热力图绘制脚本,造成系统资源浪费和图片覆盖。
3. 结论
目前通过热力图系统,已经可以了解到一些用户行为,来帮助产品运营和设计做决策:
- 资源位在屏幕上的位置和资源位内容,哪个更容易影响用户点击;
- 是否有展示型元素,因为样式的原因,导致用户误以为是按钮频繁进行点击;
- 是否“更多”文案的按钮被用户频繁点击,能否将隐藏的内容进行展开,减少用户操作;
- 页面多少屏以后的内容就已经没有(或很少)点击,从而缩减页面长度,提升页面性能。
以上就是一个简单热力图系统完整的开发思路,后续也会持续优化。
注:转载注明出处并联系作者,本文链接:https://nodefe.com/heatmap-system/
HI,能否提供一下上报的源码啊