💬

8種類のチャットルームの無限スクロールの実装サンプル

2024/08/12に公開

はじめに

ライブラリを使わない実装 + 7種類のライブラリを使う実装で、計8種類の方法でチャットルームの無限スクロールを実装するサンプルを紹介します。
メッセージ機能のあるアプリなどの技術選定や実装の参考にしていただければと思います。

デモ

https://hotaritobu.github.io/message-infinity-scroll-samples/

ソース

https://github.com/HotariTobu/message-infinity-scroll-samples

環境

node v20.12.1
react 18.3.1
Chrome 127.0.6533

要件

チャットルームの要件は以下の通りです。

  • メッセージが垂直方向に並べられて表示される
    • 古いものほど上、新しいものほど下に表示される
    • 自分以外のものは左側、自分のものは右側に表示される
  • メッセージを入力するフィールドと送信ボタンがある
  • 一番上の近くまでスクロールすると古いメッセージが読み込まれる
    • 読み込まれたあとにスクロールの位置は変わらない
  • 一番下の近くまでスクロールしているとき、新しいメッセージが届くと一番下までスクロールする
  • ページが読み込まれた直後は一番下までスクロールされている

各サンプルの詳細

それぞれのコンポーネントは大まかに以下の要素に分けられます。

  • 子コンポーネントを配置する部分
  • スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分
  • メッセージが読み込まれたあとにスクロールの位置を調節する部分
  • 新しいメッセージが届いたときに一番下までスクロールする部分
  • ページの読み込み直後に一番下までスクロールする部分

これらに加えてライブラリ固有の要素があります。
ライブラリの順番は2024年8月4日時点での更新日時の新しい順です。

また、スタイルの指定にはTailwind CSSを使用しています。

useMessagesフック

useMessagesはメッセージの読み込みを疑似的に行うフックです。

const {
  lastLoadedMessages, // 最後に読み込まれたメッセージの配列
  messages, // 読み込まれたメッセージの配列
  isLoading, // メッセージを読み込んでいる最中は`true`、それ以外は`false`
  hasMore, // まだ読み込まれていないメッセージがある場合は`true`、それ以外は`false`
  loadMore, // さらにメッセージを読み込む関数
} = useMessages()

messages.concat(lastLoadedMessages)がすべての読み込まれたメッセージを表します。
メッセージは日時の降順で並べられ、messages[0]が最も新しいメッセージになります。

例えば1, 2, 3, ..., 10というようにメッセージがあり、messages = [1, 2, 3]lastLoadedMessages = [4, 5]という状態だったとします。
ここでloadMoreを呼ぶとmessages = [1, 2, 3, 4, 5]lastLoadedMessages = [6, 7]という状態になります。
さらに入力フィールドによって追加されたメッセージを0とすると、messages = [0, 1, 2, 3, 4, 5]lastLoadedMessages = [6, 7]という状態になります。

Normal

ライブラリを使わずに実装すると以下のようになると思います。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/normal.tsx#L91-L114

MessageCardは1つのメッセージを表示するコンポーネントです。

LoadingTriggerは一番上の近くまでスクロールしたときに表示されます。
isLoadingtrueのときにクルクルと回り、読み込み中であることを示します。
hasMorefalseの場合は表示されません。

FollowingTriggerは表示されませんが、一番下の近くまでスクロールしたと判定する領域を表します。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/normal.tsx#L20-L49

Intersection Observer APIを利用してスクロール位置を監視します。
onScroll等を使って実装するよりも効率が良いそうです。

LoadingTriggerの一部が表示された場合はメッセージを読み込みます。
FollowingTriggerの一部が表示された場合はref.current.nearBottomを更新し、一番下の近くまでスクロールしているかを保持します。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/normal.tsx#L52-L62

古いメッセージが読み込まれるとlastLoadedMessagesが更新されるので、その値の変化をトリガーにします。
lastLoadedMessagesのメッセージが表示されている領域の高さまでスクロールすることで、表示する領域をメッセージの読み込み前と同じにします。


新しいメッセージが届いたときに一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/normal.tsx#L65-L76

新しいメッセージはmessagesに追加されるので、それをトリガーにします。


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/normal.tsx#L79-L88


ReactVirtuoso

https://www.npmjs.com/package/react-virtuoso

大量のデータを仮想化して効率良く表示できるコンポーネントを追加するライブラリです。
内部でスクロール位置の監視をしてくれるため、自前で監視する必要がありません。
Virtuosoは下方向へのスクロールを想定していますが、アイテムの順序を反転させることによって上方向へのスクロールも実現できます。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L12-L15

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L77-L91

followOutputtrueにするとメッセージが追加されたときに下へスクロールしてくれますが、スムーズにスクロールしてくれないためfalseにしています。

atTopThresholdは一番上の近くまでスクロールしているか判定するための閾値で、LoadingTriggerの高さである64(px)を指定しています。
atBottomThresholdは同様に下の閾値で、FollowingTriggerの高さである128(px)を指定しています。

各メッセージの高さを正確に計測してもらうため、メッセージの周りの余白はMessageCard自身のpaddingによって確保しています。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L66-L74

NormalのIntersectionObserverのハンドラの処理と同じです。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L27-L34


新しいメッセージが届いたときに一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L37-L50

setTimeoutがないと正しくスクロールが開始されませんでした。


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtuoso.tsx#L53-L62


TanstackReactVirtual

https://www.npmjs.com/package/@tanstack/react-virtual

大量のデータを仮想化して効率良く表示する点ではReact Virtuosoと同じですが、コンポーネントではなくフックを追加するライブラリです。
より直観的に表示をカスタマイズすることができます。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/tanstack-react-virtual.tsx#L94-L132

逆順でメッセージを表示するため、-virtualItem.index - 1というようにメッセージのインデックスを指定してます。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

Normalと同じです。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/tanstack-react-virtual.tsx#L63-L65


新しいメッセージが届いたときに一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/tanstack-react-virtual.tsx#L68-L79

メッセージがすべてレンダリングされていない状態で正しくスクロールを行うため、scrollArea.scrollHeightではなくvirtualizer.getTotalSizeの値を使用します。


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/tanstack-react-virtual.tsx#L82-L91


ReactWindow

https://www.npmjs.com/package/react-window

大量のデータを仮想化して効率良く表示するコンポーネントを追加する点ではReact Virtuosoと同じですが、事前に大きさがわからない要素に対して自動的に位置を調節してくれません。
一応、要素の大きさの変化を通知できるので、自前で位置を調節することはできます。


VariableSizeListのコールバックを定義する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-window.tsx#L249-L274

計測したメッセージの高さをitemSizesに格納します。

handleItemsRenderedでは直接メッセージの高さの計測を行わず、再レンダリングのトリガーのみ行っています。
ここで直接メッセージの高さの計測を行うとなぜうまくいかないのかは忘れました。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-window.tsx#L40-L70

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-window.tsx#L277-L298

Innerはメッセージのコンテナを表します。
VariableSizeListに渡されるstyleheightによって高さが固定されるため、marginによって一番下の余白を確保すると同時に、FollowingTriggerを同じだけ下にずらします。

ItemではMessageCardの高さを計測するためにstyleからheightを抜いています。

react-windowのコンポーネントは動的なサイズにできないため、AutoSizerを利用しています。
ページ読み込み直後はAutoSizerが渡すwidthが正しくないため、disableWidthを指定しています。

estimatedItemSizeにはgetItemSizeが返す仮の値と同じものを渡さないとページ読み込み時のスクロール位置がずれます。


メッセージの実際の高さを計測し、コンポーネントに反映させる部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-window.tsx#L91-L154

表示される領域が変化したとき、itemSIzesの更新を行います。
handleItemsRenderedでこの処理を行うとうまくいかないのは、variableSizeListがレンダリングが終わるまでnullであるからだったかもしれません。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

Normalと同じです。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-window.tsx#L188-L212

ページ読み込み直後の一番下へのスクロールと競合するため、length === 0の場合はスクロールしないようにします。

メッセージの高さの計測が完了するまでvariableSizeList.scrollToItemは使えないため、先にlengthの個数分のメッセージの高さを自前で合算して一度スクロールします。


新しいメッセージが届いたときに一番下までスクロールする部分:

Normalと同じです。


ページの読み込み直後に一番下までスクロールする部分:

Normalと同じです。


ReactEasyInfiniteScrollHook

https://www.npmjs.com/package/react-easy-infinite-scroll-hook

無限スクロールを実装するために進行方向のみのスクロールを監視するフックを追加するライブラリです。
要素の仮想化は行ってくれませんが、react-windowreact-virtualizedとの併用を想定しているようです。


useInfiniteScrollを使う部分

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L12-L18

initialScrollを指定しないと、ページの読み込み直後の一番下へのスクロールの後に一番上までスクロールしてしまうため、Infinityを渡しています。

scrollThresholdにはLoadingTriggerの高さである"64px"を指定します。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L89-L111


スクロールが一番下に近いかを検知する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L27-L47

スクロールが一番上に近いかを自前で検知する必要はありません。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L50-L60

