🙆

面談記録管理アプリ開発:チャットのスクロールの実装

2025/01/16に公開

はじめに

前回はチャット機能の解説を行いました!

前回の記事はこちら↓
https://zenn.dev/kenberu1200/articles/c5b34d0748d214

前回解説したところまでだと、チャットをスクロールして遡ることができないので、今回はチャットのログを遡る機能を実装していきます。

コントローラーの修正

まず、MessageControllerloadOlderメソッドを実装していきます。

app/Http/Controllers/MessageController.php
public function loadOlder(Message $message)
{
    $messages = Message::where('created_at', '<', $message->created_at)
        ->where('meeting_logs_id', '=', $message->meeting_logs_id)
        ->latest()
        ->paginate(10);

    return MessageResource::collection($messages);
}

まず、フロントエンドから表示しているメッセージの中で一番古いメッセージを受け取ります。

次に、そのメッセージ$messageの作成日時created_atを参照して、その日時よりも過去のメッセージでかつ表示している面談記録のメッセージあるものを降順にして10件のページネーションとして取得しています。

そして、MessageResourceに沿ったデータの形にしてフロントエンドに返します。

ルーティング

続いて、ルーティングの設定をしていきます。

routes/web.php
Route::get('/message/older/{message}', [MessageController::class, 'loadOlder'])
    ->name('message.loadOlder');

フロントエンドの実装

そして、フロントエンド側を実装していきます。

該当ソースコード全文
resources/js/Pages/MeetingLog/Show.jsx
export default function Show({ auth, meetingLog, messages }) {
    const [noMoreMessages, setNoMoreMessages] = useState(false);
    const [scrollFromBottom, setScrollFromBottom] = useState(0);
    const loadMoreIntersect = useRef(null);
    const messagesCtrRef = useRef(null);
    //// 省略 ////
    const loadMoreMessages = useCallback(() => {
        if (noMoreMessages) {
            return;
        }

        const firstMessage = localMessages[0];
        axios
        .get(route("message.loadOlder", firstMessage.id))
        .then(({data}) => {
            if(data.data.length === 0) {
                console.log("No more Messages");
                setNoMoreMessages(true);
                return;
            }
            const scrollHeight = messagesCtrRef.current.scrollHeight;
            const scrollTop = messagesCtrRef.current.scrollTop;
            const clientHeight = messagesCtrRef.current.clientHeight;
            const tmpScrollFromBottom = scrollHeight - scrollTop - clientHeight;
            setScrollFromBottom(tmpScrollFromBottom);

            setLocalMessages((prevMessages) => {
                return [...data.data.reverse(), ...prevMessages];
            });
        })
    }, [localMessages]);

    useEffect(() => {
        if(messagesCtrRef.current && scrollFromBottom !== null) {
            messagesCtrRef.current.scrollTop =
            messagesCtrRef.current.scrollHeight -
            messagesCtrRef.current.offsetHeight -
            scrollFromBottom;
        }

        if (noMoreMessages) {
            return
        }

        const observer = new IntersectionObserver(
            (entries) =>
            entries.forEach(
                (entry) => entry.isIntersecting && loadMoreMessages()
            ),
            {
                rootMargin: "0px 0px 250px 0px",
            }
        );

        if(loadMoreIntersect.current) {
            setTimeout(() => {
            observer.observe(loadMoreIntersect.current);
            }, 100);
        }

        return () => {
            observer.disconnect();
        }
    },[localMessages]);
}

以下部分ごとに説明していきます。

各Stateとrefの宣言

const [noMoreMessages, setNoMoreMessages] = useState(false);
const [scrollFromBottom, setScrollFromBottom] = useState(0);
const loadMoreIntersect = useRef(null);
const messagesCtrRef = useRef(null);

Staterefの説明は以下の通りです。

変数名 説明
noMoreMessages 読み込むメッセージの有無を判別するためのフラグ
scrollFromBottom 現在のスクロール位置がメッセージリストの底からどのくらい離れているかを記録するState
loadMoreIntersect スクロールが該当位置に達したかどうかを監視るうためのrefオブジェクト
messagesCtrRef メッセージリスト全体をラップするDOM要素を参照して、スクロール位置を制御するためのrefオブジェクト

loadMoreIntersectmessageCtrRefは以下の箇所に配置されています。
loadMoreIntersectは、メッセージが存在していた場合のDOM要素を参照しており、messageCtrRefはメッセージリスト全体をラップするdivタグでDOM要素を参照している。

<div
    {/* messagesCtrRef */}
    ref={messagesCtrRef}
    className="flex-1 overflow-y-auto p-5 max-h-[400px]"
>
    {/* {messages} */}
    {localMessages.length === 0 && (
        <div className="flex justify-center items-center h-full">
            <div className="text-lg text-gray-500">
                メッセージがありません
            </div>
        </div>
    )}
    {localMessages.length > 0 && (
        <div className="flex-1 flex flex-col">
            {/* loadMoreIntersect */}
            <div ref={loadMoreIntersect}></div>
            {localMessages.map((message) => (
                <MessageItem
                key={message.id}
                message={message}
                />
            ))}
        </div>
    )}
</div>

loadMoreMessages

まず、loadMoreMessagesについて解説します。
loadMoreMessagesは、過去のメッセージをバックエンド側から取得してくる関数です。

const loadMoreMessages = useCallback(() => {

}, [localMessages]);

