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


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

    开心的一天

    那么如何知道模块对用户是否曝光了呢?之前我们是监听页面的滚动事件,然后通过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,
         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

    3. 总结

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

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

    也欢迎您关注我的公众号:“前端小茶馆”。

    前端小茶馆公众号

  • 相关阅读:
    springboot日期格式转换
    通过nginx访问本地图片
    终止线程池对应某个线程
    下载文件并将下载成功的文件考入另一个目录
    centos7安装mysql5.7
    java dwg转svg
    PostgreSQL 实现按月按年,按日统计 分组统计
    oracle查询语句,根据中文的拼音排序
    java操作solr
    AMD,CMD,UMD 三种模块规范 写法格式
  • 原文地址:https://www.cnblogs.com/xumengxuan/p/14830263.html
Copyright © 2020-2023  润新知