動態監聽 DOM 元素高度變化

1、背景

考慮這樣一種情況,產品同學希望達到以下功能:

在我們的網頁中有一個固定區域,這個區域會用於渲染從後端拉取的含有圖片等資源的富文本字符串。

他需要在內容不超過一個最大高度的時候完全顯示所有內容,超過最大內容後僅展示最大高度範圍內的內容,超出部分隱藏,並通過一個按鈕 “展示更多” 來給用戶展示更多的選擇。

在這看似簡單的需求當中,其實涉及到了一個難點,那就是怎樣動態的監聽到內容區域的高度變化?

因爲在這裏面會含有圖片資源,他們在渲染的時候會發起網絡請求,等待圖片加載完成後觸發瀏覽器重排,該區域的高度被撐開。

因此,內容區域的高度是動態變化,且變化的時間點是未知的,那麼怎樣知道我們的內容區高度發生了變化呢?

爲此我做了以下幾種嘗試:

2、MutationObserver

MutationObserver 接口提供了監視對 DOM 樹所做更改的能力。它被設計爲舊的 Mutation Events 功能的替代品,該功能是 DOM3 Events 規範的一部分。

observe(target, options)

這個方法會根據傳入的 options 配置,觀察 DOM 樹中的單個 Node 或者所有的子孫節點的變化。

他一共有七個屬性,這裏就不一一介紹了,可以通過 MutationObserverInit 來獲取相應的介紹.

那麼我們要怎麼使用這個 API 來監聽目標區域的高度變化呢?

  1. 首先我們要創建對該區域的 dom 根結點引用:
// useRef創建引用
const contentRef = useRef();

// 綁定ref
<div
  class
  dangerouslySetInnerHTML={{ __html: details }}
  style={{ maxHeight }}
  ref={contentRef}
/>;
  1. 然後我們需要創建一個 MutationObserver 實例:
const [height, setHeight] = useState(-1);
const [observer, setObserver] = useState<MutationObserver>(null!);
useEffect(() => {
  const observer = new MutationObserver((mutationList) => {
    if (height !== contentRef.current?.clientHeight) {
      console.log("高度變化了!");
      setHeight(contentRef.current.clientHeight);
    }
  });
  setObserver(observer);
}, []);
  1. 當我們的 ref 或者 observer 發生變化的時候,對 ref 節點進行觀察:
useEffect(() => {
  if (!observer || !contentRef.current) return;
  observer.observe(contentRef.current, {
    childList: true, // 子節點的變動(新增、刪除或者更改)
    attributes: true, // 屬性的變動
    characterData: true, // 節點內容或節點文本的變動
    subtree: true, // 是否將觀察器應用於該節點的所有後代節點
  });
}, [contentRef.current, observer]);

完整代碼:

const Details = () => {
    // useRef創建引用
    const contentRef = useRef();
    const [height, setHeight] = useState(-1);
    const [observer, setObserver] = useState<MutationObserver>(null!);

    useEffect(() => {
          const observer = new MutationObserver((mutationList) => {
            if (height !== contentRef.current?.clientHeight) {
                console.log('高度變化了!');
                setHeight(contentRef.current.clientHeight);
            }
          });
          setObserver(observer);
    }, []);

    useEffect(() => {
          if (!observer || !contentRef.current) return
          observer.observe(contentRef, {
            childList: true, // 子節點的變動(新增、刪除或者更改)
            attributes: true, // 屬性的變動
            characterData: true, // 節點內容或節點文本的變動
            subtree: true// 是否將觀察器應用於該節點的所有後代節點
          });
    }, [contentRef.current, observer]);

    // 綁定ref
    return<div class dangerouslySetInnerHTML={{ __html: details }} style={{ maxHeight }} ref={contentRef} />
}

經過上面的一番操作之後,發現根本達不到效果,因爲我們的 css 屬性根本沒有發生變化(我們是通過 maxHeight 來約束容器的高度的), 但是資源加載完畢之後,瀏覽器重排根本沒有產生 css 屬性的變化,它的高度是自動計算的

因此這個方案無濟於事!但是它確實可以監聽到認爲修改容器的高度產生的變化,比如:contentRef.current.style.height = '1000px',這個 api 是可以監聽到這一操作的,但是並不符合我們的場景

此外,它的瀏覽器兼容性也還行:

3、IntersectionObserver

經過激情編碼,最後發現 MutationObserver 根本達不到我們想要的效果之後,其實我的心態已經產生了一些變化,不過不要緊!

我們可以換一種思路,既然我們無法通過監聽容器的高度變化來展示相應的 “展開更多” 操作,那麼我們可不可以將這個 “展開更多” 固定到一個位置上,然後超出部分隱藏,

當我們的內容自動撐開,達到指定高度後,我們這個 “展開更多” 的操作的按鈕就顯示出來了,聽上去不錯,能達到要求!廢話不多說,開擼!

因爲這裏只涉及到相應的 css 樣式的書寫,就不做展示了。

經過處理之後,確實在容器高度小於指定高度的時候,“展示更多” 按鈕不會展示,超過最大值之後,會將該按鈕展示出來,

