React で遅延読み込み機構を作ってみました。
デモ
コード
useShown
と useUpdateHeight
という2つのフックを使って実現します。useShown はスクロール状況から「読み込みを開始すべきか」を判定します。useUpdateHeight はコンポーネントの高さをストアへ伝えます。
import { FC, useEffect, useRef, useState, createContext, Children, useCallback, } from 'react'; const HeightContext = createContext< [number[], (index: number, height: number) => void] >([[], () => null]); const IndexContext = createContext(0); /** * height store をつなぐ処理と、index を各要素に与えるためのラッパー */ const LazyLoadWrapper: FC = ({ children }) => { const [height, setHeight] = useState<number[]>([]); const updateHeight = useCallback((index: number, height: number) => { setHeight((arr) => { const newValue = arr.concat(); newValue[index] = height; return newValue; }); }, []); return ( <HeightContext.Provider value={[height, updateHeight]}> {Children.map(children, (child, index) => ( <IndexContext.Provider value={index} key={index}> {child} </IndexContext.Provider> ))} </HeightContext.Provider> ); }; /** * 遅延読み込みされるコンポーネント */ const Child = () => { const divRef = useRef<HTMLDivElement>(null); const shown = useShown(); const updateHeight = useUpdateHeight(); const height = useRef((Math.floor(Math.random() * 9) + 1) * 100).current; useEffect(() => { if (divRef.current && shown) { updateHeight(); } }, [shown, updateHeight]); if (!shown) { return null; } return <div style={{ width: 100, height }} ref={divRef}></div>; }; export const App= () => { return ( <LazyLoadWrapper> <Child /> <Child /> <Child /> <Child /> <Child /> </LazyLoadWrapper> ); };
各コンポーネントで高さを伝達し、LazyLoadWrapper で囲むだけで遅延読み込みが実現できます。
useShown と useUpdateHeight の実装
結構シンプルです。useShown は「まだマウントしてない要素のうち一番上にあって、かつスクロール量が閾値を超えているか」を判定しているだけです。閾値は適当に「画面の高さ * 3」だけの余裕が取れるようにしています。
useUpdateHeight はさらにシンプルで、HeightContext の配列を更新するだけです。
import { useEffect, useState, useCallback, useContext } from 'react'; /** * 「表示するべきか否か」を返すフック。一度 shown が true になったら false になることはない。 * height store の更新はしない。 */ const useLazyload = () => { const index = useContext(IndexContext); const [height] = useContext(HeightContext); const [shown, setShown] = useState(false); useEffect(() => { if (shown) { return; } const nextIndexToMount = (() => { const indexOfFirstUndefined = height.findIndex( (item) => typeof item === 'undefined' ); if (indexOfFirstUndefined !== -1) { return indexOfFirstUndefined; } return height.length; })(); // 先頭から連続したマウント済み要素の高さの総和 const totalHeight = height .slice(0, nextIndexToMount) .reduce((a, b) => a + b, 0); const listener = () => { if ( nextIndexToMount === index && totalHeight < 3 * window.innerHeight + window.scrollY ) { setShown(true); } }; window.addEventListener('scroll', listener); return () => { window.removeEventListener('scroll', listener); }; }, [height, index, shown]); return shown; }; /** * コンポーネントの高さをストアへ伝えるためのフック。 */ const useUpdateHeight = () => { const index = useContext(IndexContext); const [, setHeight] = useContext(HeightContext); return useCallback( (height: number) => setHeight(index, height), [index, setHeight] ); };