useCallbackは、メモ化されたコールバック関数を作成するためのReactフックの一つです。
今回の場合は、localMessagesが変更された場合、定義された関数が再生成されます。
変更されていない場合は、キャッシュされた関数が返ってきます。
https://ja.react.dev/reference/react/useCallback

これは、無駄な再生成を省くことで、パフォーマンスの改善が見込めます。

useCallbackの処理内容は以下で説明します。

まず、読み込むメッセージが存在するかどうかを判別するStatenoMoreMessagestrueだった場合、処理を終了します。

if (noMoreMessages) {
    return;
}

次に、現在読み込んでいるメッセージリストの先頭のメッセージ、すなわち最も古いメッセージをfirstMessageに代入します。

const firstMessage = localMessages[0];

そして、axiosライブラリを用いて、バックエンドに対してgetリクエストをfirstMessageとともに送信します。

 axios.get(route("message.loadOlder", firstMessage.id))

getリクエストのレスポンスを基に、実行したい処理をthenの中で記述していきます。
レスポンスデータは引数であるdataオブジェクトに入ります。

.then(({data}) => {

})

以下、thenの中の処理を解説していきます。

まず、dataの中身がない場合、すなわちlength0の場合、setNoMoreMessagesを使ってnoMoreMessagesステートの値をtrueにして処理を終了します。

if(data.data.length === 0) {
    console.log("No more Messages");
    setNoMoreMessages(true);
    return;
}

次に、メッセージリストの底からスクロール現在地までの距離を求めて、scrollFromButtomステートに値を記録しておきます。

つまり、リスト全体からどのくらいの高さまでスクロールされているのかを保存しています。

const scrollHeight = messagesCtrRef.current.scrollHeight;
const scrollTop = messagesCtrRef.current.scrollTop;
const clientHeight = messagesCtrRef.current.clientHeight;
const tmpScrollFromBottom = scrollHeight - scrollTop - clientHeight;
setScrollFromBottom(tmpScrollFromBottom);

上記の計算式がよくわからなかったので調べてみました。
https://qiita.com/hoto17296/items/be4c1362647dd241905d

https://ja.javascript.info/size-and-scroll

二つの記事を参考にして、なんとなく理解できたと思います。
特に、二つ目のサイトの図が参考になりました!

そして、setLocalMessagesを用いて、レスポンスとして帰ってきたデータ、すなわちバックエンドから取得した過去10件分のメッセージリストをlocalMessagesに追加します。

setLocalMessages((prevMessages) => {
    return [...data.data.reverse(), ...prevMessages];
});

useEffect

続いて、該当するuseEffectについて解説します。
useEffectとは、ターゲットとなっている変数が更新された場合、再レンダリングとともに内部に記述された処理を実行するフックです。

今回は、localMessagesの値が更新されたとき、すなわちかチャット送受信時や過去のメッセージの読み込み時に、実行されるフックになります。

 useEffect(() => {

},[localMessages]);

さらに、メッセージリスト全体をラップするDOMが存在し、かつスクロールの位置が設定されている場合、スクロールの位置を調整します。

後述しますが、スクロールバーがチャット画面の上端付近に到達したかどうかを判定して、過去メッセージ取得のリクエストを飛ばしています。

そのため、スクロール位置を制御する処理を行わなければ、スクロールバーが常態にいる状態が維持され、読み込むメッセージがなくなるまで無制限に読み込み続けてしまいます。

if(messagesCtrRef.current && scrollFromBottom !== null) {
    messagesCtrRef.current.scrollTop =
    messagesCtrRef.current.scrollHeight -
    messagesCtrRef.current.offsetHeight -
    scrollFromBottom;
}

また、読み込むメッセージの有無を判定するnoMoreMessagestrueだった場合、すなわち読み込むメッセージがなくなった場合、途中で処理を終了します。

if (noMoreMessages) {
    return
}

続いて、Intersection Observer APIのインターフェースであるIntersectionObserverをインスタンス化してobserverに代入しています。

これは、スクロール位置を監視して、rootMarginで設定した位置にスクロールされたときに、loadMoreMessages()を実行します。
https://developer.mozilla.org/ja/docs/Web/API/IntersectionObserver

今回は、メッセージ表示部分の上端から下に250pxの位置にスクロールされたときに処理が実行されます。

const observer = new IntersectionObserver(
    (entries) =>
    entries.forEach(
        (entry) => entry.isIntersecting && loadMoreMessages()
    ),
    {
        rootMargin: "0px 0px 250px 0px",
    }
);

そして、observer.observe()を用いて、loadMoreIntersect.currentを監視します。

if(loadMoreIntersect.current) {
    setTimeout(() => {
        observer.observe(loadMoreIntersect.current);
    }, 100);
}

最後に、該当ページを離れるときにdisconnect()を用いて監視を終了します。

return () => {
    observer.disconnect();
}

以上で、無限スクロールができ、過去のメッセージ履歴を逐次閲覧する仕組みができました!

おわりに

今回は、チャットのスクロール機能について解説しました。

作成したアプリにはほかにも機能を付けていますが、後は基本的なCRUD処理を行っているだけなので、GitHubのソースコードを参照いただければと思います。

また、要望いただくなど、執筆するモチベーションになる出来事があれば、追加で書いていきたいと思います。

次回は、作成したアプリをデプロイした方法を解説した記事を書いていきます!

ではでは!

Discussion