6

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

 3 years ago
source link: https://www.xiabingbao.com/post/js/dom-expose-intersectionobserver.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
在产品推广过程中,经常需要判断用户是否对某个模块感兴趣。那么如何实现对一个模块的曝光监听呢?

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

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

那么如何知道模块对用户是否曝光了呢?之前我们是监听页面的滚动事件,然后通过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,可以很好地实现组件曝光量的统计。

实现的方式主要有两种:

  1. 函数的方式;

  2. 高阶组件的方式;

传入的参数:

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 中使用起来也不太方便:

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

  2. 组件卸载时,要主动取消对 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 的实现方式了。

see you-蚊子的前端博客

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK