90行程式碼,15個元素實現無限滾動

前端勸退師發表於2019-09-18

前言

在本篇文章你將會學到:

  • IntersectionObserver API 的用法,以及如何相容。
  • 如何在React Hook中實現無限滾動。
  • 如何正確渲染多達10000個元素的列表。
    90行程式碼,15個元素實現無限滾動
    無限下拉載入技術使使用者在大量成塊的內容面前一直滾動檢視。這種方法是在你向下滾動的時候不斷載入新內容。

當你使用滾動作為發現資料的主要方法時,它可能使你的使用者在網頁上停留更長時間並提升使用者參與度。隨著社交媒體的流行,大量的資料被使用者消費。無線滾動提供了一個高效的方法讓使用者瀏覽海量資訊,而不必等待頁面的預載入。

90行程式碼,15個元素實現無限滾動

如何構建一個體驗良好的無限滾動,是每個前端無論是專案或面試都會碰到的一個課題。

本文的原版實現來自:Creating Infinite Scroll with 15 Elements

1. 早期的解決方案

關於無限滾動,早期的解決方案基本都是依賴監聽scroll事件:

function fetchData() {
  fetch(path).then(res => doSomeThing(res.data));
}

window.addEventListener('scroll', fetchData);
複製程式碼

然後計算各種.scrollTop().offset().top等等。

手寫一個也是非常枯燥。而且:

  • scroll事件會頻繁觸發,因此我們還需要手動節流。
  • 滾動元素內有大量DOM,容易造成卡頓。

90行程式碼,15個元素實現無限滾動
後來出現交叉觀察者IntersectionObserver API ,在與VueReact這類資料驅動檢視的框架後,無限滾動的通用方案就出來了。

2. 交叉觀察者:IntersectionObserver

const box = document.querySelector('.box');
const intersectionObserver = new IntersectionObserver((entries) => {
  entries.forEach((item) => {
    if (item.isIntersecting) {
      console.log('進入可視區域');
    }
  })
});
intersectionObserver.observe(box);
複製程式碼

敲重點: IntersectionObserver API是非同步的,不隨著目標元素的滾動同步觸發,效能消耗極低。

2.1 IntersectionObserverEntry物件

90行程式碼,15個元素實現無限滾動
這裡我就粗略的介紹下需要用到的:

IntersectionObserverEntry物件

callback函式被呼叫時,會傳給它一個陣列,這個陣列裡的每個物件就是當前進入可視區域或者離開可視區域的物件(IntersectionObserverEntry物件)

這個物件有很多屬性,其中最常用的屬性是:

  • target: 被觀察的目標元素,是一個 DOM 節點物件
  • isIntersecting: 是否進入可視區域
  • intersectionRatio: 相交區域和目標元素的比例值,進入可視區域,值大於0,否則等於0

2.3 options

呼叫IntersectionObserver時,除了傳一個回撥函式,還可以傳入一個option物件,配置如下屬性:

  • threshold: 決定了什麼時候觸發回撥函式。它是一個陣列,每個成員都是一個門檻值,預設為[0],即交叉比例(intersectionRatio)達到0時觸發回撥函式。使用者可以自定義這個陣列。比如,[0, 0.25, 0.5, 0.75, 1]就表示當目標元素 0%、25%、50%、75%、100% 可見時,會觸發回撥函式。
  • root: 用於觀察的根元素,預設是瀏覽器的視口,也可以指定具體元素,指定元素的時候用於觀察的元素必須是指定元素的子元素
  • rootMargin: 用來擴大或者縮小視窗的的大小,使用css的定義方法,10px 10px 30px 20px表示top、right、bottom 和 left的值
const io = new IntersectionObserver((entries) => {
  console.log(entries);
}, {
  threshold: [0, 0.5],
  root: document.querySelector('.container'),
  rootMargin: "10px 10px 30px 20px",
});
複製程式碼

2.4 observer

observer.observer(nodeone); //僅觀察nodeOne 
observer.observer(nodeTwo); //觀察nodeOne和nodeTwo 
observer.unobserve(nodeOne); //停止觀察nodeOne
observer.disconnect(); //沒有觀察任何節點
複製程式碼

3. 如何在React Hook中使用IntersectionObserver

在看Hooks版之前,來看正常元件版的:

class SlidingWindowScroll extends React.Component {
this.$bottomElement = React.createRef();
...
componentDidMount() {
    this.intiateScrollObserver();
}
intiateScrollObserver = () => {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 0.1
    };
    this.observer = new IntersectionObserver(this.callback, options);
    this.observer.observe(this.$bottomElement.current);
}
render() {
    return (
    <li className='img' ref={this.$bottomElement}>
    )
}
複製程式碼

