精讀《react-intersection-observer 原始碼》

黃子毅發表於2020-06-23

1 引言

IntersectionObserver 可以輕鬆判斷元素是否可見,在之前的 精讀《用 React 做按需渲染》 中介紹了原生 API 的方法,這次剛好看到其 React 封裝版本 react-intersection-observer,讓我們看一看 React 封裝思路。

2 簡介

react-intersection-observer 提供了 Hook useInView 判斷元素是否在可視區域內,API 如下:

import React from "react";
import { useInView } from "react-intersection-observer";

const Component = () => {
  const [ref, inView] = useInView();

  return (
    <div ref={ref}>
      <h2>{`Header inside viewport ${inView}.`}</h2>
    </div>
  );
};

由於判斷元素是否可見是基於 dom 的,所以必須將 ref 回撥函式傳遞給 代表元素輪廓的 DOM 元素,上面的例子中,我們將 ref 傳遞給了最外層 DIV。

useInView 還支援下列引數:

  • root:檢測是否可見基於的視窗元素,預設是整個瀏覽器 viewport。
  • rootMargin:root 邊距,可以在檢測時提前或者推遲固定畫素判斷。
  • threshold:是否可見的閾值,範圍 0 ~ 1,0 表示任意可見即為可見,1 表示完全可見即為可見。
  • triggerOnce:是否僅觸發一次。

3 精讀

首先從入口函式 useInView 開始解讀,這是一個 Hook,利用 ref 儲存上一次 DOM 例項,state 則儲存 inView 元素是否可見的 boolean 值:

export function useInView(
  options: IntersectionOptions = {},
): InViewHookResponse {
  const ref = React.useRef<Element>()
  const [state, setState] = React.useState<State>(initialState)

  // 中間部分..

  return [setRef, state.inView, state.entry]
}

當元件 ref 被賦值時會呼叫 setRef,回撥 node 是新的 DOM 節點,因此先 unobserve(ref.current) 取消舊節點的監聽,再 observe(node) 對新節點進行監聽,最後 ref.current = node 更新舊節點:

// 中間部分 1
const setRef = React.useCallback(
  (node) => {
    if (ref.current) {
      unobserve(ref.current);
    }

    if (node) {
      observe(
        node,
        (inView, intersection) => {
          setState({ inView, entry: intersection });

          if (inView && options.triggerOnce) {
            // If it should only trigger once, unobserve the element after it's inView
            unobserve(node);
          }
        },
        options
      );
    }

    // Store a reference to the node, so we can unobserve it later
    ref.current = node;
  },
  [options.threshold, options.root, options.rootMargin, options.triggerOnce]
);

另一段是,當 ref 不存在時會清空 inView 狀態,畢竟當不存在監聽物件時,inView 值只有重設為預設 false 才合理:

// 中間部分 2
useEffect(() => {
  if (!ref.current && state !== initialState && !options.triggerOnce) {
    // If we don't have a ref, then reset the state (unless the hook is set to only `triggerOnce`)
    // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
    setState(initialState);
  }
});

這就是入口檔案的邏輯,我們可以看到還有兩個重要的函式 observeunobserve,這兩個函式的實現在 intersection.ts 檔案中,這個檔案有三個核心函式:observeunobserveonChange

  • observe:監聽 element 是否在可視區域。
  • unobserve:取消監聽。
  • onChange:處理 observe 變化的回撥。

先看 observe,對於同一個 root 下的監聽會做合併操作,因此需要生成 observerId 作為唯一標識,這個標識由 getRootIdrootMarginthreshold 共同決定。

對於同一個 root 的監聽下,拿到 new IntersectionObserver() 建立的 observerInstance 例項,呼叫 observerInstance.observe 進行監聽。這裡儲存了兩個 Map - OBSERVER_MAPINSTANCE_MAP,前者是保證同一 root 下 IntersectionObserver 例項唯一,後者儲存了元件 inView 以及回撥等資訊,在 onChange 函式使用:

export function observe(
  element: Element,
  callback: ObserverInstanceCallback,
  options: IntersectionObserverInit = {}
) {
  // IntersectionObserver needs a threshold to trigger, so set it to 0 if it's not defined.
  // Modify the options object, since it's used in the onChange handler.
  if (!options.threshold) options.threshold = 0;
  const { root, rootMargin, threshold } = options;
  // Validate that the element is not being used in another <Observer />
  invariant(
    !INSTANCE_MAP.has(element),
    "react-intersection-observer: Trying to observe %s, but it's already being observed by another instance.\nMake sure the `ref` is only used by a single <Observer /> instance.\n\n%s"
  );
  /* istanbul ignore if */
  if (!element) return;
  // Create a unique ID for this observer instance, based on the root, root margin and threshold.
  // An observer with the same options can be reused, so lets use this fact
  let observerId: string =
    getRootId(root) +
    (rootMargin
      ? `${threshold.toString()}_${rootMargin}`
      : threshold.toString());

  let observerInstance = OBSERVER_MAP.get(observerId);
  if (!observerInstance) {
    observerInstance = new IntersectionObserver(onChange, options);
    /* istanbul ignore else  */
    if (observerId) OBSERVER_MAP.set(observerId, observerInstance);
  }

  const instance: ObserverInstance = {
    callback,
    element,
    inView: false,
    observerId,
    observer: observerInstance,
    // Make sure we have the thresholds value. It's undefined on a browser like Chrome 51.
    thresholds:
      observerInstance.thresholds ||
      (Array.isArray(threshold) ? threshold : [threshold]),
  };

  INSTANCE_MAP.set(element, instance);
  observerInstance.observe(element);

  return instance;
}

對於 onChange 函式,因為採用了多元素監聽,所以需要遍歷 changes 陣列,並判斷 intersectionRatio 超過閾值判定為 inView 狀態,通過 INSTANCE_MAP 拿到對應例項,修改其 inView 狀態並執行 callback

這個 callback 就對應了 useInView Hook 中 observe 的第二個引數回撥:

function onChange(changes: IntersectionObserverEntry[]) {
  changes.forEach((intersection) => {
    const { isIntersecting, intersectionRatio, target } = intersection;
    const instance = INSTANCE_MAP.get(target);

    // Firefox can report a negative intersectionRatio when scrolling.
    /* istanbul ignore else */
    if (instance && intersectionRatio >= 0) {
      // If threshold is an array, check if any of them intersects. This just triggers the onChange event multiple times.
      let inView = instance.thresholds.some((threshold) => {
        return instance.inView
          ? intersectionRatio > threshold
          : intersectionRatio >= threshold;
      });

      if (isIntersecting !== undefined) {
        // If isIntersecting is defined, ensure that the element is actually intersecting.
        // Otherwise it reports a threshold of 0
        inView = inView && isIntersecting;
      }

      instance.inView = inView;
      instance.callback(inView, intersection);
    }
  });
}

最後是 unobserve 取消監聽的實現,在 useInView setRef 灌入新 Node 節點時,會呼叫 unobserve 對舊節點取消監聽。

首先利用 INSTANCE_MAP 找到例項,呼叫 observer.unobserve(element) 銷燬監聽。最後銷燬不必要的 INSTANCE_MAPROOT_IDS 儲存。

export function unobserve(element: Element | null) {
  if (!element) return;
  const instance = INSTANCE_MAP.get(element);

  if (instance) {
    const { observerId, observer } = instance;
    const { root } = observer;

    observer.unobserve(element);

    // Check if we are still observing any elements with the same threshold.
    let itemsLeft = false;
    // Check if we still have observers configured with the same root.
    let rootObserved = false;
    /* istanbul ignore else  */
    if (observerId) {
      INSTANCE_MAP.forEach((item, key) => {
        if (key !== element) {
          if (item.observerId === observerId) {
            itemsLeft = true;
            rootObserved = true;
          }
          if (item.observer.root === root) {
            rootObserved = true;
          }
        }
      });
    }
    if (!rootObserved && root) ROOT_IDS.delete(root);
    if (observer && !itemsLeft) {
      // No more elements to observe for threshold, disconnect observer
      observer.disconnect();
    }

    // Remove reference to element
    INSTANCE_MAP.delete(element);
  }
}

從其實現角度來看,為了保證正確識別到子元素存在,一定要保證 ref 能持續傳遞給元件最外層 DOM,如果出現傳遞斷裂,就會判定當前元件不在檢視內,比如:

const Component = () => {
  const [ref, inView] = useInView();

  return <Child ref={ref} />;
};

const Child = ({ loading, ref }) => {
  if (loading) {
    // 這一步會判定為 inView:false
    return <Spin />;
  }

  return <div ref={ref}>Child</div>;
};

如果你的程式碼基於 inView 做了阻止渲染的判定,那麼這個元件進入 loading 後就無法改變狀態了。為了避免這種情況,要麼不要讓 ref 的傳遞斷掉,要麼當沒有拿到 ref 物件時判定 inView 為 true。

4 總結

分析了這麼多 React- 類的庫,其核心思想有兩個:

  1. 將原生 API 轉換為框架特有 API,比如 React 系列的 Hooks 與 ref。
  2. 處理生命週期導致的邊界情況,比如 dom 被更新時先 unobserve 再重新 observe

看過 react-intersection-observer 的原始碼後,你覺得還有可優化的地方嗎?歡迎討論。

討論地址是:react-intersection-observer 原始碼》· Issue #257 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

本文使用 mdnice 排版

相關文章