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