创见博客
useTrackExposure 的实现原理与设计:基于 IntersectionObserver 的曝光埋点 Hook
七崽爱吃小饼干2026/07/14阅读 0专栏 react-track-hooks/React

useTrackExposure 的实现原理与设计:基于 IntersectionObserver 的曝光埋点 Hook

在前端埋点体系中,曝光埋点是非常常见也非常容易出错的一类事件。点击埋点通常由用户主动行为触发,而曝光埋点更多依赖页面滚动、元素位置、视口大小和渲染时机。一个推荐卡片、广告位、商品卡、信息流内容是否真正被用户看见,不能简单等同于“组件已经渲染”。

react-track-hooks 中的 useTrackExposure 就是为这类场景设计的。它基于浏览器原生的 IntersectionObserver 实现元素可见性监听,并将监听结果接入统一的 useTrack 上报链路,从而把曝光判断、参数组装和埋点发送封装成一个易用的 React Hook。

源码实现

useTrackExposure 位于 src/track/hooks/useTrackExposure.ts,源码如下:

ts
import { TrackConfig, TrackType } from '../../types';
import { getTrackGlobalConfig } from '../config';
import { useTrack } from './useTrack';
import { useEffect, useRef } from 'react';

/**
 * 曝光埋点 Hook - 封装元素曝光埋点逻辑,返回需要监听的 DOM 引用
 * @template T 目标元素类型(默认:HTMLElement)
 * @param eventName 曝光事件名称(必填)
 * @param customParams 自定义埋点参数(可选)
 * @param config 埋点配置项(可选,支持曝光阈值、是否只上报一次等)
 * @returns 需绑定到目标元素的 Ref 对象
 */
export const useTrackExposure = <T extends HTMLElement = HTMLElement>(
  eventName: string,
  customParams: Record<string, any> = {},
  config: TrackConfig = {},
) => {
  const globalConfig = getTrackGlobalConfig();
  const mergedConfig = {
    ...globalConfig,
    ...config,
    exposureOnce: config.exposureOnce ?? globalConfig.exposureConfig?.exposureOnce,
    exposureThreshold: config.exposureThreshold ?? globalConfig.exposureConfig?.exposureThreshold,
  };

  const { triggerTrack } = useTrack(
    { eventName, type: TrackType.EXPOSURE, ...customParams },
    mergedConfig,
  );

  const latestConfigRef = useRef(mergedConfig);
  latestConfigRef.current = mergedConfig;

  const targetRef = useRef<T>(null);
  const hasReported = useRef(false);

  useEffect(() => {
    if (!latestConfigRef.current.enable) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && !hasReported.current) {
            const exposureParams = {
              intersectionRatio: entry.intersectionRatio,
              boundingClientRect: entry.boundingClientRect,
              exposureTime: Date.now(),
            };
            triggerTrack(exposureParams);

            if (latestConfigRef.current.exposureOnce) {
              hasReported.current = true;
              observer.unobserve(entry.target);
            }
          }
        });
      },
      { threshold: latestConfigRef.current.exposureThreshold },
    );

    const target = targetRef.current;
    if (target) observer.observe(target);

    return () => {
      if (target) observer.unobserve(target);
      observer.disconnect();
    };
  }, [triggerTrack, mergedConfig.enable, mergedConfig.exposureOnce, mergedConfig.exposureThreshold]);

  return targetRef;
};

这段代码的整体思路非常直接:创建一个 ref 给业务组件绑定 DOM,使用 IntersectionObserver 监听这个 DOM 是否进入视口,当满足曝光条件时调用 triggerTrack 上报。

为什么曝光不能只看组件渲染

很多初学埋点的人会把“组件 mounted”当成曝光。但在真实页面中,这个判断通常并不准确。

比如一个信息流页面可能一次性渲染几十个卡片,其中一部分在首屏下方,用户没有滚动到那里。它们虽然已经出现在 DOM 中,但并没有真正被用户看到。如果在组件挂载时就上报曝光,会造成曝光数据虚高。

还有一种情况是元素只露出了一点边缘,比如只显示了 5%。这是否算曝光,应该由业务策略决定,而不是由组件是否存在决定。

因此,曝光埋点需要回答两个问题:

  • 元素是否进入了用户可见区域。
  • 元素可见比例达到多少才算有效曝光。

IntersectionObserver 正好是浏览器为这类问题提供的原生能力。

IntersectionObserver 的角色

IntersectionObserver 可以监听目标元素和视口之间的交叉状态。相比手动监听 scroll 并计算元素位置,它有几个优势。

第一,它由浏览器调度,性能更好。手动滚动监听很容易在高频滚动中触发大量计算,而 IntersectionObserver 可以由浏览器优化触发时机。

第二,它直接提供 isIntersecting 和 intersectionRatio。前者表示元素是否和视口相交,后者表示可见比例。曝光埋点最需要的核心数据,API 已经直接给出。

第三,它支持 threshold。开发者可以指定可见比例阈值,比如 0.5 表示元素可见面积达到 50% 时才触发回调。

