跳转到正文
Jiankai
返回

从点击到停留:低代码页面的热力图实践

发布于:
前端工程

用户在页面上点了哪里,又在哪些地方停了下来?把这些行为画回页面,就会得到这样的热力图:

电脑、平板和手机网页上叠加了表示行为分布的彩色热区

以点击热图为例,聚集了较多点击的位置会形成明显的热区。我们可以直接对照按钮、图片和文字,观察用户实际操作的位置。

2022 年,我在低代码平台里负责过这套能力的建设。当时的平台已经能通过看板查询组件的曝光量和点击量,但复盘时仍要来回比对:先查出组件 ID 对应的数据,再回到页面里找它的位置和样式。即使知道一个按钮被点了多少次,也还不知道用户具体点在了哪里。

我想解决的就是这段人工比对的过程,让数据直接对应到页面内容上。下面先从几个常见的页面问题说起,再展开采集、统计和渲染的实现。

1. 为什么需要三种不同类型的热力图?

1.1 看起来像按钮,却点不动

页面里有一个奖杯图标,样式很像可点击的入口,实际上却只是装饰。用户点了几次没有反应,可能会以为页面出了问题。

左侧装饰图标形成点击热区,右侧示意点击入口提供弹窗反馈

如果只看真正按钮的点击量,这些尝试可能根本不会出现在数据里。点击热图要做的,就是把采集到的点击位置标回页面,让我们有机会发现“用户想点”和“页面允许点”的差距。

1.2 用户以为到底了,下面却还有内容

还有一种情况:页面中间出现了大块留白或明显的分隔,用户以为已经看完,便离开了。设计者放在下方的重要内容,可能还没来得及出现。

左侧下方内容容易被忽略,右侧用更连续的布局和向下箭头提示页面尚未结束

这时,页面底部没有点击,并不能说明用户对那里的内容不感兴趣。我们得先知道有多少访问到达过那里。浏览触达热图沿页面高度展示这个比例,帮助我们找到内容在哪一段开始很少被触达。

1.3 浏览到了,又停留了多久?

即使用户确实浏览到了某段内容,也可能只是一闪而过。比如一张活动规则海报,本来就不需要点击;如果内容分散、重点不明显,仅靠点击量和浏览深度,仍然很难判断哪些部分值得继续检查。

左侧内容零散,右侧尝试将相关信息集中,并突出主要内容

因此,还需要记录各个区域在可视范围里停留了多久。停留热图把这些时长的差异画出来,让我们能进一步观察重点内容是一闪而过,还是获得了较长的可见时间。

它也常被称为“注意力热图”。不过测量得到的是可见时长,不能据此确认用户认真阅读了内容。下文使用“可见停留时长”描述它。

1.4 分别记录点击、触达和停留

把上面的场景放在一起,就能看出三类热图各自补充了什么信息:

类型 采集什么 回答什么
点击热力图 点击位置与次数 用户在哪些位置发生了交互?
浏览触达热力图 每次访问的最深浏览位置 页面上的哪些位置被触达了?
可见停留热力图 各区块处于可视区域的时长 哪些区域获得了更多可见停留时间?

点击提供交互的位置,触达补充到达的范围,停留再加上时间。用哪一种、怎样组合,取决于当前想检查的是哪类问题。

2. 三种热图,从哪一种开始做?

三种热图各有用途,但第一版要一起做完,工作量就不只是前端画几张图了。采集、存储、查询和分析界面都要配合调整,后端和数据侧也需要投入。

最初的总方案考虑过独立 SDK 和自动采集。落到第一版时,我选择先利用平台已有的组件曝光和点击数据:曝光数据直接用于组件级展示,点击数据补充触点位置,再复用原来的上报和查询链路。这样能先把热图交给使用者,再扩展浏览深度和停留时长的采集。

这个选择也限定了第一版的覆盖范围:只有组件原本会上报的点击,才会进入热图。页面空白区域、装饰元素和未接入上报的组件,都不能通过这张图分析。要覆盖前面提到的装饰元素误点,还需要进一步扩大点击采集范围。

随着后续两类热图加入,整条链路可以按下面三个环节理解:

页面行为经过采集、聚合,再叠加到预览页面上的流程

  • 页面端负责采集位置、深度和时长,尽量减少对业务交互的影响。
  • 数据侧负责按筛选条件去重、聚合,返回适合展示的数据。
  • 分析端负责在预览页面中重新定位,把数值转换为颜色,同时提供具体数值。

分析端拿到数据以后,首先要解决的是位置问题:采集时发生的点击,怎样才能准确地放回预览页面?

3. 先把点击位置还原到页面上

3.1 记下了坐标,为什么还会画偏?

最直接的办法,是把点击发生时的页面坐标记下来,展示时再按这个坐标画点。但假设按钮在采集设备上距离页面顶部 1000px,在分析端却位于 1200px,原来的点就可能落到按钮之外。

低代码页面尤其容易遇到这类问题:设备宽度不同,图片等比缩放;上方模块显隐,下面的内容整体移动;Tab 切换和弹窗也会改变当前可见内容。

因此,我把点击绑定到元素上,记录元素标识、采集时的元素尺寸,以及触点在元素内部的偏移。展示时先找到同一个元素,再根据它现在的位置和尺寸还原点击。

3.2 用元素标识找到同一块内容

先看元素怎么找。平台本身拥有组件标识,可以把它作为定位锚点。对于组件内部没有独立标识的元素,当时的做法是沿 DOM 向上寻找唯一 ID,再补上组件内部的相对选择器;同类兄弟节点用 :nth-of-type() 区分。

这种方式减少了对整棵 DOM 路径的依赖,但相对选择器仍会受到结构变化影响。例如列表插入一项后,原来的第三个节点可能已经换了内容。

能够控制组件实现时,可以为需要分析的区域提供稳定标识。下面使用通用的 data-heatmap-id 举例:

<button data-heatmap-id="signup-button">立即报名</button>

这里希望找到的是同一块内容。组件内部内容发生变化时,即使选择器仍能找到节点,也需要判断旧数据是否还适合画在这里。

3.3 按元素尺寸换算点击位置

找到元素后,就可以处理尺寸变化了。将点击在元素内部的偏移除以采集时的宽高,得到它在元素内的位置比例:

nx = 点击在元素内的横向偏移 / 采集时元素宽度
ny = 点击在元素内的纵向偏移 / 采集时元素高度

例如,在 160 × 100 的元素内点击了 (40, 60),归一化位置就是 (0.25, 0.6)。展示时元素变成 240 × 150,对应偏移变为 (60, 90)。

同一触点通过横向四分之一、纵向百分之六十的位置,在不同尺寸的元素中还原

采集时直接保存这两个比例,代码可以写成:

type RelativePoint = { nx: number; ny: number };

function capturePoint(
  event: MouseEvent,
  element: HTMLElement
): RelativePoint | null {
  const rect = element.getBoundingClientRect();
  if (rect.width <= 0 || rect.height <= 0) return null;

  const nx = (event.clientX - rect.left) / rect.width;
  const ny = (event.clientY - rect.top) / rect.height;
  if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;

  return { nx, ny };
}

这里的 event 是一次实际点击事件,element 是已确定要归属的分析元素。键盘激活等没有实际指针位置的点击,需要单独处理,避免把它们误记在元素左上角。

还原时,把比例乘以元素当前的宽高,再换算到热图覆盖层内。下面以一个固定在视口、没有边框和缩放的覆盖层为例,输入与输出都使用 CSS 像素:

function projectPoint(
  point: RelativePoint,
  element: HTMLElement,
  overlay: HTMLElement
) {
  const rect = element.getBoundingClientRect();
  if (rect.width <= 0 || rect.height <= 0) return null;

  const layer = overlay.getBoundingClientRect();
  return {
    x: Math.round(rect.left - layer.left + point.nx * rect.width),
    y: Math.round(rect.top - layer.top + point.ny * rect.height),
  };
}

getBoundingClientRect() 返回相对于视口的位置,因此两个矩形可以在同一坐标系内相减。若覆盖层使用整页坐标,或内部还有滚动、缩放,就需要按对应的坐标系换算;offsetLeft、页面滚动距离和视口坐标不能直接混用。MDN 对该方法的说明

这样就处理了元素的平移和宽高缩放。页面滚动、尺寸变化或组件显隐后,重新计算元素位置,就能更新热区。元素内部重新排版、轮播内容切换或发生旋转时,则还需要额外区分内容和变换,单靠宽高比例无法全部还原。

下面是当时同一组点击数据在不同宽度下的展示效果。可以对照绿色和蓝色元素观察:页面尺寸变了,热区仍然对应元素内部的相近位置。

同一组点击热区在 768、414 和 375 三种页面宽度下的位置对比

