如何监控click事件

2,959 阅读6分钟

前言

最近有一个需求需要做一个监控系统,并且重点在于用户行为监控,主要目的是为了获取用户点击热力图,分析用户画像,指导迭代和运营策略调整;这其中最重要的一环就是监控click事件,click是页面触发最频繁的事件之一,那么如何监控click事件呢?

这就不得不让我想起事件模型、事件级别,事件模型就是指一个事件它先从document往子元素一步一步地向下捕获,直到目标元素,然后触发事件,之后再不断向父元素冒泡;而事件级别就是指DOM0、DOM2、DOM3事件,DOM0:直接在目标元素上设置on开头的事件处理程序,这种程序在每一个元素上只能设置一个,因此多次设置会覆盖;DOM2:通过addEventListener绑定事件,可以绑定多个;DOM3:增加了load等事件

综上所述:DOM0、DOM2是事件绑定的两种方式,这是我们在监控click事件时需要考虑的两种情况,那么下面我们依次分析这两种事件绑定方式如何监控

DOM0 click事件监控

DOM0 click冒泡事件监控

比方说,这是我们的业务代码:

<html>
  <head>
    <title>onclick事件监听</title>
  </head>
  <body>
    <button id="btn">很大很大的按钮</button>

    <script>
      // 业务代码
      const el = document.getElementById("btn");
      el.onclick = function (e) {
        e.stopPropagation();
        console.log("onclick");
      };
    </script>
  </body>
</html>

我们要在sdk中监听这个onclick事件,由于这个事件会冒泡到document,很容易想出直接在document上面绑定onclick事件,这样就监听到了子元素冒泡上来的事件

document.onclick = function (e) {
        console.log("onclick:监控到了冒泡上来的事件", e.target, e.currentTarget);
};

截屏2023-04-25 13.18.14.png

好,DOM0事件监控处理完了;发布到线上,后来从某一天开始后台就没有监控数据了,赶紧先自查一下,免得要背锅;发现自己写的没有问题啊,看了一下业务代码,只因为同事写了一个这个直接把我们之前的监控干崩了: document.onclick = function(e) { console.log("监控系统出BUG了!") }

看来onclick事件容易被业务代码覆盖,所以我们用DOM0事件绑定并不安全;那么能不能用DOM2事件来捕捉DOM0事件呢?DOM0和DOM2事件在内部处理程序是相互独立的,但是他们的相同点在于都支持冒泡,所以我们可以在根元素上绑定DOM2事件,这样就能够避免事件被修改了

document.addEventListener('click',function (e) {
    console.log("addEventListener:监控到了冒泡上来的事件", e.target, e.currentTarget);
});

截屏2023-04-25 13.26.43.png

DOM0 阻止冒泡的事件监控

那么如果在DOM0事件中阻止冒泡呢?

截屏2023-04-25 13.28.38.png

发现DOM2事件就无法捕捉到DOM0事件了,也就是说当DOM2事件阻止冒泡之后我们就无法监控该事件了,但是事实果真如此吗?我们回顾一下事件模型,捕获=>目标=>冒泡,DOM0事件阻止了冒泡,但是我们仍然可以在捕获阶段监听事件

截屏2023-04-25 13.37.30.png

综合上面的所有情况,可以利用捕获阶段的click事件来监控所有的DOM0事件

DOM2 click事件监控

DOM2 click事件在不阻止冒泡的情况下与DOM0类似,也可以通过在根元素上绑定事件监控click事件;这里主要讨论DOM2 阻止冒泡的事件监控,比如下面的业务代码:

// 业务代码
const el = document.getElementById("btn");
el.addEventListener("click", function (e) {
  // this指向el
  e.stopPropagation();
  console.log("onclick");
});

根据上面的经验,同样可以通过捕获阶段监控click事件,这样几乎所有的click事件都可以监控到,但是如果扩展到其他事件,我们又要重新写一遍,那么怎么办呢?我们看一看最火的前端框架Vue、React是怎么处理这个问题的

