我們在產品推廣過程中,經常需要判斷用戶是否對某個模塊感興趣。那么就需要獲取該模塊的曝光量和用戶對該模塊的點擊量,若點擊量/曝光量越高,說明該模塊越有吸引力。
那么如何知道模塊對用戶是否曝光了呢?之前我們是監聽頁面的滾動事件,然后通過getBoundingClientRect()
現在我們直接使用IntersectionObserver
就行了,使用起來簡單方便,而且性能上也比監聽滾動事件要好很多。
1. IntersectionObserver
我們先來簡單了解下這個 api 的使用方法。
IntersectionObserver 有兩個參數,new IntersectionObserver(callback, options)
,callback 是當觸發可見性時執行的回調,options 是相關的配置。
// 初始化一個對象
const io = new IntersectionObserver(
(entries) => {
// entries是一個數組
console.log(entries);
},
{
threshold: [0, 0.5, 1], // 觸發回調的節點,0表示元素剛完全不可見,1表示元素剛完全可見,0.5表示元素可見了一半等
},
);
// 監聽dom對象,可以同時監聽多個dom元素
io.observe(document.querySelector('.dom1'));
io.observe(document.querySelector('.dom2'));
// 取消監聽dom元素
io.unobserve(document.querySelector('.dom2'));
// 關閉觀察器
io.disconnect();
在 callback 中的 entries 參數是一個IntersectionObserverEntry類型的數組。
主要有 6 個元素:
{
time: 3893.92,
rootBounds: ClientRect {
bottom: 920,
height: 1024,
left: 0,
right: 1024,
top: 0,
width: 920
},
boundingClientRect: ClientRect {
// ...
},
intersectionRect: ClientRect {
// ...
},
intersectionRatio: 0.54,
target: element
}
各個屬性的含義:
{
time: 觸發該行為的時間戳(從打開該頁面開始計時的時間戳),單位毫秒
rootBounds: 視窗的尺寸,
boundingClientRect: 被監聽元素的尺寸,
intersectionRect: 被監聽元素與視窗交叉區域的尺寸,
intersectionRatio: 觸發該行為的比例,
target: 被監聽的dom元素
}
我們利用頁面可見性的特點,可以做很多事情,比如組件懶加載、無限滾動、監控組件曝光等。
2. 監控組件的曝光
我們利用IntersectionObserver
這個 api,可以很好地實現組件曝光量的統計。
實現的方式主要有兩種:
- 函數的方式;
- 高階組件的方式;
傳入的參數:
interface ComExposeProps {
readonly always?: boolean; // 是否一直有效
// 曝光時的回調,若不存在always,則只執行一次
onExpose?: (dom: HTMLElement) => void;
// 曝光后又隱藏的回調,若不存在always,則只執行一次
onHide?: (dom: HTMLElement) => void;
observerOptions?: IntersectionObserverInit; // IntersectionObserver相關的配置
}
我們約定整體的曝光量大於等於 0.5,即為有效曝光。同時,我們這里暫不考慮該 api 的兼容性,若需要兼容的話,可以安裝對應的 polyfill 版。
2.1 函數的實現方式
用函數的方式來實現時,需要業務側傳入真實的 dom 元素,我們才能監聽。
// 一個函數只監聽一個dom元素
// 當需要監聽多個元素,可以循環調用exposeListener
const exposeListener = (target: HTMLElement, options?: ComExposeProps) => {
// IntersectionObserver相關的配置
const observerOptions = options?.observerOptions || {
threshold: [0, 0.5, 1],
};
const intersectionCallback = (entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting) {
if (entry.intersectionRatio >= observerOptions.threshold[1]) {
if (target.expose !== 'expose') {
options?.onExpose?.(target);
}
target.expose = 'expose';
if (!options?.always && typeof options?.onHide !== 'function') {
// 當always屬性為加,且沒有onHide方式時
// 則在執行一次曝光后,移動監聽
io.unobserve(target);
}
}
} else if (typeof options?.onHide === 'function' && target.expose === 'expose') {
options.onHide(target);
target.expose = undefined;
if (!options?.always) {
io.unobserve(target);
}
}
};
const io = new IntersectionObserver(intersectionCallback, observerOptions);
io.observe(target);
};
調用起來也非常方便:
exposeListener(document.querySelector('.dom1'), {
always: true, // 監聽的回調永遠有效
onExpose() {
console.log('dom1 expose', Date.now());
},
onHide() {
console.log('dom1 hide', Date.now());
},
});
// 沒有always時,所有的回調都只執行一次
exposeListener(document.querySelector('.dom2'), {
// always: true,
onExpose() {
console.log('dom2 expose', Date.now());
},
onHide() {
console.log('dom2 hide', Date.now());
},
});
// 重新設置IntersectionObserver的配置
exposeListener(document.querySelector('.dom3'), {
observerOptions: {
threshold: [0, 0.2, 1],
},
onExpose() {
console.log('dom1 expose', Date.now());
},
});
那么組件的曝光數據,就可以在onExpose()
的回調方式里進行上報。
不過我們可以看到,這里面有很多標記,需要我們處理,單純的一個函數不太方便處理;而且也沒對外暴露出取消監聽的 api,導致我們想在卸載組件前也不方便取消監聽。
因此我們可以用一個 class 類來實現。
2.2 類的實現方式
類的實現方式,我們可以把很多標記放在屬性里。核心部分跟上面的差不多。
class ComExpose {
target = null;
options = null;
io = null;
exposed = false;
constructor(dom, options) {
this.target = dom;
this.options = options;
this.observe();
}
observe(options) {
this.unobserve();
const config = { ...this.options, ...options };
// IntersectionObserver相關的配置
const observerOptions = config?.observerOptions || {
threshold: [0, 0.5, 1],
};
const intersectionCallback = (entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
if (entry.intersectionRatio >= observerOptions.threshold[1]) {
if (!config?.always && typeof config?.onHide !== 'function') {
io.unobserve(this.target);
}
if (!this.exposed) {
config?.onExpose?.(this.target);
}
this.exposed = true;
}
} else if (typeof config?.onHide === 'function' && this.exposed) {
config.onHide(this.target);
this.exposed = false;
if (!config?.always) {
io.unobserve(this.target);
}
}
};
const io = new IntersectionObserver(intersectionCallback, observerOptions);
io.observe(this.target);
this.io = io;
}
unobserve() {
this.io?.unobserve(this.target);
}
}
調用的方式:
// 初始化時自動添加監聽
const instance = new ComExpose(document.querySelector('.dom1'), {
always: true,
onExpose() {
console.log('dom1 expose');
},
onHide() {
console.log('dom1 hide');
},
});
// 取消監聽
instance.unobserve();
不過這種類的實現方式,在 react 中使用起來也不太方便:
- 首先要通過
useRef()
獲取到 dom 元素; - 組件卸載時,要主動取消對 dom 元素的監聽;
2.3 react 中的組件嵌套的實現方式
我們可以利用 react 中的useEffect()
hook,能很方便地在卸載組件前,取消對 dom 元素的監聽。
import React, { useEffect, useRef, useState } from 'react';
interface ComExposeProps {
children: any;
readonly always?: boolean; // 是否一直有效
// 曝光時的回調,若不存在always,則只執行一次
onExpose?: (dom: HTMLElement) => void;
// 曝光后又隱藏的回調,若不存在always,則只執行一次
onHide?: (dom: HTMLElement) => void;
observerOptions?: IntersectionObserverInit; // IntersectionObserver相關的配置
}
/**
* 監聽元素的曝光
* @param {ComExposeProps} props 要監聽的元素和回調
* @returns {JSX.Element}
*/
const ComExpose = (props: ComExposeProps): JSX.Element => {
const ref = useRef<any>(null);
const curExpose = useRef(false);
useEffect(() => {
if (ref.current) {
const target = ref.current;
const observerOptions = props?.observerOptions || {
threshold: [0, 0.5, 1],
};
const intersectionCallback = (entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting) {
if (entry.intersectionRatio >= observerOptions.threshold[1]) {
if (!curExpose.current) {
props?.onExpose?.(target);
}
curExpose.current = true;
if (!props?.always && typeof props?.onHide !== 'function') {
// 當always屬性為加,且沒有onHide方式時
// 則在執行一次曝光后,移動監聽
io.unobserve(target);
}
}
} else if (typeof props?.onHide === 'function' && curExpose.current) {
props.onHide(target);
curExpose.current = false;
if (!props?.always) {
io.unobserve(target);
}
}
};
const io = new IntersectionObserver(intersectionCallback, observerOptions);
io.observe(target);
return () => io.unobserve(target); // 組件被卸載時,先取消監聽
}
}, [ref]);
// 當組件的個數大於等於2,或組件使用fragment標簽包裹時
// 則創建一個新的div用來掛在ref屬性
if (React.Children.count(props.children) >= 2 || props.children.type.toString() === 'Symbol(react.fragment)') {
return <div ref="{ref}">{props.children}</div>;
}
// 為該組件掛在ref屬性
return React.cloneElement(props.children, { ref });
};
export default ComExpose;
調用起來更加方便了,而且還不用手動獲取 dom 元素和卸載監聽:
<comexpose always="" onexpose="{()" ==""> console.log('expose')} onHide={() => console.log('hide')}>
<div classname="dom dom1">dom1 always</div>
</comexpose>
Vue 組件實現起來的方式也差不多,不過我 Vue 用的確實比較少,這里就不放 Vue 的實現方式了。
3. 總結
現在我們已經基本實現了關於組件的曝光的監聽方式,整篇文章的核心全部都在IntersectionObserver
上。基於上面的實現方式,我們其實還可以繼續擴展,比如在組件即將曝光時踩初始化組件;頁面中的倒計時只有在可見時才執行,不可見時則直接停掉等等。
IntersectionObserver 還等着我們探索出更多的用法!
也歡迎您關注我的公眾號:“前端小茶館”。