基于 IntersectionObserver 实现一个组件的曝光监控

蚊子前端博客
发布于 2021-05-31 11:34
在产品推广过程中,经常需要判断用户是否对某个模块感兴趣。那么如何实现对一个模块的曝光监听呢?

我们在产品推广过程中,经常需要判断用户是否对某个模块感兴趣。那么就需要获取该模块的曝光量和用户对该模块的点击量,若点击量/曝光量越高,说明该模块越有吸引力。

开心的一天-蚊子的前端博客

那么如何知道模块对用户是否曝光了呢?之前我们是监听页面的滚动事件,然后通过getBoundingClientRect()现在我们直接使用IntersectionObserver就行了,使用起来简单方便,而且性能上也比监听滚动事件要好很多。

1. IntersectionObserver

我们先来简单了解下这个 api 的使用方法。

IntersectionObserver 有两个参数,new IntersectionObserver(callback, options),callback 是当触发可见性时执行的回调,options 是相关的配置。

COPYJAVASCRIPT

// 初始化一个对象 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 个元素:

COPYJAVASCRIPT

{ 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 }

各个属性的含义:

COPYJAVASCRIPT

{ time: 触发该行为的时间戳(从打开该页面开始计时的时间戳),单位毫秒 rootBounds: 视窗的尺寸, boundingClientRect: 被监听元素的尺寸, intersectionRect: 被监听元素与视窗交叉区域的尺寸, intersectionRatio: 触发该行为的比例, target: 被监听的dom元素 }

我们利用页面可见性的特点,可以做很多事情,比如组件懒加载、无限滚动、监控组件曝光等。

奇怪的知识又增加了-蚊子的前端博客

2. 监控组件的曝光

我们利用IntersectionObserver这个 api,可以很好地实现组件曝光量的统计。

实现的方式主要有两种:

  1. 函数的方式;

  2. 高阶组件的方式;

传入的参数:

COPYJAVASCRIPT

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 元素,我们才能监听。

COPYJAVASCRIPT

// 一个函数只监听一个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); };

调用起来也非常方便:

COPYJAVASCRIPT

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 类的实现方式

类的实现方式,我们可以把很多标记放在属性里。核心部分跟上面的差不多。

COPYJAVASCRIPT

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); } }

调用的方式:

COPYJAVASCRIPT

// 初始化时自动添加监听 const instance = new ComExpose(document.querySelector('.dom1'), { always: true, onExpose() { console.log('dom1 expose'); }, onHide() { console.log('dom1 hide'); }, }); // 取消监听 instance.unobserve();

不过这种类的实现方式,在 react 中使用起来也不太方便:

  1. 首先要通过useRef()获取到 dom 元素;

  2. 组件卸载时,要主动取消对 dom 元素的监听;

沉迷工作-蚊子的前端博客

2.3 react 中的组件嵌套的实现方式

我们可以利用 react 中的useEffect()hook,能很方便地在卸载组件前,取消对 dom 元素的监听。

COPYJAVASCRIPT

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 元素和卸载监听:

COPYJAVASCRIPT

<ComExpose always onExpose={() => console.log('expose')} onHide={() => console.log('hide')}> <div className="dom dom1">dom1 always</div> </ComExpose>

Vue 组件实现起来的方式也差不多,不过我 Vue 用的确实比较少,这里就不放 Vue 的实现方式了。

see you-蚊子的前端博客

3. 总结

现在我们已经基本实现了关于组件的曝光的监听方式,整篇文章的核心全部都在IntersectionObserver上。基于上面的实现方式,我们其实还可以继续扩展,比如在组件即将曝光时踩初始化组件;页面中的倒计时只有在可见时才执行,不可见时则直接停掉等等。

IntersectionObserver 还等着我们探索出更多的用法!

阅读(1166)

公众号:

qrcode

微信公众号:前端小茶馆

公众号:

qrcode

微信公众号:前端小茶馆