無限滾動&懶加載&元素位置 etc....

最近遇到了無限滾動的問題,用到了IntersectionObserver API,這裏正好就將相關知識梳理整理一下。

前置概念知識

  1. scrollHeight:容器元素被撐起後的實際大小高度
  2. scrollTop:容器元素的滾動距離,也是當前視口相對於容器實際高度定點的距離。
  3. offsetTop:元素的上外邊框至包含元素的上內邊框之間的像素距離。
  4. el.getBoundingClientRect().top:方法返回元素的大小及其相對於視口的位置。(top相對於視口的高度距離)
  5. clientHeight:元素內容區高度加上上下內邊距高度,clientHeight = content + padding。
  6. IntersectionObserver API:
    (阮老師的這篇文章介紹的很清晰:連接,下面是部分引用)
var io = new IntersectionObserver(callback, option);
// 開始觀察
io.observe(document.getElementById('example'));

// 停止觀察
io.unobserve(element);

// 關閉觀察器
io.disconnect();

由瀏覽器原生提供的構造函數,異步觀察目標元素與其祖先元素或頂級文檔視窗(viewport)交叉狀態的方法,接受兩個參數:callback是可見性變化時的回調函數,option是配置對象(該參數可選)。

構造函數的返回值是一個觀察器實例。實例的observe方法可以指定觀察哪個 DOM 節點。可觀察多個實例,

目標元素的可見性變化時,就會調用觀察器的回調函數callback。
callback一般會觸發兩次。一次是目標元素剛剛進入視口(開始可見),另一次是完全離開視口(開始不可見)。

var io = new IntersectionObserver(
  entries => {
    console.log(entries);
  }
);

回調函數的參數是一個IntersectionObserverEntry對象數組,監聽了多少,數組就有多少IntersectionObserverEntry對象。
對象包含了以下數據:
在這裏插入圖片描述
參數意思爲:

time:可見性發生變化的時間,是一個高精度時間戳,單位爲毫秒
target:被觀察的目標元素,是一個 DOM 節點對象
rootBounds:根元素的矩形區域的信息,getBoundingClientRect()方法的返回值,如果沒有根元素(即直接相對於視口滾動),則返回null
boundingClientRect:目標元素的矩形區域的信息
intersectionRect:目標元素與視口(或根元素)的交叉區域的信息
intersectionRatio:目標元素的可見比例,即intersectionRect佔boundingClientRect的比例,完全可見時爲1,完全不可見時小於等於0

無限滾動實現方案

1. 判斷容器是否滾動到“底”

一般底部有個提示性的footer,暫定20px的話,核心代碼即爲:

 container.scrollHeight -
        container.scrollTop -
        container.clientHeight <
        20

注意一般會加上節流防抖,不同的瀏覽器的onscroll觸發次數可能差別很大。判斷到底後,就發請求append。