3.4 把还原后的点画成热区

有了还原后的坐标和聚合后的点击次数,就可以开始绘制了。当时使用 heatmap.js,把这些点传给实例即可:

const heatmap = h337.create({ container: overlay, radius: 24 });

heatmap.setData({
  max: 20,
  data: [
    { x: 80, y: 120, value: 20 },
    { x: 160, y: 180, value: 6 },
  ],
});

这里的坐标和次数都是示例。库负责根据点和值绘制热区,元素定位、数据聚合和覆盖层管理仍需要由业务完成。heatmap.js API 文档

热区画出来后,比较不同日期的数据时,还要注意颜色尺度。每张图都按自己的最大值显示为红色,很容易让几十次点击和几千次点击看起来一样热,因此比较时需要统一尺度,并保留具体数值。

3.5 让热区跟随当前可见的面板

前面提到,Tab 切换也会改变页面内容。即使坐标换算正确,如果两个面板的数据同时画出来,当前页面上仍然会出现不属于这个面板的热区。

所以绘制时还要根据当前状态选择可见元素。下面是当时的演示:切换 Tab 后,面板里的热区随展示内容更新,两个面板的数据不会同时叠在一个位置上。

Tab 1 和 Tab 2 展示不同面板时,各自对应不同的点击热区
查看 Tab 切换动图
Tab 切换时,点击热区跟随可见面板更新

4. 再看用户浏览到了哪里

点击热图能呈现交互,但对于没有点击的内容,我们还需要知道用户有没有浏览到那里。浏览触达热图就用来观察这一点。

下面的 Demo 将触达率画成自上而下的颜色覆盖层。点选某个高度时,可以查看曾到达这里的样本占比。

左侧为普通页面,右侧为浏览触达热图,提示线标出当前位置的触达率

4.1 记录视口底部到过的最深位置

为了计算这个占比,需要先为每次访问记录一个最深浏览位置:视口底部到页面顶部的距离,在整个访问过程中达到过的最大值。

当前浏览深度 = min(滚动距离 + 视口高度, 页面高度)
最深浏览位置 = max(之前的最深位置, 当前浏览深度)
最深浏览距离从页面顶部量到访问过程中视口底部到过的最深位置

首次进入页面时就应记录一次,否则没有滚动便离开的访问会被遗漏。如果页面在内部容器中滚动,需要监听并测量那个容器;只监听文档滚动无法覆盖它。

为适配移动端宽度,当时方案把深度换算成 vw:

深度 vw = 深度 px / 页面宽度 px × 100

这和前面点击位置的换算有相似之处,都是为了把不同宽度下的数据放到一起处理。vw 适合整体按宽度缩放的页面;内容如果在另一种布局中重新排版,仍需要按页面版本或布局分组。

4.2 同一页面看了三次,浏览深度怎么算?

假设同一个浏览环境访问了三次页面,最深位置分别是 800、600 和 500。这里既可以保留三次访问,也可以只留下最大的 800,取决于想回答什么问题。

当时希望观察“同一浏览环境曾经到达过哪里”,所以多次访问取最大值。如果关心的是“每一次访问通常会看到哪里”,就应该按访问标识分别计算。

为了关联多次访问,方案在本地存储中保存随机标识,用它识别浏览环境。换设备、换 WebView 或清理存储都可能产生新标识,因此这里的浏览环境数不能直接当作自然人数。

下面继续采用浏览环境去重的口径。有了每个环境的最大深度,接下来就能计算各个位置的触达率,分母为同一筛选条件下,有有效深度记录的浏览环境数。

4.3 从最大深度算出各位置的触达率

假设去重后有五个浏览环境,最大深度依次为 300、330、350、350、350,单位已经统一为 vw。按最大深度统计时,350 对应的环境也会计入 300 和 330 的触达数量,所以结果如下:

深度 最深位置恰好在这里的数量 曾到达这里的数量 触达率
300 1 5 100%
330 1 4 80%
350 3 3 60%

因此,计算某个位置的触达率,需要累计所有最大深度不小于当前位置的记录:

触达率(d) = 最大深度 ≥ d 的浏览环境数 / 有有效记录的浏览环境总数

数据侧可以先返回“最大深度 → 数量”的分布,渲染前再从深到浅累计。对于样本和布局都固定的长页面,触达率应当随深度增加保持不变或下降;出现反向上升时,可以先检查分组和累计计算。