scrollAreainfiniteScrollRefから取得する以外はNormalと同じです。


新しいメッセージが届いたときに一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L63-L74


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-easy-infinite-scroll-hook.tsx#L77-L86


ReactInfiniteScrollHook

https://www.npmjs.com/package/react-infinite-scroll-hook

react-easy-infinite-scroll-hookと同じようなライブラリです。


コンテナのrefのコールバックを定義する部分

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-infinite-scroll-hook.tsx#L88-L94

react-easy-infinite-scroll-hookと違い、フック内部で管理されるスクロール要素のインスタンスにアクセスできないので、フックにスクロール要素を渡すと同時に自分のコンポーネントでもスクロール要素を保持しなければなりません。

また、rootRefはなぜか呼ぶと再レンダリングを引き起こすので、setRefにはuseCallbackを使います。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-infinite-scroll-hook.tsx#L97-L120


スクロールが一番下に近いかを検知する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-infinite-scroll-hook.tsx#L26-L46

スクロールが一番上に近いかを検知しない以外はNormalと同じです。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

Normalと同じです。


新しいメッセージが届いたときに一番下までスクロールする部分:

Normalと同じです。


ページの読み込み直後に一番下までスクロールする部分:

Normalと同じです。


ReactVirtualized

https://www.npmjs.com/package/react-virtualized

react-windowの作者がそれより前に作った、同じようなライブラリらしいです。


Listのコールバックを定義する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L208-L224

react-windowと同様ですが、react-virtualizedのコンポーネントには要素のkeyをカスタマイズするPropsがありません。

containerPropsrefを指定すると表示領域が更新されなくなりました。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L227-L289

レンダリングしたコンポーネントを独自にキャッシュしてしまうようなので、メッセージの高さの更新を反映させるため、defaultCellRangeRendererを呼ぶときにキャッシュを消しています。

また、クラスコンポーネントでの使用を想定しているようで、カスタムデータをcellRangeRendererrowRendererに送る方法がないので、react-windowのようにメッセージのコンポーネント等を分離することができません。

さらに、メッセージを子として持つ要素にアクセスする方法がないため、styleコンポーネントとReactVirtualized__Grid__innerScrollContainerを用いて直接スタイルを指定してします。


メッセージの実際の高さを計測し、コンポーネントに反映させる部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L32-L99

react-windowと同様ですが、itemsContainerheaderRefから取得しています。


初期化の完了を通知する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L101-L103

cellRangeRendererの中で指定しているheaderReffooterRefが、初回のuseEffectの呼び出しの後に設定されるので、initializedを使ってuseEffectの再呼び出しをトリガーします。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L105-L140


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L143-L178

list.scrollToIndexでは正しくスクロールされないため、list.getOffsetForRowでスクロール位置を取得してからscrollArea.scrollToでスクロールしています。


新しいメッセージが届いたときに一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L181-L192

listから一番下までのスクロールの位置を直接取得できないため、footer.scrollIntoViewでスクロールしています。


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-virtualized.tsx#L195-L204


ReactInfiniteScroller

https://www.npmjs.com/package/react-infinite-scroller

react-easy-infinite-scroll-hookのコンポーネント版のようなライブラリです。


子コンポーネントを配置する部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-infinite-scroller.tsx#L88-L118

initialLoadfalseにすると初回のメッセージの読み込み時に、ページの読み込み直後の一番下へのスクロールと競合するため、trueにしています。


スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:

react-infinite-scroll-hookと同じです。


メッセージが読み込まれたあとにスクロールの位置を調節する部分:

Normalと同じです。


新しいメッセージが届いたときに一番下までスクロールする部分:

Normalと同じです。


ページの読み込み直後に一番下までスクロールする部分:

https://github.com/HotariTobu/message-infinity-scroll-samples/blob/main/src/components/message-list/react-infinite-scroller.tsx#L70-L85

React Infinite Scrollerではスクロールイベント等を読み込みのトリガーにしているので、ページ読み込み時のメッセージが少なく、スクロールバーが表示されない場合は初回のメッセージの読み込みが起こりません。
そのような場合はスクロールイベントを自前で発生させてメッセージを読み込みます。


その他

おわりに

この記事を作成するにあたってGitHubのblobページのリンクを多くコピーする必要がありました。しかし、いちいちアドレスバーからコピーするのが面倒だったので、blobページのリンクを簡単にコピーするブラウザ拡張機能をついでに作りました。

https://github.com/HotariTobu/github-blob-link-copy

ちなみにページのプロトタイプはv0に作ってもらいました。

https://v0.dev/r/sC3MSejkBQA

Discussion