基於 IntersectionObserver 實現一個元件的曝光監控

蚊子部落格發表於2021-05-31

我們在產品推廣過程中,經常需要判斷使用者是否對某個模組感興趣。那麼就需要獲取該模組的曝光量和使用者對該模組的點選量,若點選量/曝光量越高,說明該模組越有吸引力。

開心的一天

那麼如何知道模組對使用者是否曝光了呢?之前我們是監聽頁面的滾動事件,然後通過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

3. 總結

現在我們已經基本實現了關於元件的曝光的監聽方式,整篇文章的核心全部都在IntersectionObserver上。基於上面的實現方式,我們其實還可以繼續擴充套件,比如在元件即將曝光時踩初始化元件;頁面中的倒數計時只有在可見時才執行,不可見時則直接停掉等等。

IntersectionObserver 還等著我們探索出更多的用法!

也歡迎您關注我的公眾號:“前端小茶館”。

前端小茶館公眾號

相關文章