首页/文章/javascript

如何实现元素的曝光监测

2024-07-15
12865 分钟
...

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:霁明

一些名词解释

曝光
页面上某一个元素、组件或模块被用户浏览了,则称这个元素、组件或模块被曝光了。
视图元素
将页面上展示的元素、组件或模块统称为视图元素。
可见比例
视图元素在可视区域面积/视图元素整体面积。
有效停留时长
视图元素由不可见到可见,满足可见比例并且保持可见状态的持续的一段时间。
重复曝光
在同一页面,某个视图元素不发生DOM卸载或页面切换的情况下,发生的多次曝光称为重复曝光。例如页面上某个视图元素,在页面来回滚动时,则会重复曝光。

如何监测曝光

需要考虑的一些问题

曝光条件
页面上某一视图元素的可见比例达到一定值(例如0.5),且有效停留时间达到一定时长(例如500ms),则称该视图元素被曝光了。
如何检测可见比例
使用 IntersectionObserver api 对元素进行监听,通过 threshold 配置项设置可见比例,当达到可见比例时,观察器的回调就会执行。 IntersectionObserver 使用示例:

let callback = (entries, observer) => {
  entries.forEach((entry) => {
    
    
    
    
    
    
    
    
  });
};
let options = {
  threshold: 1.0,
};
let observer = new IntersectionObserver(callback, options);

let target = document.querySelector("#listItem");
observer.observe(target);

如何监听动态元素
使用 IntersectionObserver 对元素进行监听之前,需要先获取到元素的 DOM,但对于一些动态渲染的元素,则无法进行监听。所以,需要先监听DOM元素是否发生挂载或卸载,然后对元素动态使用IntersectionObserver 进行监听,可以使用 MutationObserver 对 DOM变更进行监听。 MutationObserver的使用示例:

const targetNode = document.getElementById("some-id");


const config = { attributes: true, childList: true, subtree: true };


const callback = function (mutationsList, observer) {
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};


const observer = new MutationObserver(callback);


observer.observe(targetNode, config);


observer.disconnect();

如何监听停留时长
维护一个观察列表,元素可见比例满足要求时,将该元素信息(包含曝光开始时间)添加到列表,当元素退出可视区域时(可见比例小于设定值),用当前时间减去曝光开始时间,则可获得停留时长。

总体实现

实现一个exposure方法,支持传入需要检测曝光的元素信息(需包含className),使用 IntersectionObserver 和 MutationObserver 对元素进行动态监听。

  • 初始化时,根据className查找出已渲染的曝光监测元素,然后使用IntersectionObserver统一监听,如果有元素发生曝光,则触发对应曝光事件;
  • 对于一些动态渲染的曝光监测元素,需要使用MutationObserver监听dom变化。当有节点新增时,新增节点若包含曝光监测元素,则使用IntersectionObserver进行监听;当有节点被移除时,移除节点若包含曝光监测元素,则取消对其的监听;
  • 维护一个observe列表,元素开始曝光时将元素信息添加到列表,元素退出曝光时如果曝光时长符合规则,则触发对应曝光事件,并在observe列表中将该元素标记为已曝光,已曝光后再重复曝光则不进行采集。如果元素在DOM上被卸载,则将该元素在observe列表中的曝光事件删除,下次重新挂载时,则重新采集。
  • 设置一个定时器,定时检查observe列表,若列表中有未完成曝光且符合曝光时长规则的元素,则触发其曝光事件,并更新列表中曝光信息。

初始化流程

元素发生挂载或卸载过程

元素曝光过程

代码实现

const exposure = (trackElems?: ITrackElem[]) => {
  const trackClassNames =
    trackElems
    ?.filter((elem) => elem.eventType === TrackEventType.EXPOSURE)
    .map((elem) => elem.className) || [];

  const intersectionObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        const entryElem = entry.target;
        const observeList = getObserveList();
        let expId = entryElem.getAttribute(EXPOSURE_ID_ATTR);

        if (expId) {
          
          const currentItem = observeList.find((o) => o.id === expId);
          if (currentItem.hasExposed) return;
        }

        if (entry.isIntersecting) {
          if (!expId) {
            expId = getRandomStr(8);
            entryElem.setAttribute(EXPOSURE_ID_ATTR, expId);
          }
          const exit = observeList.find((o) => o.id === expId);
          if (!exit) {
            
            const trackElem = trackElems.find((item) =>
              entryElem?.classList?.contains(item.className)
                                             );
            const observeItem = { ...trackElem, id: expId, time: Date.now() };
            observeList.push(observeItem);
            setObserveList(observeList);
          }
        } else {
          if (!expId) return;
          const currentItem = observeList.find((o) => o.id === expId);
          if (currentItem) {
            if (Date.now() - currentItem.time > 500) {
              
              tracker.track(
                currentItem.event,
                TrackEventType.EXPOSURE,
                currentItem.params
              );
              currentItem.hasExposed = true;
              setObserveList(observeList);
            }
          }
        }
      });
    },
    { threshold: 0.5 }
  );

  const observeElems = (queryDom: Element | Document) => {
    trackClassNames.forEach((name) => {
      const elem = queryDom.getElementsByClassName?.(name)?.[0];
      if (elem) {
        intersectionObserver.observe(elem);
      }
    });
  };

  const mutationObserver = new MutationObserver((mutationList) => {
    mutationList.forEach((mutation) => {
      if (mutation.type !== 'childList') return;

      mutation.addedNodes.forEach((node: Element) => {
        observeElems(node);
      });

      mutation.removedNodes.forEach((node: Element) => {
        trackClassNames.forEach((item) => {
          const elem = node.getElementsByClassName?.(item)?.[0];
          if (!elem) return;
          const expId = elem.getAttribute('data-exposure-id');
          if (expId) {
            const observeList = getObserveList();
            const index = observeList.findIndex((o) => o.id === expId);
            if (index > -1) {
              
              observeList.splice(index, 1);
              setObserveList(observeList);
            }
          }
          intersectionObserver.unobserve(elem);
        });
      });
    });
  });

  observeElems(document);
  mutationObserver.observe(document.body, {
    subtree: true,
    childList: true,
  });

  const timer = setInterval(() => {
    
    const observeList = getObserveList();
    let shouldUpdate = false;
    observeList.forEach((o) => {
      if (!o.hasExposed && Date.now() - o.time > 500) {
        tracker.track(o.event, TrackEventType.EXPOSURE, o.params);
        o.hasExposed = true;
        shouldUpdate = true;
      }
    });
    if (shouldUpdate) {
      setObserveList(observeList);
    }
  }, 3000);

  return () => {
    mutationObserver.disconnect();
    intersectionObserver.disconnect();
    clearInterval(timer);
    removeObserveList();
  };
};

export default exposure;

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录