🍇

Reactで実装するデバウンスとスロットリング

2024/02/25に公開

はじめに

先輩エンジニアにデバウンスとスロットリングの説明を受けて、なるほどー!ってなったんですが、これってどうやって実装するんだ??となったので実装してみました。

デバウンス・スロットリングって何?

デバウンス・スロットリングとは、一定時間内に特定の関数が呼び出される回数を制限することで、パフォーマンスやUXを改善するための手法のことです。
例えば検索処理で、 検索フォームの入力値が更新されるたびにfetchするような処理では、一文字を入力するたびにfetch処理が実行され、ブラウザに負荷がかかったり、UX的にも好ましくないです。
頻度が多いイベントにおいて不要な処理を実行しないようにするときに、デバウンスやスロットリングの手法が有効になります。

デバウンス

デバウンスはイベントが発生するたびに関数を呼び出すのではなく、関数を呼び出して指定された時間 (1秒等) 待機します。そして待機時間内にイベントが再度発生しなければ関数を実行します。待機時間内にイベントが再度実行された場合、待機時間がリセットされもう一度指定された時間待機します。これを繰り返します。
連続のイベントが終わるまで関数の実行を待ってくれる優しいやつですね!

スロットリング

スロットリングはデバウンスのように関数の実行を遅延させますが、デバウンスとの違いは連続のイベントの発生をすべて待たないという点です。
関数を呼び出すタイミングは以下の3パターンです。

  • イベントの最初の1回
  • 指定された時間を経過した時
  • 最後の最新状態での1回

デバウンスは連続のイベント発生が全部終わるまで待ってくれるけど、スロットリングは指定時間過ぎたらとりあえず一回関数実行しとくわ!!というデバウンスに比べてちょっとせっかちなやつです。

Reactで実装してみよう

説明だけでは、何言ってんの🤔?????となると思いますので実装してみましょう。

まずは検索機能を実装しよう

デバウンスとスロットリングの実装をする前に、元ネタとなる入力イベントが発生するたびにfetchを実行するような以下の検索処理を実装します。

export const SearchPage = () => {
  const [query, setQuery] = useState<string>("");
  return (
    <div className={styles.container}>
      <h1>Search Books</h1>
      <input
        type="text"
        className={styles.input}
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
        }}
        placeholder="検索キーワードを入力"
      />
      <SearchResult query={query} />
    </div>
  );
};
type FetchResult = {
  items: {
    id: string;
    volumeInfo: {
      title: string;
    };
  }[];
};

type ItemType = {
  id: string;
  title: string;
};

const searchBooks = async (query: string): Promise<FetchResult> => {
  const endpoint = "https://www.googleapis.com/books/v1";
  const res = await fetch(`${endpoint}/volumes?q=${query}`);
  return await res.json();
};

export const SearchResult = ({ query }: { query: string }) => {
  const [items, setItems] = useState<ItemType[]>([]);

  // "query"が更新されるたびに実行する。
  useEffect(() => {
    if (query === "") return;
    (async () => {
      const data = await searchBooks(query);
      const newItem = data?.items.map((item) => {
        return {
          id: item.id,
          title: item.volumeInfo.title,
        };
      });
      setItems(newItem);
    })();
  }, [query]);

  return (
    <div className={styles.container}>
      {items?.map((item) => {
        return <p key={item.id}>{item.title}</p>;
      })}
    </div>
  );
};

動かしてみるとこんな感じです。
1文字入力されるたびにfetchされカクカクしていますね。

デバウンスを実装

それではデバウンスを実装してみましょう。
ポイントはこの二つです。

  • setTimeout()でデバウンスさせたい処理の待機時間を設定する。
  • useEffect()のクリーンアップ関数でデバウンスさせたい処理の待機時間をリセットする。
export const SearchResult = ({ query }: { query: string }) => {
  const [items, setItems] = useState<ItemType[]>([]);

  useEffect(() => {
    if (query === "") return;
        // 1秒後にfetch処理を実行する。
    const timeoutID = setTimeout(async () => {
      const data = await searchBooks(query);
      const newItems = data?.items.map((item) => {
        return {
          id: item.id,
          title: item.volumeInfo.title,
        };
      });
      setItems(newItems);
    }, 1000);
    // クリーンアップ関数を使用して入力があるたびにtimeoutIDのタイマーをリセットする。
    return () => {
      clearTimeout(timeoutID);
    };
  }, [query]);

  return (
    <div className={styles.container}>
      {items?.map((item) => {
        return <p key={item.id}>{item.title}</p>;
      })}
    </div>
  );
};

上記の処理では待機時間を1秒に設定しています。
入力の間隔が1秒以内で続く場合は、1文字入力されるたびにクリーアップ関数が実行され、待機時間をリセットします。そのため、入力が1秒以内でずっと続く場合はsetTimeout()で制御している処理はずーーっと実行されません。
そして1秒以上入力の間隔が空いた場合、実行されると言った感じです。
「ハリーポッター」と入力が完了してから、検索を行うことができていますね!

スロットリングを実装

次にスロットリングを実装してみましょう。
ポイントはこの二つです。

  • useRef()で関数を実行した時間を保持する。
  • 前回の実行時間と現在の時間を比較して指定した時間(以下であれば2秒)を経過している場合、関数を実行する。
export const SearchResult = ({ query }: { query: string }) => {
  const [items, setItems] = useState<ItemType[]>([]);
  const checkTime = useRef<dayjs.Dayjs>();

  useEffect(() => {
    if (query === "") return;
    const now = dayjs();
    // 前回の実行から2秒が経過している場合関数を実行する。
    if (!checkTime.current || now >= checkTime.current.add(2, "s")) {
      checkTime.current = now;
      (async () => {
        const data = await searchBooks(query);
        const newItems = data?.items.map((item) => {
          return {
            id: item.id,
            title: item.volumeInfo.title,
          };
        });
        setItems(newItems);
      })();
        // 最後の1回を実行するためのロジック
    } else {
      const timeoutID = setTimeout(async () => {
        const data = await searchBooks(query);
        const newItems = data?.items.map((item) => {
          return {
            id: item.id,
            title: item.volumeInfo.title,
          };
        });
        setItems(newItems);
      }, 1000);
      return () => {
        clearTimeout(timeoutID);
      };
    }
  }, [query]);

  return (
    <div className={styles.container}>
      {items?.map((item) => {
        return <p key={item.id}>{item.title}</p>;
      })}
    </div>
  );
};

関数の実行タイミングとしては、以下のようになっていますね!!

  • 入力イベントの初回である入力値に"h"を入力したとき
  • 初回のイベントから2秒が経過したとき
  • イベントが終了したとき

あとがき

実際に実装してみてイメージをつかむことができたのでよかったです。
デバウンスとスロットリングの違いについても理解できたので、適材適所で使い分けていきたい💪

参照

https://appmaster.io/ja/glossary/hurontoendonosurotsutoringutodebaunsu

Discussion