完整代碼實例:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
  <style>
    body {
      margin: 0;
    }

    .container {
      height: 500px;
      overflow-y: scroll;
    }

    .item {
      width: 100%;
      height: 200px;
      margin-bottom: 20px;
      background-color: antiquewhite;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  </style>
</head>

<body>
  <div class="container">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item cur">3</div>
    <div class="item">4</div>
  </div>
  <script type="text/javascript">
    let container = document.getElementsByClassName("container")[0];

    function debounce(fn, delay) {
      let timer = null;
      return function (args) {
        if (timer) {
          clearTimeout(timer);
        }
        timer = setTimeout(function () {
          fn.apply(this, args);
        }, delay);
      };
    }
    let k = 5;
    const bottomAppend4item = () => {
      console.log('滾動函數執行了');
      // 20一般爲滾動到底部前的一個底部標籤大小值,這裏以剛好的margin爲例
      if (
        container.scrollHeight -
        container.scrollTop -
        container.clientHeight <
        20
      ) {
        console.log('true');

        let cnt = 4;
        while (cnt--) {
          console.log(1);
          let newItem = document.createElement("div");
          newItem.setAttribute("class", "item");
          newItem.innerText = `${k++}`
          container.appendChild(newItem)
        }
        cnt = 4;

      }
    }
    const debounceAJAX = debounce(bottomAppend4item, 50)
    container.onscroll = debounceAJAX;
  </script>
</body>

</html>

2. IntersectionObserver檢測最後一個元素的intersectionRatio顯示比

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      .img-area {
        width: 500px;
        height: 500px;
        margin: 0 auto;
      }

      .my-photo {
        width: 500px;
        height: 300px;
      }
    </style>
  </head>

  <body>
    <div id="container">
      <div class="img-area">
        <img class="my-photo" alt="loading" src="./img/1.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" src="./img/2.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" src="./img/3.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" src="./img/4.png" />
      </div>
    </div>
    <div class="scrollerFooter1">
      沒有內容了
    </div>
    <script>
      function infinityScroll(footerNode, callback) {
        var observer = new IntersectionObserver(function (changes) {
          // 注意intersectionRatio這個屬性值的判斷
          if (changes[0].intersectionRatio <= 0) return;

          callback();
        });
        observer.observe(document.querySelector(footerNode));
      }
      infinityScroll(".scrollerFooter1", function () {
        for (var i = 0; i < 3; i++) {
          document
            .getElementById("container")
            .appendChild(document.getElementById("container").firstChild);
        }
      });
    </script>
  </body>
</html>

用IntersectionObserver實現懶加載方案

事先把img元素的鏈接放在data-*的data-src裏,判斷元素的intersectionRatio佔比出現後,取出data-src動態賦值給src加載圖片。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      .img-area {
        width: 500px;
        height: 500px;
        margin: 0 auto;
      }

      .my-photo {
        width: 500px;
        height: 300px;
      }
    </style>
  </head>

  <body>
    <div class="container">
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/1.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/2.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/3.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/4.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/5.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/1.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/2.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/3.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/4.png" />
      </div>
      <div class="img-area">
        <img class="my-photo" alt="loading" data-src="./img/5.png" />
      </div>
    </div>
    <script>
      function lazyLoad(imgClassName) {
        const imgList = Array.from(document.querySelectorAll(imgClassName));
        var io = new IntersectionObserver(function (ioes) {
          ioes.forEach(function (ioe) {
            var el = ioe.target;
            var intersectionRatio = ioe.intersectionRatio;
            if (intersectionRatio > 0 && intersectionRatio <= 1) {
              if (!el.src) {
                el.src = el.dataset.src;
              }
            }
          });
        });
        imgList.forEach(function (item) {
          io.observe(item);
        });
      }
      lazyLoad(".my-photo");
    </script>
  </body>
</html>

判斷元素是否在視口中

上面兩個場景問題其實都可以引申出一個方法,如何判斷元素出現在視口中。

1. 滾動屬性判斷

公式: el.offsetTop - document.documentElement.scrollTop <= viewPortHeight

function isInViewPortOfOne (el) {
    // viewPortHeight 兼容所有瀏覽器寫法
    const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight 
    const offsetTop = el.offsetTop
    const scrollTop = document.documentElement.scrollTop
    const top = offsetTop - scrollTop
    console.log('top', top)
     // 這裏有個+100是爲了提前加載+ 100
    return top <= viewPortHeight + 100
}
2. 直接getBoundingClientRect

上面在前置中就介紹了這個方法就是相對於視口的距離,el.getBoundingClientReact().top <= viewPortHeight
跟上面公式連在一起,可以得出:el.offsetTop - document.documentElement.scrollTop = el.getBoundingClientRect().top

例子:

function isInViewPortOfTwo (el) {
    const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight 
    const top = el.getBoundingClientRect() && el.getBoundingClientRect().top
    console.log('top', top)
    return top  <= viewPortHeight + 100
}
3. IntersectionObserver的intersectionRatio比

同上無限滾動和懶加載的代碼和介紹

看到有人問如果上面公式的結果小於0的怎麼辦不判斷嗎,小於0其實就是滾動離開了視口區域。我們一般利用這些方法對元素做初次的渲染,這個問題不在這個場景的考慮範疇。

一些polyfill

對於getBoundingClientRect的polyfill支持,可以用更“原始”的offsetXX屬性實現。

function getElementTop(element){
    var actualTop = element.offsetTop;
    var current = element.offsetParent;
    while (current !== null){
        actualTop += current. offsetTop;
        current = current.offsetParent;
    }
    return actualTop;
}

function getBoundingClientRect(element) {
    var scrollTop = document.documentElement.scrollTop;
    var scrollLeft = document.documentElement.scrollLeft;
    if (element.getBoundingClientRect) {
        if (typeof arguments.callee.offset != "number") {
            var temp = document.createElement("div");
            temp.style.cssText = "position:absolute;left:0;top:0;"; document.body.appendChild(temp);
            arguments.callee.offset = -temp.getBoundingClientRect().top - scrollTop; document.body.removeChild(temp);
            temp = null;
        }
        var rect = element.getBoundingClientRect();
        var offset = arguments.callee.offset;
        return {
            left: rect.left + offset,
            right: rect.right + offset,
            top: rect.top + offset,
            bottom: rect.bottom + offset
        };
    } else {
        var actualLeft = getElementLeft(element);
        var actualTop = getElementTop(element);
        return {
            left: actualLeft - scrollLeft,
            right: actualLeft + element.offsetWidth - scrollLeft,
            top: actualTop - scrollTop,
            bottom: actualTop + element.offsetHeight - scrollTop
        }
    }
}

封裝一個動態獲取高度的組件

之前一些中臺項目,我們也封裝了動態獲取高度的React組件,用此高階組件封裝的組件,可以動態給子組件設置當前瀏覽器文檔流剩餘空間的高度,使整體剛好佔滿屏幕(當然超過有滾動條)。原理就是差不多也是用了上面的一些元素位置屬性。

import React, { useState, useRef, useLayoutEffect } from 'react';

import { getElementTop, triggerEvent } from 'utils/tools';

type ObtainHeightProps = {
  bgColor?: string; // 背景色
  children: React.ReactNode;
  overflow?: string;
  className?: string;
};

//  計算滾動內容高度的組件
const ObtainHeight = (props: ObtainHeightProps) => {
  const { className, bgColor, overflow } = props;
  const obtainRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(
    document.documentElement.clientHeight - 200
  );

  const setObtainHeight = () => {
    const contentDom: HTMLDivElement | null = obtainRef.current;
    const top = getElementTop(contentDom as HTMLDivElement);
    const clientHeight = document.documentElement.clientHeight - top - 20;
    setHeight(clientHeight);
  };
  useLayoutEffect(() => {
    setObtainHeight();
    window.onresize = () => {
      setObtainHeight();
    };
    // 主動觸發一次resize,解決計算不準確的bug
    const evt = window.document.createEvent('UIEvents');
    evt.initEvent('resize', true, false);
    window.dispatchEvent(evt);
    setTimeout(() => {
      triggerEvent(window, 'resize');
    }, 200);
    return () => {
      window.onresize = null;
    };
  }, []);

  return (
    <div
      className={`${className} obtain-height`}
      ref={obtainRef}
      style={{ height, background: bgColor, overflow }}
    >
      {props.children}
    </div>
  );
};
export default ObtainHeight;

/**
 * @description: 獲取元素在頁面距離頂部的偏移量
 * @param {element} 元素節點
 * @return:  Number
 */
export const getElementTop: (element: HTMLDivElement) => number = (
  element: HTMLDivElement
) => {
  // 獲取 element 元素距離父元素的 offsetTop 像素;
  // console.log(element);
  if (!element) return 200;
  let actualTop = element.offsetTop;
  let current = element.offsetParent;
  // console.log(element, actualTop, current);
  // 判斷當前元素是都循環到 HTML 根元素了
  while (current !== null) {
    // offsetTop 循環相加
    actualTop += (current as HTMLDivElement).offsetTop;
    // 當 current 爲 HTML 根元素是, current.offsetParent 的值爲 null
    current = (current as HTMLDivElement).offsetParent;
    // console.log(element, actualTop, current);
  }
  // console.log(actualTop);
  return actualTop;
};
// 事件觸發函數
export function triggerEvent(el: Element | Window, type: string) {
  if ('createEvent' in document) {
    // modern browsers, IE9+
    const e = document.createEvent('HTMLEvents');
    e.initEvent(type, false, true);
    el.dispatchEvent(e);
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章