为了账号安全,请及时绑定邮箱和手机立即绑定

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

标签:
JavaScript

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

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

3. 总结

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

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
14
获赞与收藏
47

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消