但是也遇到了一個問題,操作按鈕是有高度的,如果我們的內容高度介於最大高度 - 按鈕高度 到 容器的最大高度之間, 按鈕會產生顯示一部分,同時又隱藏一部分的效果,這可不是我們想要的!

如圖所示:

顯然這種效果是不符合要求的,我們的 “展示更多” 按鈕,只有兩種狀態,要麼全部展示,要麼不展示,沒有這種部分展示的效果

因此我查閱了相關資料,瞭解到了 IntersectionObserver 這個 API,它可以監聽一個元素是否進入用戶視野,它的相關使用方法可以參考這篇文章:IntersectionObserver API 使用教程

它使用起來和 MutationObserver 幾乎一樣,只是名字不一樣而已

它監聽的值裏面有一個比較重要的屬性:intersectionRatio

藉助這個 API,我的設計思路是這樣的:

當用戶滾動網頁的時候(或者不滾動,此時目標區域已經出現在屏幕中),可以得到 intersectionRatio 的值,通過判斷這個值是否等於 1 來決定要不要展示 “展示更多” 按鈕

但經過我的編碼實現後,發現滾動事件發生的時候,intersectionRatio 的變化是不可靠的,有時候完全可見了,但是它並不等於 1。經過多輪實驗,結果依然如此。但是它確實可以用來判斷一個元素是否進入用戶視野

由於使用上結果的不可靠,我放棄這個方案(可能是我使用方式上出了問題)

它的各瀏覽器兼容性如下:

4、ResizeObserver

顧名思義,這個 API 就是專門監聽 DOM 尺寸變化的,只不過它還處於試驗階段,各瀏覽器的兼容性很差,所以基本不考慮

具體使用方法可以參考這篇文章:檢測 DOM 尺寸變化 JS API ResizeObserver 簡介

它現階段各瀏覽器的兼容性情況:

5、監聽所有資源的 onload 事件

既然上述方法都不行,那麼我絞盡腦汁,又想出了另外一種方法:監聽所有帶有 src 屬性的 DOM 元素的 onload 事件,通過他的回調來判斷當前容器的高度情況

這種實現方式,在思路上是完全符合目的的,具體做法參考如下:

const [height, setHeight] = useState(-1);
const [showMore, setShowMore] = useState(false);
// contentRef 的定義見 MutationObserver 一節
useEffect(() => {
  const sources = contentRef.current.querySelectorAll("[src]");
  sources.onload = () => {
    const height = contentRef?.current?.clientHeight ?? 0;
    const show = height >= parseInt(MAX_HEIGHT, 10);

    setHeight(height);
    setShowMore(show);
  };
}, []);

通過這種方式可以實現對富文本中的圖片進行加載後,對容器高度進行相應的判斷。

但是這種方式,存在不確定性,即無法判斷是否找齊了所有高度由內容撐開的資源。

6、Iframe

這是終極方案,也是在此背景中所採用的方案。

既然 window 可以監聽到 resize 事件,那麼我們就可以利用 iframe 來達到同樣的效果,具體做法就是在容器裏面嵌套一個隱藏的高度爲 100% 的 iframe,通過監聽他的 resize 事件,來判斷當前容器的高度。

話不多說,具體實現方式如下:

const Detail: FC<{}> = () => {
  const ref = useRef<HTMLDivElement>(null);
  const ifr = useRef<HTMLIFrameElement>(null);
  const [height, setHeight] = useState(-1);
  const [showMore, setShowMore] = useState(false);
  const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
  const introduceInfo = useAppSelect(
    (state) => state.courseInfo?.data?.introduce_info ?? {}
  );
  const details = introduceInfo.details ?? "";
  const isFolded = maxHeight === MAX_HEIGHT;
  const onresize = useCallback(() => {
    const height = ref?.current?.clientHeight ?? 0;
    const show = height >= parseInt(MAX_HEIGHT, 10);

    setHeight(height);
    setShowMore(show);
    if (ifr.current && show) {
      ifr.current.remove();
    }
  }, []);

  useEffect(() => {
    if (!ref.current || !ifr.current?.contentWindow) return;
    ifr.current.contentWindow.onresize = onresize;
    onresize();
  }, [details]);

  if (!details) returnnull;

  return (
    <section class>
      <div class>
        <div
          class
          dangerouslySetInnerHTML={{ __html: details }}
          style={{ maxHeight }}
          ref={ref}
        />
        {/* 這個iframe是用來動態監聽content高度的變化的 */}
        <iframe title={IFRAME_ID} id={IFRAME_ID} ref={ifr} />
      </div>
      {isFolded && showMore && (
        <>
          <div
            class
            onClick={() => {
              setMaxHeight(isFolded ? "none" : MAX_HEIGHT);
            }}
          >
            查看全部
            <IconArrowDown class />
          </div>
          <div class />
        </>
      )}
    </section>
  );
};

這種方式實際上就是對 ResizeObserver 的一種 hack,經過多次實踐,符合功能要求。

7、總結

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/adz6CG-r17qh6hC8AduUZA