眾所周知,React 16.x後推出了useRef來替代原有的createRef,用於追蹤DOM節點。那讓我們開始吧:

4. 原理

實現一個元件,可以顯示具有15個元素的固定視窗大小的n個專案的列表: 即在任何時候,無限滾動n元素上也僅存在15個DOM節點。

90行程式碼,15個元素實現無限滾動

  • 採用relative/absolute 定位來確定滾動位置
  • 追蹤兩個ref: top/bottom來決定向上/向下滾動的渲染與否
  • 切割資料列表,保留最多15個DOM元素。

5. useState宣告狀態變數

我們開始編寫元件SlidingWindowScrollHook:

const THRESHOLD = 15;
const SlidingWindowScrollHook = (props) =>  {
  const [start, setStart] = useState(0);
  const [end, setEnd] = useState(THRESHOLD);
  const [observer, setObserver] = useState(null);
  // 其它程式碼...
}
複製程式碼

1. useState的簡單理解:

const [屬性, 操作屬性的方法] = useState(預設值);
複製程式碼

2. 變數解析

  • start:當前渲染的列表第一個資料,預設為0
  • end: 當前渲染的列表最後一個資料,預設為15
  • observer: 當前觀察的檢視ref元素

6. useRef定義追蹤的DOM元素

const $bottomElement = useRef();
const $topElement = useRef();
複製程式碼

正常的無限向下滾動只需關注一個dom元素,但由於我們是固定15個dom元素渲染,需要判斷向上或向下滾動。

7. 內部操作方法和和對應useEffect

請配合註釋食用:

useEffect(() => {
    // 定義觀察
    intiateScrollObserver();
    return () => {
      // 放棄觀察
      resetObservation()
  }
},[end]) //因為[end] 是同步重新整理,這裡用一個就行了。

// 定義觀察
const intiateScrollObserver = () => {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 0.1
    };
    const Observer = new IntersectionObserver(callback, options)
    // 分別觀察開頭和結尾的元素
    if ($topElement.current) {
      Observer.observe($topElement.current);
    }
    if ($bottomElement.current) {
      Observer.observe($bottomElement.current);
    }
    // 設初始值
    setObserver(Observer)    
}

// 交叉觀察的具體回撥,觀察每個節點,並對實時頭尾元素索引處理
const callback = (entries, observer) => {
    entries.forEach((entry, index) => {
      const listLength = props.list.length;
      // 向下滾動,重新整理資料
      if (entry.isIntersecting && entry.target.id === "bottom") {
        const maxStartIndex = listLength - 1 - THRESHOLD;     // 當前頭部的索引
        const maxEndIndex = listLength - 1;                   // 當前尾部的索引
        const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex; // 下一輪增加尾部
        const newStart = (end - 5) <= maxStartIndex ? end - 5 : maxStartIndex; // 在上一輪的基礎上計算頭部
        setStart(newStart)
        setEnd(newEnd)
      }
      // 向上滾動,重新整理資料
      if (entry.isIntersecting && entry.target.id === "top") {
        const newEnd = end === THRESHOLD ? THRESHOLD : (end - 10 > THRESHOLD ? end - 10 : THRESHOLD); // 向上滾動尾部元素索引不得小於15
        let newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0); // 頭部元素索引最小值為0
        setStart(newStart)
        setEnd(newEnd)
        }
    });
}

// 停止滾動時放棄觀察
const resetObservation = () => {
    observer && observer.unobserve($bottomElement.current); 
    observer && observer.unobserve($topElement.current);
}

// 渲染時,頭尾ref處理
const getReference = (index, isLastIndex) => {
    if (index === 0)
      return $topElement;
    if (isLastIndex) 
      return $bottomElement;
    return null;
}
複製程式碼

8. 渲染介面


  const {list, height} = props; // 資料,節點高度
  const updatedList = list.slice(start, end); // 資料切割
  
  const lastIndex = updatedList.length - 1;
  return (
    <ul style={{position: 'relative'}}>
      {updatedList.map((item, index) => {
        const top = (height * (index + start)) + 'px'; // 基於相對 & 絕對定位 計算
        const refVal = getReference(index, index === lastIndex); // map迴圈中賦予頭尾ref
        const id = index === 0 ? 'top' : (index === lastIndex ? 'bottom' : ''); // 綁ID
        return (<li className="li-card" key={item.key} style={{top}} ref={refVal} id={id}>{item.value}</li>);
      })}
    </ul>
  );
