Firestoreでリアルタイムな無限スクロールを実現する

# 動機
FirestoreのSnapshotListenerを使えばリアルタイムチャットのような機能を比較的容易に作成することができます。しかし一方で、特別な制御を行わなければメッセージを一度に購読してしまい、場合によってはデータ量が非常に大きくなってしまいます。これを避けるために、無限スクロールをSnapshotListenerを使いつつ実現する方法に挑戦しました。

# 制作したもの
ReactとFirestoreを使って無限スクロールが行える[簡易Chatアプリ](https://chatapp-f73df.firebaseapp.com/)を作ってみました

![ChatappCapture.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200228/4420137c-0667-f59a-75a0-7173adf7d352.gif)
# 使用技術
 - React : 16.8.0以降の[Hooks](https://ja.reactjs.org/docs/hooks-intro.html)がサポートされているもの
 - Firestore : SnapshotListenerについては[公式](https://firebase.google.com/docs/firestore/query-data/listen?hl=ja)を参照ください

# 期待動作
Chatアプリでは次の動作を満たすものとします。
また、今回の前提として購読中メッセージは物理的に削除されないこととします。

1. 初回ロード時に直近一定数のChatメッセージを表示する
- 新しいメッセージが追加されたら、リアルタイムにChatウィンドウに追加する
- 上向きにスクロールすることで過去のメッセージを一定数ずつ追加する
- メッセージが編集された場合、リアルタイムに表示を更新する


# 実現方法
この機能を実現するために、Firestoreの制御とスクロールの制御に分けて解説します
## Firestoreの制御
メッセージを購読するためにSnapshotListenerを登録します。無限スクロールにおけるSnapshotListenerは大きく、未来/過去の２種類の購読方法によって実現しています。
このような制御を分ける理由としては、購読するメッセージ数が増加する可能性のある「未来」のメッセージに対して`limit`関数を適用しないためです。この理由については以下の記事を参照ください。
> [firestoreのonSnapshotを使う際に気を付けたいこと](https://qiita.com/mktu/items/17a993f675ccd6d17aed)

![çhatapp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200228/db6f5f90-1189-fac9-0c4e-e05ebaa38437.png)

### 1. 未来（最新メッセージ）の購読リスナー
新しく投稿されたメッセージを受け取るためのリスナーを登録します。`startAfter`を用いて、現在時刻以降の全ての新規メッセージを購読しています。

```tsx
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`を使ってスクロール時に購読するメッセージ数を制御しています。

```tsx
//過去メッセージの購読リスナー
const registPastMessageListener = useCallback((startAfter:number){
   return db.collection('messages')
     .orderBy('date','desc') // 日付の新しいデータから取得する
     .startAfter(startAfter)
     .limit(limit)
     .onSnapshot((snapshot)=>{
      ...// データ取得
     })
},[])
```
### 3.全体のコードイメージ
Firestoreの制御全体のコードの流れは概ね以下のようになります。`useInfiniteSnapshotListener `というカスタムフックを`export`し、これをスクロールするコンポーネント側から呼び出します。

```tsx
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`内は次のような処理が含まれます。

```tsx
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. 読み込みが可能なメッセージが残されているかどうかの判断
最も古いメッセージを番兵として持っておき、このメッセージが読み込まれたかどうかで判断します。

```tsx

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](https://www.npmjs.com/package/react-infinite-scroller)のように多様な使い方ができるものもあります（一般的にInfinite Scrollと呼ばれるもの）。
今回のChatアプリでは自作したものを用いていますが、`SimpleInfiniteScroller`を上記のようなライブラリに置き換えることも可能です。
`SimpleInfiniteScroller`についてはここでの説明は割愛しますが、[こちら](https://github.com/mktu/react-simple-infinite-scroller)にコードを公開しています。

```tsx

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.全体のコードイメージ
スクロール制御全体のコードの流れは概ね以下のようになります。

```tsx
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を使った無限スクロールについて、今回は全体の流れ中心に記載しましたが、実際には使い勝手を向上させるために新着メッセージの自動スクロールダウンなどの詳細制御等も必要になってくるかと思います。