8種類のチャットルームの無限スクロールの実装サンプル
はじめに
ライブラリを使わない実装 + 7種類のライブラリを使う実装で、計8種類の方法でチャットルームの無限スクロールを実装するサンプルを紹介します。
メッセージ機能のあるアプリなどの技術選定や実装の参考にしていただければと思います。
デモ
ソース
環境
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
ライブラリを使わずに実装すると以下のようになると思います。
子コンポーネントを配置する部分:
MessageCardは1つのメッセージを表示するコンポーネントです。
LoadingTriggerは一番上の近くまでスクロールしたときに表示されます。
isLoadingがtrueのときにクルクルと回り、読み込み中であることを示します。
hasMoreがfalseの場合は表示されません。
FollowingTriggerは表示されませんが、一番下の近くまでスクロールしたと判定する領域を表します。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
Intersection Observer APIを利用してスクロール位置を監視します。
onScroll等を使って実装するよりも効率が良いそうです。
LoadingTriggerの一部が表示された場合はメッセージを読み込みます。
FollowingTriggerの一部が表示された場合はref.current.nearBottomを更新し、一番下の近くまでスクロールしているかを保持します。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
古いメッセージが読み込まれるとlastLoadedMessagesが更新されるので、その値の変化をトリガーにします。
lastLoadedMessagesのメッセージが表示されている領域の高さまでスクロールすることで、表示する領域をメッセージの読み込み前と同じにします。
新しいメッセージが届いたときに一番下までスクロールする部分:
新しいメッセージはmessagesに追加されるので、それをトリガーにします。
ページの読み込み直後に一番下までスクロールする部分:
ReactVirtuoso
大量のデータを仮想化して効率良く表示できるコンポーネントを追加するライブラリです。
内部でスクロール位置の監視をしてくれるため、自前で監視する必要がありません。
Virtuosoは下方向へのスクロールを想定していますが、アイテムの順序を反転させることによって上方向へのスクロールも実現できます。
子コンポーネントを配置する部分:
followOutputをtrueにするとメッセージが追加されたときに下へスクロールしてくれますが、スムーズにスクロールしてくれないためfalseにしています。
atTopThresholdは一番上の近くまでスクロールしているか判定するための閾値で、LoadingTriggerの高さである64(px)を指定しています。
atBottomThresholdは同様に下の閾値で、FollowingTriggerの高さである128(px)を指定しています。
各メッセージの高さを正確に計測してもらうため、メッセージの周りの余白はMessageCard自身のpaddingによって確保しています。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
NormalのIntersectionObserverのハンドラの処理と同じです。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
新しいメッセージが届いたときに一番下までスクロールする部分:
setTimeoutがないと正しくスクロールが開始されませんでした。
ページの読み込み直後に一番下までスクロールする部分:
TanstackReactVirtual
大量のデータを仮想化して効率良く表示する点ではReact Virtuosoと同じですが、コンポーネントではなくフックを追加するライブラリです。
より直観的に表示をカスタマイズすることができます。
子コンポーネントを配置する部分:
逆順でメッセージを表示するため、-virtualItem.index - 1というようにメッセージのインデックスを指定してます。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
Normalと同じです。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
新しいメッセージが届いたときに一番下までスクロールする部分:
メッセージがすべてレンダリングされていない状態で正しくスクロールを行うため、scrollArea.scrollHeightではなくvirtualizer.getTotalSizeの値を使用します。
ページの読み込み直後に一番下までスクロールする部分:
ReactWindow
大量のデータを仮想化して効率良く表示するコンポーネントを追加する点ではReact Virtuosoと同じですが、事前に大きさがわからない要素に対して自動的に位置を調節してくれません。
一応、要素の大きさの変化を通知できるので、自前で位置を調節することはできます。
VariableSizeListのコールバックを定義する部分:
計測したメッセージの高さをitemSizesに格納します。
handleItemsRenderedでは直接メッセージの高さの計測を行わず、再レンダリングのトリガーのみ行っています。
ここで直接メッセージの高さの計測を行うとなぜうまくいかないのかは忘れました。
子コンポーネントを配置する部分:
Innerはメッセージのコンテナを表します。
VariableSizeListに渡されるstyleのheightによって高さが固定されるため、marginによって一番下の余白を確保すると同時に、FollowingTriggerを同じだけ下にずらします。
ItemではMessageCardの高さを計測するためにstyleからheightを抜いています。
react-windowのコンポーネントは動的なサイズにできないため、AutoSizerを利用しています。
ページ読み込み直後はAutoSizerが渡すwidthが正しくないため、disableWidthを指定しています。
estimatedItemSizeにはgetItemSizeが返す仮の値と同じものを渡さないとページ読み込み時のスクロール位置がずれます。
メッセージの実際の高さを計測し、コンポーネントに反映させる部分:
表示される領域が変化したとき、itemSIzesの更新を行います。
handleItemsRenderedでこの処理を行うとうまくいかないのは、variableSizeListがレンダリングが終わるまでnullであるからだったかもしれません。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
Normalと同じです。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
ページ読み込み直後の一番下へのスクロールと競合するため、length === 0の場合はスクロールしないようにします。
メッセージの高さの計測が完了するまでvariableSizeList.scrollToItemは使えないため、先にlengthの個数分のメッセージの高さを自前で合算して一度スクロールします。
新しいメッセージが届いたときに一番下までスクロールする部分:
Normalと同じです。
ページの読み込み直後に一番下までスクロールする部分:
Normalと同じです。
ReactEasyInfiniteScrollHook
無限スクロールを実装するために進行方向のみのスクロールを監視するフックを追加するライブラリです。
要素の仮想化は行ってくれませんが、react-windowやreact-virtualizedとの併用を想定しているようです。
useInfiniteScrollを使う部分
initialScrollを指定しないと、ページの読み込み直後の一番下へのスクロールの後に一番上までスクロールしてしまうため、Infinityを渡しています。
scrollThresholdにはLoadingTriggerの高さである"64px"を指定します。
子コンポーネントを配置する部分:
スクロールが一番下に近いかを検知する部分:
スクロールが一番上に近いかを自前で検知する必要はありません。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
scrollAreaをinfiniteScrollRefから取得する以外はNormalと同じです。
新しいメッセージが届いたときに一番下までスクロールする部分:
ページの読み込み直後に一番下までスクロールする部分:
ReactInfiniteScrollHook
react-easy-infinite-scroll-hookと同じようなライブラリです。
コンテナのrefのコールバックを定義する部分
react-easy-infinite-scroll-hookと違い、フック内部で管理されるスクロール要素のインスタンスにアクセスできないので、フックにスクロール要素を渡すと同時に自分のコンポーネントでもスクロール要素を保持しなければなりません。
また、rootRefはなぜか呼ぶと再レンダリングを引き起こすので、setRefにはuseCallbackを使います。
子コンポーネントを配置する部分:
スクロールが一番下に近いかを検知する部分:
スクロールが一番上に近いかを検知しない以外はNormalと同じです。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
Normalと同じです。
新しいメッセージが届いたときに一番下までスクロールする部分:
Normalと同じです。
ページの読み込み直後に一番下までスクロールする部分:
Normalと同じです。
ReactVirtualized
react-windowの作者がそれより前に作った、同じようなライブラリらしいです。
Listのコールバックを定義する部分:
react-windowと同様ですが、react-virtualizedのコンポーネントには要素のkeyをカスタマイズするPropsがありません。
containerPropsのrefを指定すると表示領域が更新されなくなりました。
子コンポーネントを配置する部分:
レンダリングしたコンポーネントを独自にキャッシュしてしまうようなので、メッセージの高さの更新を反映させるため、defaultCellRangeRendererを呼ぶときにキャッシュを消しています。
また、クラスコンポーネントでの使用を想定しているようで、カスタムデータをcellRangeRendererやrowRendererに送る方法がないので、react-windowのようにメッセージのコンポーネント等を分離することができません。
さらに、メッセージを子として持つ要素にアクセスする方法がないため、styleコンポーネントとReactVirtualized__Grid__innerScrollContainerを用いて直接スタイルを指定してします。
メッセージの実際の高さを計測し、コンポーネントに反映させる部分:
react-windowと同様ですが、itemsContainerをheaderRefから取得しています。
初期化の完了を通知する部分:
cellRangeRendererの中で指定しているheaderRefとfooterRefが、初回のuseEffectの呼び出しの後に設定されるので、initializedを使ってuseEffectの再呼び出しをトリガーします。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
list.scrollToIndexでは正しくスクロールされないため、list.getOffsetForRowでスクロール位置を取得してからscrollArea.scrollToでスクロールしています。
新しいメッセージが届いたときに一番下までスクロールする部分:
listから一番下までのスクロールの位置を直接取得できないため、footer.scrollIntoViewでスクロールしています。
ページの読み込み直後に一番下までスクロールする部分:
ReactInfiniteScroller
react-easy-infinite-scroll-hookのコンポーネント版のようなライブラリです。
子コンポーネントを配置する部分:
initialLoadをfalseにすると初回のメッセージの読み込み時に、ページの読み込み直後の一番下へのスクロールと競合するため、trueにしています。
スクロールが一番上または下に近いかを検知し、メッセージの読み込み等を行う部分:
react-infinite-scroll-hookと同じです。
メッセージが読み込まれたあとにスクロールの位置を調節する部分:
Normalと同じです。
新しいメッセージが届いたときに一番下までスクロールする部分:
Normalと同じです。
ページの読み込み直後に一番下までスクロールする部分:
React Infinite Scrollerではスクロールイベント等を読み込みのトリガーにしているので、ページ読み込み時のメッセージが少なく、スクロールバーが表示されない場合は初回のメッセージの読み込みが起こりません。
そのような場合はスクロールイベントを自前で発生させてメッセージを読み込みます。
その他
- react-list: スクロールしても内容が更新されなかったため断念
-
react-infinite-scroll-component:
inverseをtrueにするとloaderが表示されなかったため断念
おわりに
この記事を作成するにあたってGitHubのblobページのリンクを多くコピーする必要がありました。しかし、いちいちアドレスバーからコピーするのが面倒だったので、blobページのリンクを簡単にコピーするブラウザ拡張機能をついでに作りました。
ちなみにページのプロトタイプはv0に作ってもらいました。
Discussion