原方案还将距离按 10vw 分桶,减少查询和渲染的数据量。这会引入量化误差,四舍五入时最大约为半个桶宽。桶越大越节省数据,边界也越粗,需要结合分析精度选择。

这里还保留着一个限制:最大深度只能说明视口到达过哪里。快速跳转、锚点导航等行为可能跨过中间内容,无法据此确认路径上的每一段都被阅读过。即使确实滚动经过了某段内容,停留一瞬和停留几秒,也会得到相同的触达结果。

5. 继续看用户在这里停留了多久

要区分一闪而过和停留查看,就需要补上时间。停留热图会为页面各个区域累计可见时长,再根据时长着色。

下面是当时的 Demo。它与触达热图的外观相似,点选后显示的数值变成了当前区块的平均浏览时长。为了让这个时长对应回具体位置,采集时需要先把页面分块。

左侧为普通页面,右侧为停留热图,提示线显示当前区块的平均浏览时长

5.1 按区块累计可见时长

早期方案将整页沿纵向均分为 100 份,约每秒采样一次,为当时处于可视区域内的区块累计时长。下面的原理图用 12 个区块举例,可以看到活动说明附近的区块停留较久,下方没有停留记录的区块则为零。

活动说明附近的区块累计四到五秒,下方未停留的区块为零秒

这里也能看出,多个区块可以同时出现在视口中。用户停留一秒,三个区块都可能各增加一秒,因此各区块时长的总和可以超过页面访问时长。在同一次访问、同一计量范围内,单个区块的有效可见时长应不超过这次访问的有效时长。

不过,整页等分还有一个问题:低代码页面增删楼层后,总高度会变化,原来的区块编号就可能对应到别的内容。于是后续方案把标识调整为“楼层标识 + 楼层内序号”。例如下面的数据,表示两个楼层中若干区块的累计可见时长,单位为毫秒:

{
  "intro_1": 2000,
  "intro_2": 3000,
  "rules_1": 5000
}

这样,上方增加一个楼层后,下方区块仍然可以根据自身楼层定位。不过楼层内部内容发生重排时,原来的序号也可能改变含义,仍需要结合内容版本判断。

区块位置稳定以后,计时本身仍然是近似的:秒级采样无法还原间隔内所有细节。今天重新实现时,我会使用实际经过的时间并处理异常长间隔,避免将计时器延迟全部算成有效停留。

5.2 页面开着,就能一直算停留吗?

如果用户已经切到后台,页面仍然开着,计时器继续累加就会产生无效时长。快速滑过时也有类似问题:内容确实经过视口,却可能只出现了一瞬。

所以当时为计时增加了两类限制:页面处于前台,以及滚动速度没有超过阈值。前者用于排除后台时间,后者用于减少快速划过对结果的影响。

前台状态可以先通过 Page Visibility API 判断文档是否可见。WebView 被原生页面遮盖等情况,还需要结合宿主提供的生命周期信号验证,单靠浏览器 API 无法覆盖所有遮挡场景。MDN:Page Visibility API

再看快速滚动。它的速度可以由相邻记录计算:

速度 = abs(本次滚动位置 - 上次滚动位置) / 两次记录的时间差

阈值需要连同距离和时间单位一起定义。例如使用 CSS 像素和毫秒,结果就是 px/ms;设备宽度和页面缩放也会影响它的含义。当时的阈值来自经验选择,适合通过滚动样例观察效果,再根据实际数据调整。

这个阈值只能提供近似判断。提高过滤力度,也可能漏掉短暂的有效浏览,需要结合页面内容和采样精度取舍。

5.3 人均停留时长,应该除以多少人?

过滤之后,就可以把同一浏览环境多次访问的区块时长汇总,再计算看板上展示的人均值。不过,前面 Demo 中一个看似简单的“平均浏览时长”,到这里还需要明确分母:没有停留记录的环境是否算进去?

假设某个区块累计停留 100 秒,本次筛选有 100 个有效浏览环境,其中只有 10 个留下了该区块的有效停留记录:

指标 计算方式 结果
全体样本人均时长 100 秒 / 100 个有效浏览环境 1 秒
有停留者人均时长 100 秒 / 10 个有停留记录的浏览环境 10 秒

两者回答的问题不同。本文示例采用第一种口径,将采集有效但该区块没有时长的样本按零计入。数据采集失败的样本则不能直接视为零停留。