在 useTrackExposure 中,observer 创建时传入:

ts
{ threshold: latestConfigRef.current.exposureThreshold }

这让曝光阈值可以由全局配置或单个 Hook 配置控制。

API 设计:返回 ref,而不是返回函数

useTrackExposure 的返回值是:

ts
return targetRef;

这意味着业务使用方式是:

tsx
function RecommendCard() {
  const ref = useTrackExposure<HTMLDivElement>('recommend_card_exposure', {
    cardId: 'card_001',
  });

  return <div ref={ref}>推荐内容</div>;
}

这个 API 和点击埋点的设计不同。点击埋点返回的是事件处理函数,因为点击行为本来就是事件驱动;曝光埋点返回的是 DOM ref,因为曝光判断依赖真实 DOM 和视口关系。

这种设计符合 React 组件模型。业务组件只需要把 ref 挂到要监听的元素上,不需要关心 IntersectionObserver 的创建、监听、取消监听和销毁。

配置合并:全局策略与局部覆盖

曝光埋点有两个重要配置:

  • exposureOnce:是否只上报一次。
  • exposureThreshold:元素可见比例达到多少时触发曝光。

项目默认全局配置中,这两个值位于:

ts
exposureConfig: {
  exposureOnce: true,
  exposureThreshold: 0.5,
}

也就是说,默认情况下,一个元素只曝光一次,并且可见比例达到 50% 时才算曝光。

useTrackExposure 会先读取全局配置:

ts
const globalConfig = getTrackGlobalConfig();

然后合并 Hook 级配置:

ts
const mergedConfig = {
  ...globalConfig,
  ...config,
  exposureOnce: config.exposureOnce ?? globalConfig.exposureConfig?.exposureOnce,
  exposureThreshold: config.exposureThreshold ?? globalConfig.exposureConfig?.exposureThreshold,
};

这里有一个细节:exposureOnce 和 exposureThreshold 被提升到了 mergedConfig 顶层。这是因为 TrackConfig 本身支持单个 Hook 直接传入这两个字段,使用方可以这样写:

tsx
useTrackExposure('banner_exposure', {}, {
  exposureOnce: false,
  exposureThreshold: 0.8,
});

如果单个 Hook 没有传,就回退到全局 exposureConfig。这形成了清晰的优先级:单个 Hook 配置优先,全局配置兜底。

与 useTrack 的关系

useTrackExposure 并不直接调用 fetch。它通过基础 Hook useTrack 接入统一上报链路:

ts
const { triggerTrack } = useTrack(
  { eventName, type: TrackType.EXPOSURE, ...customParams },
  mergedConfig,
);

这行代码做了两件事。

第一,它把事件类型固定为 TrackType.EXPOSURE,也就是:

ts
type: 'exposure'

这样后端或分析系统可以区分点击、曝光、停留、自定义等不同事件类型。

第二,它把自定义参数和曝光事件绑定在一起。比如业务传入 cardId、position、module 等参数,最终都会成为埋点数据的一部分。

真正触发曝光时,Hook 会补充运行时参数:

ts
const exposureParams = {
  intersectionRatio: entry.intersectionRatio,
  boundingClientRect: entry.boundingClientRect,
  exposureTime: Date.now(),
};

triggerTrack(exposureParams);

最终发送的数据会同时包含事件名、事件类型、业务参数和曝光运行时信息。

hasReported:控制单次曝光

曝光埋点有一个很常见的问题:同一个元素可能因为滚动进出视口而多次触发。如果每次进入视口都上报,有些业务会认为这是重复数据;但另一些业务又希望统计多次曝光。

useTrackExposure 通过 hasReported 和 exposureOnce 解决这个问题:

ts
const hasReported = useRef(false);

当 observer 回调触发时,会判断:

ts
if (entry.isIntersecting && !hasReported.current) {
  triggerTrack(exposureParams);
}

如果 exposureOnce 为 true,上报后会执行:

ts
hasReported.current = true;
observer.unobserve(entry.target);

这意味着当前元素不会再被监听,也不会再次上报。

如果 exposureOnce 为 false,hasReported.current 不会被改成 true,也不会调用 unobserve,因此元素可以在多次进入视口时多次上报。

这是一种简单但有效的策略,把“单次曝光”和“多次曝光”的选择权交给配置。

latestConfigRef:避免闭包拿到旧配置

代码中还有一个容易被忽略的细节:

ts
const latestConfigRef = useRef(mergedConfig);
latestConfigRef.current = mergedConfig;

IntersectionObserver 的回调函数不是 React 的同步渲染逻辑,它会在浏览器观察到可见性变化时异步执行。如果回调闭包里直接使用某次渲染时的配置,就可能拿到旧值。

通过 latestConfigRef.current,回调可以读取到最近一次渲染写入的配置。这是 React Hook 中处理异步回调常见的模式:用 ref 保存最新值,用稳定回调或外部监听读取 ref。

