在React项目里做用户行为追踪,通常不是简单地在组件里调用一两个统计函数。页面曝光、点击、停留时长等数据,往往分散在多个组件和路由中,如果直接写在业务代码里,不仅会让组件变得臃肿,后续更换数据平台时还需要逐个修改。更合理的方式是将埋点能力抽离为自定义Hook,再配合一个统一的上报模块,让业务组件只关心发生了什么,而不关心数据如何发送。

本文将拆成主动埋点与被动曝光两部分,先给出可以复用的useTrack和useExposure,再讨论统一上报和常见误区。其中曝光统计主要依赖浏览器原生的IntersectionObserver,不需要额外引入第三方库。
一、主动埋点:用useTrack抽离点击与事件追踪
主动埋点通常由用户点击、表单提交、滚动操作等显式行为触发。如果直接在业务组件里调用统计函数,会出现两个问题:一是埋点代码和业务逻辑缠绕在一起,可读性下降;二是统计字段容易遗漏,比如每次点击都要手动补充页面来源、当前路由、操作时间等信息。把埋点封装成Hook,可以统一补充公共字段,并让业务代码保持干净。
下面的useTrack返回一个track函数,业务组件只需要传入事件名和自定义数据。Hook内部会自动附加上报时间、当前URL,并写入上报队列。这里没有每点击一次就发一个请求,而是采用批量上报,减少网络开销。
// useTrack.js
import { useCallback } from 'react';
const queue = [];
let timer = null;
function flush() {
if (!queue.length) return;
const batch = queue.splice(0, queue.length);
navigator.sendBeacon?.('/api/track', new Blob([JSON.stringify(batch)], { type: 'application/json' }));
}
function push(event, data) {
queue.push({ event, data, ts: Date.now(), url: location.href });
if (queue.length >= 10) {
flush();
return;
}
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 3000);
}
export function useTrack() {
const track = useCallback(function(event, data = {}) {
push(event, data);
}, []);
return { track };
}
在组件中使用时,只需调用track方法即可。比如购买按钮的点击埋点可以这样写:
import { useTrack } from './useTrack';
export default function BuyButton({ productId }) {
const { track } = useTrack();
const handleClick = () => {
track('buy_button_click', { productId, source: 'detail' });
// 后续业务逻辑
};
return (
<button onClick={handleClick}>立即购买</button>
);
}
这种方式下,组件只关心用户点击了按钮,不关心数据发到哪里。如果以后统计平台从自建接口换成第三方SDK,只需要修改useTrack内部实现,业务组件完全不用动。对于需要携带公共信息的场景,也可以在push函数里统一补充用户ID、渠道号等字段。
二、可视区域曝光:IntersectionObserver与useExposure
曝光统计关注的不是用户主动操作,而是某个元素是否进入了浏览器可视区域。比如信息流里的广告卡片、推荐位,只有真正被用户看到才应该计一次曝光。实现这个需求最合适的浏览器API是IntersectionObserver,它能异步监听目标元素与视口或指定父元素的交叉状态。
IntersectionObserver的核心配置有三个:threshold表示可见比例阈值,rootMargin用于扩大或缩小判定区域,root可以指定参照容器。比如threshold: 0.5表示元素有50%面积进入视口时触发回调。rootMargin设为100px则可以在元素距离视口还有100像素时提前触发,适合预加载场景。
下面的useExposure会返回一个ref,组件把它绑定到需要统计曝光的元素上。为了只触发一次曝光,内部用triggered标记,触发后立即停止观察,避免重复统计。
import { useEffect, useRef } from 'react';
export function useExposure(callback, options = {}) {
const ref = useRef(null);
const callbackRef = useRef(callback);
const triggered = useRef(false);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const el = ref.current;
if (!el || triggered.current) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
triggered.current = true;
callbackRef.current(entry);
observer.unobserve(el);
}
});
}, {
threshold: options.threshold ?? 0.5,
rootMargin: options.rootMargin ?? '0px',
root: options.root ?? null
});
observer.observe(el);
return () => observer.disconnect();
}, [options.threshold, options.rootMargin, options.root]);
return ref;
}
业务组件使用时代码很简短:
import { useExposure } from './useExposure';
export default function AdCard({ adId, onExpose }) {
const ref = useExposure(function(entry) {
onExpose({ adId, ratio: entry.intersectionRatio });
}, { threshold: 0.5 });
return <div ref={ref} className="ad-card">广告内容</div>;
}
如果业务要求记录曝光停留时长,可以把上面的实现扩展为两次回调:当isIntersecting为true时记录开始时间,为false时计算停留时长并上报。也可以把triggered改成计数或时间戳,支持同一元素多次曝光统计。需要注意的是,IntersectionObserver的回调是异步执行的,如果组件在触发前被卸载,清理函数里的disconnect会阻止后续回调。
三、统一上报模块与性能优化
埋点和曝光数据如果每产生一条就发送一次,容易造成请求数量过多,尤其在滚动列表快速曝光时,瞬时请求可能达到几十个。统一上报模块的核心思路是使用内存队列缓存事件,然后按数量或时间批量发送。这样既能减少HTTP连接开销,也能把多次小请求合并成一次大请求。
上报时还需要考虑页面关闭或跳转,队列中未发送的数据不能丢失。navigator.sendBeacon专门用于这类场景,它不会阻塞页面卸载,浏览器会在后台完成发送。不过sendBeacon只支持POST,数据量也有限制,所以更适合作为页面离开时的兜底方案。常规状态下可以用fetch进行批量发送,并开启keepalive。
// report.js
const queue = [];
let timer = null;
const MAX_BATCH = 20;
export function addEvent(event, data) {
queue.push({ event, data, ts: Date.now() });
if (queue.length >= MAX_BATCH) {
sendBatch();
return;
}
scheduleFlush();
}
function scheduleFlush() {
if (timer) clearTimeout(timer);
timer = setTimeout(sendBatch, 3000);
}
function sendBatch() {
if (!queue.length) return;
const batch = queue.splice(0, queue.length);
const payload = JSON.stringify(batch);
if (navigator.sendBeacon) {
const ok = navigator.sendBeacon('/api/track', new Blob([payload], { type: 'application/json' }));
if (!ok) fallback(payload);
} else {
fallback(payload);
}
}
function fallback(payload) {
fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
});
}
window.addEventListener('pagehide', sendBatch);
另一个性能点在于曝光监听的实例数量。如果页面上有成百上千个需要曝光统计的元素,为每个元素创建一个IntersectionObserver会消耗较多内存。更优的做法是维护一个全局的观察器,所有需要曝光统计的元素都注册到同一个观察器中,通过事件名或唯一ID区分来源。这与批量上报结合起来,可以显著降低长列表页面的性能压力。
四、常见误区与可扩展设计
第一个容易踩的坑是React 18的StrictMode。开发模式下组件会被挂载两次,如果曝光Hook内部不做好清理,可能重复创建观察器或重复触发回调。上面示例中的useEffect返回了disconnect,并且用triggered标记保证一次生效,但仍建议在开发环境密切观察重复上报情况。
第二个坑是单页应用路由切换。用户从列表页跳转到详情页再返回,列表组件重新挂载,曝光统计可能被再次触发。如果业务认为同一会话内同一元素只需要曝光一次,就需要在Hook外部维护一个基于元素ID的缓存,记录已经曝光过的ID,避免重复计数。
动态列表中的曝光统计还涉及key和唯一标识。React的列表渲染要求稳定key,曝光数据也应带上业务唯一ID,例如商品ID、广告位ID,而不是简单使用数组下标。这样在上报数据去重、分析曝光转化率时才有意义。
最后,如果后续要在埋点体系中加入点击流、页面停留时长、自定义事件等,可以把useTrack和useExposure扩展为统一的useTracker,内部共享同一个上报队列。组件继续通过track和expose方法上报,数据层则可以根据事件类型、页面路径自动归类和补充字段,形成一套可扩展的行为追踪基础库。