确定口径以后,就可以根据区块均值着色,并在点选时显示具体秒数和样本数。和点击热图一样,这里的颜色也取决于所选尺度;按本次查询最大值归一化时,表示的是相对热度,不能标成“阅读完成百分比”。

6. 把采集和预览接进平台

到这里,三种热图各自需要什么数据、怎样计算已经明确了。再回到前面的整体链路,还需要把页面端的上报和看板里的预览串起来。

6.1 等到关闭页面再上报,来得及吗?

点击、滚动和计时都可能频繁产生数据,每次变化都请求一次会增加开销;但如果只在页面关闭时发送,最后一次请求又可能没有机会完成。

当时的方案考虑了批量上报,以及页面离开、锁屏和切后台等时机。今天重新实现时,我会保留定期发送,再在页面隐藏时尝试补发剩余数据,减少最后一批数据丢失的影响。

对于少量待发送数据,可以在 visibilitychange 进入 hidden 时尝试用 sendBeacon() 提交。它返回成功表示数据进入发送队列,无法替代服务端接收确认;移动端也不能依赖 unload 一定发生。MDN 对分析数据发送时机的说明

增加发送时机以后,还需要考虑重试和重复发送。前面点击次数、最大深度和时长的聚合方式不同,上报协议也要分别约定:

  • 点击是离散事件,重试需要按事件标识去重。
  • 最深浏览位置是极值,同一统计单位内取最大值。
  • 停留时长需要区分增量和累计快照。增量去重后求和,累计快照按访问和区块取最新完整值;直接将多个快照相加会重复计算。

这也补充了前面时长汇总的前提:只有确认数据是不重复的增量,或已经还原为每次访问的最终值后,才能求和。重新实现时,我会把这些约定放进采集和数据侧共同使用的协议里。

6.2 在预览页面里叠加热区

上报和聚合解决后,再看展示端。用户从数据看板选择日期、类型等条件,查询出的结果需要对应到实际的页面上,因此方案通过预览页面叠加热图。

两边使用 iframe 和 postMessage 传递数据:分析端负责筛选和提供结果,预览端负责找到当前 DOM,并完成定位和绘制。

接入时应限定消息目标来源,并校验接收消息的来源和结构,避免把任意窗口发送的数据当作热图配置。MDN:postMessage

前面演示过 Tab 切换时热区随内容更新,这要求叠加热图后,页面仍然可以正常操作。覆盖层需要区分“浏览页面”和“分析数据”两种交互:浏览时不挡住按钮、Tab 和滚动;点选分析时才接管相应操作。页面状态变化后,再根据当前可见内容重绘。

平台里也并非每个页面都需要热图分析。因此方案把采集开关放进页面配置,由运行时决定是否初始化,减少无需分析的页面产生的数据。

6.3 按页面版本和状态拆开数据

点击定位和区块计时里都遇到过内容变化的问题。到了查询这一步,同样需要判断不同版本的数据能否合并。

例如,昨天按钮写着“了解规则”,今天改成“立即报名”。两批点击即使都能还原到同一个组件,也已经对应不同的行为含义。

当时的方案已经考虑日期和渠道筛选。今天回看,我会进一步将页面版本和必要的交互状态纳入分组:先确定这些记录对应同一类内容,再放在一起分析。

7. 回到最初的页面问题

有了点击、触达和停留数据,就可以结合页面的具体内容继续排查。以开头提到的活动规则和操作入口为例:

  • 规则说明的触达率很低,可以先检查它的位置,以及上方是否有让人误以为页面已经结束的设计。
  • 触达率较高,但停留很短,可以继续观察内容是否容易被略过,或者用户是否通过其他位置已经获得了信息。
  • 入口附近停留较长,却很少点击,可以检查操作入口和说明是否清楚,再结合其他数据判断原因。

这些都只是需要验证的假设。较长停留可能来自兴趣,也可能来自看不懂;较少点击可能是入口不明显,也可能是用户已经获得了需要的信息。

做这套方案时,我需要逐个确认:一个点对应哪个元素,一段颜色包含哪些样本,一个“人均”到底除以了什么。这些定义清楚以后,页面搭建者看到热图,才能知道某个区域的颜色意味着什么,以及下一步该去检查哪里。



上一篇
没想到你是这样的npm install
下一篇
浅谈现代浏览器的网页生命周期

微信联系我

微信号:joker_mario

Jiankai 的微信二维码,扫码添加好友保存二维码