Vue事件源码

Vue事件源码

Vue本身是将事件处理程序进行了一个包装,然后将包装事件挂载到事件处理程序的函数上:

截屏2023-04-25 18.03.02.png

并且Vue内部是通过addEventListener绑定事件的

React事件源码

React事件源码

无独有偶,React也是通过addEventListener绑定事件的,并且也对事件处理程序进行了一层包装

通过对Vue源码以及React源码的简单分析,我们可以拦截addEventListener方法,并且对handler进行一层包装,这样就能够监听到所有通过addEventListener绑定的事件了

修改原型监听事件

要拦截addEventListener必须要先找到这个方法是哪个类(哪个接口)提供,我们查阅MDN文档发现它是EventTarget原型上的一个方法,那么我们可以直接改写掉这个方法,来实现我们的一些监听

截屏2023-04-25 17.04.29.png

  1. 保存原始方法:addEventListener
  2. 改写EventTarget.prototype.addEventListener
  3. 执行原始方法,然后在事件处理函数中套一层我们自己的逻辑
  4. 注意:在这个过程中需要做容错处理,刚好能够捕捉到一些事件错误,一举两得
(function instrumentClick() {
  function makeEventHandler(e, callback) {
    try {
      callback(e);
      console.log("instrumentClick:监听到冒泡的事件", e.target);
    } catch (error) {
      console.log("error");
    }
  }
  const originAddEventListener = EventTarget.prototype.addEventListener;
  EventTarget.prototype.addEventListener = function (type, callback, options) {
    if (typeof callback !== "function") {
      return;
    }

    if (type === "click") {
      console.log("捕捉到阻止冒泡的事件绑定");
      return originAddEventListener.call(this, type, (e) => makeEventHandler(e, callback.bind(this)), options);
    }

    return originAddEventListener.call(this, type, callback, options);
  };
})();

对未绑定事件元素的click监听与去重

以上都是针对程序中已绑定click事件的监听,而我们的监控是要全面覆盖所有click事件,因此我们需要对所有元素进行click事件监听,可以统一在document进行click事件监听,如果通过修改原型进行事件监控的话就会出现重复的事件处理函数执行,这里需要对事件对象进行一个去重处理;

主要处理逻辑:在事件触发之前记录事件对象lastCapturedEvent,下一次调用回调先判断上一次事件对象与本次事件对象是否是同一个,如果是同一个则直接退出,这样就可以将部分事件去重掉

if (!e || lastCapturedEvent === e) {
    return
}
lastCapturedEvent = e;

优化:防抖

还记得我们监控click事件的目的吗?是为了分析用户对各个模块的偏好,所以对于重复点击的事件对于我们的统计结果会造成干扰,假如一个用户在某一个按钮上连续点击了10000次,那么是不是说明所有用户都喜欢这个功能?显然不能,因此我们需要针对click事件做一个防抖处理;

let debouncedTimerId;
const INTERVAL = 1000;
document.addEventListener('click', function (e) {
  if(debouncedTimerId===undefined){
    console.log("addEventListener:监控到了捕获阶段事件", e.target, e.currentTarget);
  } 
  clearTimeout(debouncedTimerId);
  debouncedTimerId = setTimeout(()=>{
    debouncedTimerId = undefined;
  },INTERVAL);
},true);

后记

回顾一下,要监控click事件需要注意以下问题:

  1. 对DOM0、DOM2事件的监控
  2. 对于阻止冒泡事件的监控
  3. 对所有元素click事件的绑定
  4. 事件去重
  5. 防抖处理

对于这些问题有两种解决方案:

  1. 在根元素的捕获阶段绑定click事件
  2. 重写EventTarget.prototype.addEventListener,同时还需要监听根元素click事件,需要进行事件去重,对于onclick绑定的阻止了冒泡的事件无法监控,但是我们调研了主流前端框架,基本都是使用addEventListener绑定事件的,所以我们也可以忽略onclick绑定阻止冒泡的情况

在线运行本文代码