在这个 Hook 中,latestConfigRef 主要用于读取最新的 enable、exposureOnce 和 exposureThreshold。

enable:禁用时不创建 observer

useEffect 一开始有一行判断:

ts
if (!latestConfigRef.current.enable) return;

这表示如果埋点被禁用,就不会创建 IntersectionObserver,也不会监听 DOM。

这个设计比“监听了但不上报”更彻底。禁用埋点时,既不会产生网络请求,也不会产生额外的观察器开销。

测试中也覆盖了这个行为:当传入 { enable: false } 时,MockIntersectionObserver.instances 长度为 0,fetch 也不会被调用。

清理 observer:避免资源残留

useEffect 的返回函数负责清理:

ts
return () => {
  if (target) observer.unobserve(target);
  observer.disconnect();
};

这里做了两层清理。

第一,unobserve(target) 取消对当前目标元素的观察。

第二,disconnect() 断开 observer 监听的所有目标。

对于组件卸载、列表项移除、路由切换等场景,这一步很重要。如果不清理,observer 可能继续持有 DOM 或回调引用,造成内存泄漏或意外上报。

整体调用流程

useTrackExposure 的运行流程可以概括为:

text
组件调用 useTrackExposure(eventName, customParams, config)
  -> 读取全局配置并合并单个 Hook 配置
  -> 调用 useTrack 创建 triggerTrack
  -> 创建 targetRef 给业务元素绑定
  -> useEffect 中创建 IntersectionObserver
  -> observer.observe(targetRef.current)
  -> 元素进入视口并达到 threshold
  -> 组装 intersectionRatio、boundingClientRect、exposureTime
  -> triggerTrack 上报 exposure 事件
  -> 如果 exposureOnce 为 true,标记已上报并取消观察
  -> 组件卸载时 unobserve + disconnect

这个流程把曝光埋点拆成了三个层次:DOM 可见性判断由 IntersectionObserver 负责,埋点参数和事件类型由 useTrackExposure 负责,真正的发送、批量和失败重试由 useTrack 后面的核心链路负责。

测试体现的设计约束

项目的测试用例也很好地说明了这个 Hook 的设计目标。

第一,元素进入视口时应该发送埋点,并携带 intersectionRatio、业务参数等字段。

第二,当 exposureOnce 为 true 时,上报后应该调用 unobserve,避免重复曝光。

第三,当 exposureOnce 为 false 时,同一个元素多次触发可见状态可以多次上报。

第四,如果没有传 Hook 级曝光配置,应该使用全局 exposureConfig。

第五,当 enable 为 false 时,不应该创建 observer,也不应该发送请求。

这些测试覆盖的不是实现细节,而是曝光 Hook 对外承诺的行为边界。

设计亮点

useTrackExposure 的第一个亮点是 API 简洁。业务侧只需要拿到 ref 并绑定 DOM,不需要接触 IntersectionObserver。

第二个亮点是配置层次清晰。单个 Hook 可以覆盖曝光策略,全局配置可以提供默认策略。

第三个亮点是和统一上报链路解耦。Hook 只负责判断曝光和组装参数,不直接处理 fetch、批量队列和失败重试。

第四个亮点是可控的重复上报策略。exposureOnce 同时满足一次曝光和多次曝光两种业务需求。

第五个亮点是资源清理完整。组件卸载时会取消观察并断开 observer,避免副作用泄漏。

需要注意的边界

useTrackExposure 当前实现也有一些值得理解的边界。

首先,它依赖浏览器原生 IntersectionObserver。现代浏览器基本都支持,但如果要兼容非常旧的浏览器,使用方需要考虑 polyfill。

其次,它没有处理“曝光持续时长”。当前逻辑是元素进入视口并达到阈值后立即上报。如果业务要求“连续可见 1 秒才算曝光”,就需要在 observer 回调中增加定时确认逻辑。

第三,boundingClientRect 会被直接放入埋点参数中。它包含元素位置信息,但不同后端序列化策略可能需要关注这个对象的结构和数据量。

第四,当配置频繁变化时,effect 会根据依赖重新创建 observer。这对常规使用没有问题,但如果业务在高频渲染中每次都创建新的 config 对象,可能会带来额外的 observe/disconnect 成本。更推荐在调用侧保持配置稳定,或只在必要时改变曝光策略。

总结

useTrackExposure 是一个典型的“浏览器能力 + React Hook + 埋点链路”组合设计。它用 IntersectionObserver 判断元素是否真正进入用户视野,用 ref 把 DOM 监听自然地接入 React 组件,用 useTrack 把曝光事件接入统一的发送、批量和重试体系。

它的价值在于把一个容易散落在业务代码中的复杂逻辑抽象成了简单 Hook:业务只需要声明事件名、业务参数和曝光策略,剩下的监听、判断、上报和清理由 SDK 负责。

如果用一句话概括:useTrackExposure 让曝光埋点从“组件渲染就上报”的粗糙实现,变成了基于真实可见性、可配置阈值、可控制重复次数的可靠采集方案。

评论
0/100