動機
FirestoreのSnapshotListenerを使えばリアルタイムチャットのような機能を比較的容易に作成することができます。しかし一方で、特別な制御を行わなければメッセージを一度に購読してしまい、場合によってはデータ量が非常に大きくなってしまいます。これを避けるために、無限スクロールをSnapshotListenerを使いつつ実現する方法に挑戦しました。
制作したもの
ReactとFirestoreを使って無限スクロールが行える簡易Chatアプリを作ってみました
使用技術
期待動作
Chatアプリでは次の動作を満たすものとします。
また、今回の前提として購読中メッセージは物理的に削除されないこととします。
- 初回ロード時に直近一定数のChatメッセージを表示する
- 新しいメッセージが追加されたら、リアルタイムにChatウィンドウに追加する
- 上向きにスクロールすることで過去のメッセージを一定数ずつ追加する
- メッセージが編集された場合、リアルタイムに表示を更新する
実現方法
この機能を実現するために、Firestoreの制御とスクロールの制御に分けて解説します
Firestoreの制御
メッセージを購読するためにSnapshotListenerを登録します。無限スクロールにおけるSnapshotListenerは大きく、未来/過去の2種類の購読方法によって実現しています。
このような制御を分ける理由としては、購読するメッセージ数が増加する可能性のある「未来」のメッセージに対してlimit
関数を適用しないためです。この理由については以下の記事を参照ください。
1. 未来(最新メッセージ)の購読リスナー
新しく投稿されたメッセージを受け取るためのリスナーを登録します。startAfter
を用いて、現在時刻以降の全ての新規メッセージを購読しています。
const db = firebase.firestore();
const now = Date.now();
...
// 未来(最新メッセージ)のSnapshotListener登録
db.collection('messages')
.orderBy('date','asc') // field 'date'は数値とする
.startAfter(now)
.onSnapshot((snapshot)=>{
...// データ取得
})
2.過去メッセージの購読リスナー
初期表示及び、スクロール時に追加読み込みするためのリスナーを登録します。orderBy
とstartAfter
を用いて、現時刻より前のメッセージを新しい順に購読しています。また、limit
を使ってスクロール時に購読するメッセージ数を制御しています。
//過去メッセージの購読リスナー
const registPastMessageListener = useCallback((startAfter:number){
return db.collection('messages')
.orderBy('date','desc') // 日付の新しいデータから取得する
.startAfter(startAfter)
.limit(limit)
.onSnapshot((snapshot)=>{
...// データ取得
})
},[])
3.全体のコードイメージ
Firestoreの制御全体のコードの流れは概ね以下のようになります。useInfiniteSnapshotListener
というカスタムフックをexport
し、これをスクロールするコンポーネント側から呼び出します。
const db = firebase.firestore();
const now = Date.now();
type Unsubscribe = () => void
type Message = {id:string, ...略}
...
function useInfiniteSnapshotListener(){
const unsubscribes = useRef<Unsubscribe[]>([])
const [messages, setMessages] = useState<Message[]>([])
...
// 未来(最新メッセージ)の購読リスナー
const registLatestMessageListener = useCallback(()=>{
return db.collection('messages')
.orderBy('date','asc') // field 'date'は数値とする
.startAfter(now)
.onSnapshot((snapshot)=>{
...// setMessagesを呼び出す(後述)
})
},[])
//過去メッセージの購読リスナー
const registPastMessageListener = useCallback((startAfter:number)=>{
return db.collection('messages')
.orderBy('date','desc') // 日付の新しいデータから取得する
.startAfter(startAfter)
.limit(limit)
.onSnapshot((snapshot)=>{
...// setMessagesを呼び出す(後述)
})
},[])
// 初回ロード
const initRead = useCallback(()=>{
// 未来のメッセージを購読する
unsubscribes.current.push(registLatestMessageListener())
// 現時刻よりも古いデータを一定数、購読する
unsubscribes.current.push(registPastMessageListener(now))
},[registPastMessageListener])
// スクロール時、追加購読するためのリスナー
const lastMessageDate = messages[messages.length-1].date
const readMore = useCallback(()=>{
unsubscribes.current.push(registPastMessageListener(lastMessageDate))
},[registPastMessageListener,lastMessageDate])
// 登録解除(Unmount時に解除)
const clear = useCallback(()=>{
for (const unsubscribe of unsubscribes.current) {
unsubscribe()
}
},[])
useEffect(() => {
return () => {
clear();
};
}, [clear])
return {
initRead,
readMore,
messages
}
}
また、onSnapshot
内は次のような処理が含まれます。
function onSnapshot (snapshot) {
let added: Message[] = [];
let modified: Message[] = [];
let deleted: Message[] = [];
for (let change of snapshot.docChanges()) {
const data = change.doc.data() as Message;
const target = {
id: change.doc.id,
...data
};
if (change.type === 'added') {
added.push(target)
}
else if (change.type === 'modified') {
modified.push(target)
}
else if (change.type === 'removed') {
deleted.push(target)
}
}
if (added.length > 0) {
// 追加時
setMessages(prev=>[...prev,added])
}
if (modified.length > 0) {
// 変更時
setMessages(prev => {
return prev.map(mes => {
const found = modified.find(m => m.id === mes.id);
if (found) {
return found;
}
return mes;
});
})
}
if (deleted.length > 0) {
// 削除する(今回この操作は扱わない)
}
}
スクロール制御
スクロールによって最後のメッセージまでたどり着いた後、まだ読み込みが可能なメッセージがfirestoreに残されている場合、上記で定義したreadMore
を呼び出す制御を行います
1. 読み込みが可能なメッセージが残されているかどうかの判断
最も古いメッセージを番兵として持っておき、このメッセージが読み込まれたかどうかで判断します。
const [sentinel, setSentinel] = useState<Message>()
useEffect(()=>{
db.collection('messages')
.orderBy('date','asc') // 最も古い日付のデータ
.limit(1)
.get()
.then((querySnapshot)=>{
// setSentinelを呼び出す
})
},[])
const hasMore = sentinel ? !Boolean( messages.find(m => m.id === sentinel.id)) : false
2. スクロール上端検知と追加購読
スクロール領域の上端や下端を検知した時に、追加読み込みするためのライブラリはすでに公開されているものが多く、react-infinite-scrollerのように多様な使い方ができるものもあります(一般的にInfinite Scrollと呼ばれるもの)。
今回のChatアプリでは自作したものを用いていますが、SimpleInfiniteScroller
を上記のようなライブラリに置き換えることも可能です。
SimpleInfiniteScroller
についてはここでの説明は割愛しますが、こちらにコードを公開しています。
return ( // 上スクロール時にreadMoreが呼び出される
<SimpleInfiniteScroller
canScrollUp={hasMore}
loadMore={readMore}
reverse
>
<ul style={{ overflowY : 'auto', height : '70vh'}}>
{messages.map(m => (
<li key={m.id}>{...}</li>
))}
</ul>
</SimpleInfiniteScroller>
3.全体のコードイメージ
スクロール制御全体のコードの流れは概ね以下のようになります。
function Component(){
const [node,setNode] = useState<HTMLElement>()
const [sentinel, setSentinel] = useState<Message>()
const { messages, readMore, initRead } = useInfiniteSnapshotListener()
// 番兵の読み込み
useEffect(()=>{
db.collection('messages')
.orderBy('date','asc') // 最も古い日付のデータ
.limit(1)
.get()
.then((querySnapshot)=>{
// setSentinelを呼び出す
})
},[])
// 初回読み込み
useEffect(()=>{
initRead();
},[initRead])
const hasMore = sentinel ? !Boolean( messages.find(m => m.id === sentinel.id)) : false
return (// 上スクロール時にreadMoreが呼び出される
<SimpleInfiniteScroller
canScrollUp={hasMore}
loadMore={readMore}
reverse
>
<ul style={{ overflowY : 'auto', height : '70vh'}}>
{messages.map(m => (
<li key={m.id}>{...}</li>
))}
</ul>
</SimpleInfiniteScroller>
)
}
注意事項
メッセージ削除について
上にも記載していますが、今回内容はメッセージを物理削除しないことを前提としています。これは物理削除によってlimit
で購読するメッセージの入れ替えをもたらさないためです。そのため、削除についてはフラグを設けるなどの論理削除とする必要があります。
最後に
最後まで読んでいただきありがとうございます。
SnapshotListenerを使った無限スクロールについて、今回は全体の流れ中心に記載しましたが、実際には使い勝手を向上させるために新着メッセージの自動スクロールダウンなどの詳細制御等も必要になってくるかと思います。