「引用元にジャンプしたい」から始まった、チャットのリスト描画の設計見直し

こんにちは。YOUTRUSTでWebエンジニアをしている井上(YOUTRUST)です。

朝晩は涼しくなって、窓を開けると秋らしい風が入ってくるようになりましたね。過ごしやすくなったので、積み残していたタスクにもそろそろ向き合いたいところです。

今回は、そんな「いつかやりたい」のひとつだった、チャットルームの引用返信機能について紹介します。近日リリース予定のこの機能ですが、「引用元にジャンプする」という操作を実現するために、チャットのリスト描画方式から見直すことになりました。

以前の記事では、チャットルームに複数ファイル送信やオンラインステータスなどを追加し、「当たり前の体験」をひとつずつ作ってきた過程を紹介しました。

過去のメッセージを選んで返信し、引用部分を押すと元のメッセージまで移動する。引用返信も、普段使っているチャットツールではおなじみの機能ですね。

このおなじみの操作を実現するうえで、特に考えたのがリスト描画とデータ取得の設計でした。

引用元が、画面に存在しない

引用返信には、大きく分けて3つの操作があります。

  • メッセージを引用元として選ぶ
  • 引用元付きのメッセージを送る
  • 引用部分を押して、元のメッセージへ移動する

今回、特に設計を考えたのが最後の操作です。

画面内にあるメッセージへ移動するだけなら、対象のDOM要素までスクロールする方法が使えます。でも、引用元が数千件前のメッセージだったらどうでしょうか。

それまでのチャットは、過去へスクロールするにつれてメッセージを追加取得する構成でした。遠く離れた引用元は、DOMがないどころか、メッセージ本体もまだ取得していません。

順番に過去のメッセージを読み込めば、いつかはたどり着けます。ただ、引用元までの距離によって待ち時間や取得量が増えてしまいます。

まだ取得していないメッセージにも移動できて、その前後の会話も続けて読める。

この体験をどう作るかが、今回の出発点でした。

リスト描画の4案を比較する

検討の前提として、既存の仕組みではチャット内の全行のIDを最初に取得できていました。ID一覧は揃っていても、各行の本体をすべて取得しているわけではありません。

つまり、「どの順番に並んでいるか」はわかっています。この情報を使って、次の4案を比較しました。

方式 仕組み 気になった点
JSによる仮想化 表示範囲とその周辺の行だけDOMを作る 描画構造の変更とライブラリの導入が必要
CSSのcontent-visibility DOMを保持し、画面外の描画処理を省く 当時の対象ブラウザとの互換性、全件分のDOM保持
引用元まで追加取得する 既存のページネーションで引用元まで読み込む 遠くへ飛ぶほど取得量が増える
引用元周辺のリストに差し替える 移動先の前後だけを取得して表示する 過去の閲覧中と最新の表示で状態管理が複雑になる

引用元周辺のリストに差し替える方法も、有力な候補でした。必要な範囲だけ取得するので、遠いメッセージへも移動できます。

ただ、過去の会話を表示している間にも、新着メッセージは届きます。そこから最新へ戻る場合や、その場で返信を送る場合も考える必要があります。

表示範囲を切り替えるだけでは終わらず、過去と最新の状態をどうつなぐか、自分たちで管理する部分が増えていきます。

比較の結果、今回はreact-virtuosoを使った仮想化を選びました。

決め手は、スクロール位置の計算や表示範囲の管理をライブラリに任せながら、自分たちは行データの組み立てと取得処理に集中できることでした。履歴を1本のリストとして扱える点も、今回作りたい体験に合っていました。

全IDを骨格にして、本文は必要な範囲だけ取得する

仮想化したリストでは、全行のIDからリストの骨格を作ります。

本体を取得済みならメッセージなどの内容を表示し、未取得ならスケルトンを表示します。

ここで、DOMを作る範囲と、メッセージ本体を取得する範囲は別に制御します。

仮想化は描画するDOMの量を抑える仕組みです。それだけでは、メッセージ本体の全件取得を避けられるわけではありません。

今回は表示範囲の変化を取得処理につなぎ、必要な本体だけを遅延取得する構成にしました。スクロール中にリクエストが細かく発生しないよう、表示範囲の変化を少し待ってから、一定のまとまりで取得するようにしています。

まずスケルトンの位置へ飛ぶ

この構成なら、引用元の本体を取得していなくても、リスト内の位置はわかります。

引用部分を押したら、引用元のIDから行を探し、scrollToIndexでその行が中央に来るように移動します。

各行は、メッセージ本体の取得前後で変わらないキーを持っています。そのキーから引用元の位置を探し、スクロールします。処理の要点を抜き出すと、次のようになります。

// 説明用に簡略化したコード(データ取得・着地位置の再調整は省略)
const scrollToComment = (commentId: string) => {
  const targetKey = `c:${commentId}`;

  const index = lines.findIndex(
    line => getVirtualizedChatRoomLineKey(line) === targetKey
  );

  if (index < 0) return;

  virtuosoRef.current?.scrollToIndex({
    index,
    align: 'center',
  });
};

ポイントは、行の特定にメッセージ本体を必要としないことです。getVirtualizedChatRoomLineKeyは、取得済みのメッセージでも未取得のスケルトンでも、同じIDに対応するキーを返します。

移動先が未取得なら、その時点ではスケルトンが表示されます。表示範囲に入ったことで本体の取得が始まり、取得後にメッセージへ置き換わります。

引用部分をクリック
  ↓
引用元IDから行の位置を特定
  ↓
その行へスクロール
  ↓
未取得なら本体を取得
  ↓
スケルトンをメッセージに置き換え

引用元にたどり着くまでのメッセージを、順番にすべて取得する必要がなくなりました。

ジャンプできても、着地位置がずれる

ここまでで移動はできます。ただ、もうひとつ問題があります。

スケルトンと、実際のメッセージの高さは同じとは限りません。

短いテキストなら数行で収まりますが、長文や画像付きのメッセージは高さが変わります。移動先の周辺でスケルトンが本文に置き換わると、行の高さが変わり、狙った位置からずれてしまいます。

そこで、未取得のメッセージへジャンプした場合は、行データの変化に合わせてスクロール位置を再調整するようにしました。引用元の本体取得が完了したら調整を終えます。

また、取得に失敗した場合などに位置を合わせ続けないよう、一定時間で打ち切っています。後から別の行データが更新されたときに、意図せず引用元へ引き戻されることを防ぐためです。

前回の記事でも、画像の読み込みによってスクロール位置がずれる話を紹介しました。今回も「後から高さが変わる」という問題ですが、未取得の行へジャンプすることで、あらためて向き合うことになりました。

おわりに

今回実装したのは、引用部分を押して元のメッセージへ移動する、おなじみの操作です。

でも、その裏側では、まだ取得していないメッセージをどう扱うか、読み込みで高さが変わったらどうするか、といった課題がありました。

リスト描画の方式から考え直したことで、全IDを骨格にし、必要な本体を取得しながら任意の位置へ移動できる構成になりました。

今後、検索結果や日付からメッセージへ移動する機能を考えるときにも、この仕組みを土台にできそうです。

前回に続いて、今回も「当たり前の体験」は、当たり前には作れませんでした。引用返信を使う人が、こうした仕組みを意識せずに会話を続けられたらうれしいです。

最後までお読みいただき、ありがとうございました!


YOUTRUSTではWebエンジニアを募集しています。

興味のある方はぜひご応募ください!

youtrust.jp

herp.careers