我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:霁明
一些名词解释
曝光
页面上某一个元素、组件或模块被用户浏览了,则称这个元素、组件或模块被曝光了。
视图元素
将页面上展示的元素、组件或模块统称为视图元素。
可见比例
视图元素在可视区域面积/视图元素整体面积。
有效停留时长
视图元素由不可见到可见,满足可见比例并且保持可见状态的持续的一段时间。
重复曝光
在同一页面,某个视图元素不发生DOM卸载或页面切换的情况下,发生的多次曝光称为重复曝光。例如页面上某个视图元素,在页面来回滚动时,则会重复曝光。
如何监测曝光
需要考虑的一些问题
曝光条件
页面上某一视图元素的可见比例达到一定值(例如0.5),且有效停留时间达到一定时长(例如500ms),则称该视图元素被曝光了。
如何检测可见比例
使用 IntersectionObserver api 对元素进行监听,通过 threshold 配置项设置可见比例,当达到可见比例时,观察器的回调就会执行。 IntersectionObserver 使用示例:
1let callback = (entries, observer) => {
2 entries.forEach((entry) => {
3
4
5
6
7
8
9
10
11 });
12};
13let options = {
14 threshold: 1.0,
15};
16let observer = new IntersectionObserver(callback, options);
17
18let target = document.querySelector("#listItem");
19observer.observe(target);
如何监听动态元素
使用 IntersectionObserver 对元素进行监听之前,需要先获取到元素的 DOM,但对于一些动态渲染的元素,则无法进行监听。所以,需要先监听DOM元素是否发生挂载或卸载,然后对元素动态使用IntersectionObserver 进行监听,可以使用 MutationObserver 对 DOM变更进行监听。 MutationObserver的使用示例:
1const targetNode = document.getElementById("some-id");
2
3
4const config = { attributes: true, childList: true, subtree: true };
5
6
7const callback = function (mutationsList, observer) {
8 for (let mutation of mutationsList) {
9 if (mutation.type === "childList") {
10 console.log("A child node has been added or removed.");
11 } else if (mutation.type === "attributes") {
12 console.log("The " + mutation.attributeName + " attribute was modified.");
13 }
14 }
15};
16
17
18const observer = new MutationObserver(callback);
19
20
21observer.observe(targetNode, config);
22
23
24observer.disconnect();
如何监听停留时长
维护一个观察列表,元素可见比例满足要求时,将该元素信息(包含曝光开始时间)添加到列表,当元素退出可视区域时(可见比例小于设定值),用当前时间减去曝光开始时间,则可获得停留时长。
总体实现
实现一个exposure方法,支持传入需要检测曝光的元素信息(需包含className),使用 IntersectionObserver 和 MutationObserver 对元素进行动态监听。
- 初始化时,根据className查找出已渲染的曝光监测元素,然后使用
IntersectionObserver
统一监听,如果有元素发生曝光,则触发对应曝光事件; - 对于一些动态渲染的曝光监测元素,需要使用
MutationObserver
监听dom变化。当有节点新增时,新增节点若包含曝光监测元素,则使用IntersectionObserver
进行监听;当有节点被移除时,移除节点若包含曝光监测元素,则取消对其的监听; - 维护一个observe列表,元素开始曝光时将元素信息添加到列表,元素退出曝光时如果曝光时长符合规则,则触发对应曝光事件,并在observe列表中将该元素标记为已曝光,已曝光后再重复曝光则不进行采集。如果元素在DOM上被卸载,则将该元素在observe列表中的曝光事件删除,下次重新挂载时,则重新采集。
- 设置一个定时器,定时检查observe列表,若列表中有未完成曝光且符合曝光时长规则的元素,则触发其曝光事件,并更新列表中曝光信息。
初始化流程
元素发生挂载或卸载过程
元素曝光过程
代码实现
1const exposure = (trackElems?: ITrackElem[]) => {
2 const trackClassNames =
3 trackElems
4 ?.filter((elem) => elem.eventType === TrackEventType.EXPOSURE)
5 .map((elem) => elem.className) || [];
6
7 const intersectionObserver = new IntersectionObserver(
8 (entries) => {
9 entries.forEach((entry) => {
10 const entryElem = entry.target;
11 const observeList = getObserveList();
12 let expId = entryElem.getAttribute(EXPOSURE_ID_ATTR);
13
14 if (expId) {
15
16 const currentItem = observeList.find((o) => o.id === expId);
17 if (currentItem.hasExposed) return;
18 }
19
20 if (entry.isIntersecting) {
21 if (!expId) {
22 expId = getRandomStr(8);
23 entryElem.setAttribute(EXPOSURE_ID_ATTR, expId);
24 }
25 const exit = observeList.find((o) => o.id === expId);
26 if (!exit) {
27
28 const trackElem = trackElems.find((item) =>
29 entryElem?.classList?.contains(item.className)
30 );
31 const observeItem = { ...trackElem, id: expId, time: Date.now() };
32 observeList.push(observeItem);
33 setObserveList(observeList);
34 }
35 } else {
36 if (!expId) return;
37 const currentItem = observeList.find((o) => o.id === expId);
38 if (currentItem) {
39 if (Date.now() - currentItem.time > 500) {
40
41 tracker.track(
42 currentItem.event,
43 TrackEventType.EXPOSURE,
44 currentItem.params
45 );
46 currentItem.hasExposed = true;
47 setObserveList(observeList);
48 }
49 }
50 }
51 });
52 },
53 { threshold: 0.5 }
54 );
55
56 const observeElems = (queryDom: Element | Document) => {
57 trackClassNames.forEach((name) => {
58 const elem = queryDom.getElementsByClassName?.(name)?.[0];
59 if (elem) {
60 intersectionObserver.observe(elem);
61 }
62 });
63 };
64
65 const mutationObserver = new MutationObserver((mutationList) => {
66 mutationList.forEach((mutation) => {
67 if (mutation.type !== 'childList') return;
68
69 mutation.addedNodes.forEach((node: Element) => {
70 observeElems(node);
71 });
72
73 mutation.removedNodes.forEach((node: Element) => {
74 trackClassNames.forEach((item) => {
75 const elem = node.getElementsByClassName?.(item)?.[0];
76 if (!elem) return;
77 const expId = elem.getAttribute('data-exposure-id');
78 if (expId) {
79 const observeList = getObserveList();
80 const index = observeList.findIndex((o) => o.id === expId);
81 if (index > -1) {
82
83 observeList.splice(index, 1);
84 setObserveList(observeList);
85 }
86 }
87 intersectionObserver.unobserve(elem);
88 });
89 });
90 });
91 });
92
93 observeElems(document);
94 mutationObserver.observe(document.body, {
95 subtree: true,
96 childList: true,
97 });
98
99 const timer = setInterval(() => {
100
101 const observeList = getObserveList();
102 let shouldUpdate = false;
103 observeList.forEach((o) => {
104 if (!o.hasExposed && Date.now() - o.time > 500) {
105 tracker.track(o.event, TrackEventType.EXPOSURE, o.params);
106 o.hasExposed = true;
107 shouldUpdate = true;
108 }
109 });
110 if (shouldUpdate) {
111 setObserveList(observeList);
112 }
113 }, 3000);
114
115 return () => {
116 mutationObserver.disconnect();
117 intersectionObserver.disconnect();
118 clearInterval(timer);
119 removeObserveList();
120 };
121};
122
123export default exposure;