Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

遅延読み込みリストをカンタンに作りたい

React で遅延読み込み機構を作ってみました。

デモ

github.com

コード

useShownuseUpdateHeight という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]
  );
};