複製程式碼

9. 如何使用

App.js:

import React from 'react';
import './App.css';
import { SlidingWindowScrollHook } from "./SlidingWindowScrollHook";
import MY_ENDLESS_LIST from './Constants';

function App() {
  return (
    <div className="App">
     <h1>15個元素實現無限滾動</h1>
      <SlidingWindowScrollHook list={MY_ENDLESS_LIST} height={195}/>
    </div>
  );
}

export default App;
複製程式碼

定義一下資料 Constants.js:

const MY_ENDLESS_LIST = [
  {
    key: 1,
    value: 'A'
  },
  {
    key: 2,
    value: 'B'
  },
  {
    key: 3,
    value: 'C'
  },
  // 中間就不貼了...
  {
    key: 45,
    value: 'AS'
  }
]
複製程式碼

SlidingWindowScrollHook.js:

import React, { useState, useEffect, useRef } from "react";
const THRESHOLD = 15;

const SlidingWindowScrollHook = (props) =>  {
  const [start, setStart] = useState(0);
  const [end, setEnd] = useState(THRESHOLD);
  const [observer, setObserver] = useState(null);
  const $bottomElement = useRef();
  const $topElement = useRef();

  useEffect(() => {
    intiateScrollObserver();
    return () => {
      resetObservation()
  }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  },[start, end])

  const intiateScrollObserver = () => {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 0.1
    };
    const Observer = new IntersectionObserver(callback, options)
    if ($topElement.current) {
      Observer.observe($topElement.current);
    }
    if ($bottomElement.current) {
      Observer.observe($bottomElement.current);
    }
    setObserver(Observer)    
  }

  const callback = (entries, observer) => {
    entries.forEach((entry, index) => {
      const listLength = props.list.length;
      // Scroll Down
      if (entry.isIntersecting && entry.target.id === "bottom") {
        const maxStartIndex = listLength - 1 - THRESHOLD;     // Maximum index value `start` can take
        const maxEndIndex = listLength - 1;                   // Maximum index value `end` can take
        const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex;
        const newStart = (end - 5) <= maxStartIndex ? end - 5 : maxStartIndex;
        setStart(newStart)
        setEnd(newEnd)
      }
      // Scroll up
      if (entry.isIntersecting && entry.target.id === "top") {
        const newEnd = end === THRESHOLD ? THRESHOLD : (end - 10 > THRESHOLD ? end - 10 : THRESHOLD);
        let newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0);
        setStart(newStart)
        setEnd(newEnd)
      }
      
    });
  }
  const resetObservation = () => {
    observer && observer.unobserve($bottomElement.current);
    observer && observer.unobserve($topElement.current);
  }


  const getReference = (index, isLastIndex) => {
    if (index === 0)
      return $topElement;
    if (isLastIndex) 
      return $bottomElement;
    return null;
  }

  const {list, height} = props;
  const updatedList = list.slice(start, end);
  const lastIndex = updatedList.length - 1;
  
  return (
    <ul style={{position: 'relative'}}>
      {updatedList.map((item, index) => {
        const top = (height * (index + start)) + 'px';
        const refVal = getReference(index, index === lastIndex);
        const id = index === 0 ? 'top' : (index === lastIndex ? 'bottom' : '');
        return (<li className="li-card" key={item.key} style={{top}} ref={refVal} id={id}>{item.value}</li>);
      })}
    </ul>
  );
}
export { SlidingWindowScrollHook };
複製程式碼

以及少許樣式:

.li-card {
  display: flex;
  justify-content: center;
  list-style: none;
  box-shadow: 2px 2px 9px 0px #bbb;
  padding: 70px 0;
  margin-bottom: 20px;
  border-radius: 10px;
  position: absolute;
  width: 80%;
}
複製程式碼

然後你就可以慢慢耍了。。。

90行程式碼,15個元素實現無限滾動

10. 相容性處理

IntersectionObserver不相容Safari?

莫慌,我們有polyfill

90行程式碼,15個元素實現無限滾動
每週34萬下載量呢,放心用吧臭弟弟們。
90行程式碼,15個元素實現無限滾動

專案源地址:github.com/roger-hiro/…

參考文章:

❤️ 看完三件事

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公眾號「前端勸退師」,不定期分享原創知識。
  3. 也看看其它文章

90行程式碼,15個元素實現無限滾動

相關文章