こんにちは。 ピクシブ百科事典の開発をしております、フロントエンドエンジニアの ahu です。
ピクシブ百科事典 は、「みんなでつくる百科事典」を合言葉に運営されている、オンライン百科事典サービスです。 今回は、6月末にリリースされた新機能である 議論スレッド機能 について、その開発の舞台裏を紹介します。 具体的には、上下どちらの方向にもスクロールできる無限仮想スクロールの実装と、特定のコメントに移動できるジャンプ機能を開発しました。
機能の説明と、無限スクロールや仮想スクロールについて
技術的な詳細に入る前に、議論スレッド機能がどういったものであるかを軽くみてみましょう。
議論スレッド機能はいわゆる掲示板のような機能であり、議論の一覧が並ぶページ と、画像のようなコメントの閲覧や送信ができるページがあります。
このページは上下にスクロールできるようになっており、コメントに特定の記法が使われているとスレッド内の特定のコメントの箇所へ移動できる機能(以下ジャンプ機能と呼びます)なども実装されています。
他社様の例だと、LINE や Slack 、 Discord などと近い UI や機能を持ちます。
このようなページャーのない UI は、一般的には無限スクロールなどと呼ばれているかと思います。 ブラウザで動くように実装するなら、上下に並んでいるコメントの両端を IntersectionObserver で監視して API を叩けばよさそうで、一見簡単そうに見えますね。
しかしこの実装だと、コメントの数が増えれば増えるほどメモリ消費が大きくなり、特にモバイル端末などではレスポンスの悪化や発熱などの問題が発生するかもしれません。 こういった問題を解決するために、「見えている範囲(に近い範囲)だけをレンダリングさせるようにしよう」というアプローチがあり、これは一般に仮想スクロールと呼ばれています。
議論スレッド機能では、プロダクト的な要求から仮想スクロールを実現する必要がありました。 ここで重要なポイントとして、ジャンプ機能がありかつジャンプした先のコメントの前後を無制限に確認できるようにしたい場合、無限スクロールは双方向(上下の両方)でできる必要があります。 ここが主に、今回の実装での最も難しい箇所だと思われます。
双方向のスクロールとライブラリの選定
無限仮想スクロールのライブラリは react-virtualized や @tanstack/react-virtual が有名でしょう。 以下は、今回必要そうな機能についていくつかのライブラリを比較してみた様子です。(出典)
@tanstack/react-virtual | react-virtualized | react-window | virtua | react-virtuoso | |
---|---|---|---|---|---|
Vertical scroll | 🟠 | ✅ | ✅ | ✅ | ✅ |
Dynamic list size | ✅ | 🟠 | 🟠 | ✅ | ✅ |
Dynamic item size | 🟠 | 🟠 | 🟠 | ✅ | ✅ |
Reverse (bi-directional) infinite scroll | ❌ | ❌ | ❌ | ✅ | ✅ |
Scroll restoration | ❌ | ❌ | ❌ | ✅ | ✅ |
「Reverse (bi-directional) infinite scroll」が今回一番注目したい部分である、無限仮想スクロールかつ、下だけでなく上にも要素を追加できるか、という項目になります。
よく知られたライブラリでも、意外と対応されていないニッチな機能ということがわかりますね。
実際に、@tanstack/react-virtual でもサンプルコードは書いてみましたが、確かに動いてはいなさそうでした。
(isRtl
という水平スクロールの場合にスクロール方向を反転できるオプションがあり、これで工夫できないかも試しましたが無理そうでした。)
virtua と react-virtuoso という2つのライブラリが候補として残り、これらで目指す実装は実現できるのか、実際にプロトタイプを作って試してみることにしました。
2つのライブラリでプロトタイプを作ってみる
プロトタイプは、以下の動画のようなものを作りました。
最初は500番目の要素が表示されていて、上下にスクロールができ、番号を入力するとその要素にジャンプできるというものです。
要素にジャンプしたあとは、最初と同じようにそのコメントから上下にどこまででもスクロールできます。
まずは react-virtuoso による実装から。
loadMoreRows
は、オフセットを受け取りそれより後ろのデータを CHUNK_SIZE
分返すような関数です。
スクロール全体を監視するというよりは、上端や下端に到達した場合の処理を書いて渡してあげる感じです。 ただ、スクロール位置を制御するような低レベルの API は公開されていないため、ジャンプの処理を実装する場合はコンポーネントを強制的に再描画する必要がありそうで、そこは気になります。 (key を state で持って +1 していくのは微妙そうですが、プロトタイプなので一旦これで。) 処理の流れは大変追いやすいのですが、ライブラリのサイズが少し大きいのも気になりました。
const ScrollArea = ({ initialIndex, handlerRef }: Props) => { const ref = useRef<VirtuosoHandle>(null); const [rows, setRows] = useState<string[]>([]); const [offsetFromFirstItem, setOffsetFromFirstItem] = useState(initialIndex); const loadMoreRowsDown = useCallback( async (rowCount: number) => { const newRows = await loadMoreRows(offsetFromFirstItem + rowCount + 1); setRows((rows) => [...rows, ...newRows]); }, [offsetFromFirstItem], ); const loadMoreRowsUp = useCallback(async (index: number) => { const newRows = await loadMoreRows(index - CHUNK_SIZE); setRows((rows) => [...newRows, ...rows]); setOffsetFromFirstItem((prev) => prev - newRows.length); }, []); useEffect(() => { (async () => { const newRows = await loadMoreRows(offsetFromFirstItem); setRows(newRows); })(); }, []); // rows を完全に入れ替える場合は、強制的に再描画してスクロール位置をリセットする const [key, setKey] = useState(0); const scrollTo = useCallback( async (index: number) => { if (!ref.current) return; // インデックスが管理範囲内ならスクロールし、範囲外なら初期状態と同じように表示する if ( index >= offsetFromFirstItem && index < offsetFromFirstItem + rows.length ) { ref.current.scrollToIndex({ index: index - offsetFromFirstItem, align: "start", }); } else { const newRows = await loadMoreRows(index); setRows(newRows); setOffsetFromFirstItem(index); setKey((prev) => prev + 1); } }, [offsetFromFirstItem, rows.length], ); useImperativeHandle(handlerRef, () => ({ scrollTo }), [scrollTo]); return ( <Virtuoso key={key} ref={ref} style={{ height: 300 }} data={rows} firstItemIndex={offsetFromFirstItem} startReached={loadMoreRowsUp} endReached={loadMoreRowsDown} itemContent={(index, rows) => { return ( <div key={index} style={indexBasedStyle(index)}> {rows} </div> ); }} // SEE: https://github.com/petyosi/react-virtuoso/issues/1117 skipAnimationFrameInResizeObserver /> ); };
次は virtua による実装です。
自分があまり一般的でない機能を作っているからというのもあるのですが、公開されている API は低レベルなものが多く、自分で多くを書く必要があります。 (onScroll は throttle を挟むのが正攻法な気もしますが、プロトタイプなので一旦これで。) しかしその分、要素を追加した場合のスクロール位置の制御など細かい挙動の調整も可能で、よく考えてコードを書けばそれに応えて堅実な動作をしてくれる、という印象でした。 サンプルコードが充実していること、コア実装が分離されていて Vue や Solid や Svelte 用の実装もあること、ライブラリのサイズが小さいこと、なども長く使っていく上では良い点だろうなと感じました。
const ScrollArea = ({ initialIndex, handlerRef }: Props) => { const ref = useRef<VirtualizerHandle>(null); const [rows, setRows] = useState<string[]>([]); const offsetFromFirstItem = useRef(initialIndex); // 要素を上に追加する場合と下に追加する場合とで、スクロール位置の保持の挙動を変える const [shift, setShift] = useState(false); // 同じ引数で何度も関数が呼ばれる挙動があるので、ロード中であるというフラグを作る const isLoading = useRef(false); const loadMoreRows = useCallback( async (...params: Parameters<typeof loadMoreRows_>) => { isLoading.current = true; const newRows = await loadMoreRows_(...params); isLoading.current = false; return newRows; }, [], ); // onScroll は rows の更新によって自身を再度呼び出すことがあるため、更新直後のスクロールをスキップする const skipScroll = useRef(true); const onScroll = useCallback(async () => { if (!ref.current || isLoading.current || skipScroll.current) { skipScroll.current = false; return; } if (ref.current.findEndIndex() + 1 === rows.length) { // 下方向にスクロールして下限に到達した場合 const offsetFromDataTop = offsetFromFirstItem.current + rows.length; const newRows = await loadMoreRows(offsetFromDataTop); setShift(false); setRows((rows) => [...rows, ...newRows]); skipScroll.current = true; } else if (ref.current.findStartIndex() <= 0) { // 上方向にスクロールして上限に到達した場合 const offsetFromDataTop = offsetFromFirstItem.current - CHUNK_SIZE; const newRows = await loadMoreRows(offsetFromDataTop); setShift(true); setRows((rows) => [...newRows, ...rows]); offsetFromFirstItem.current -= newRows.length; skipScroll.current = true; } }, [loadMoreRows, rows.length]); // scrollTo のための状態で、初期データの取得にも使う("ready" が何もしない状態) type LoadState = "ready" | "loading" | "loadend"; const [loadState, setLoadState] = useState<LoadState>("loading"); const scrollTo = useCallback( async (index: number) => { if (!ref.current) return; // インデックスが管理範囲内ならスクロールし、範囲外なら初期状態と同じように表示する const offset = offsetFromFirstItem.current; if (index >= offset && index < offset + rows.length) { ref.current.scrollToIndex(index - offset, { align: "start" }); } else { offsetFromFirstItem.current = index; setLoadState("loading"); } }, [rows.length], ); const offsetForJump = useRef(0); // ジャンプする位置を一時的に記憶するのに使う useEffect(() => { if (loadState !== "loading") { return; } (async () => { // データがないと上方向へのスクロールができないので、バッファ込みでデータを取る const offsetWithBuffer = offsetFromFirstItem.current - Math.floor(CHUNK_SIZE / 2); const newRows = await loadMoreRows(offsetWithBuffer); setShift(false); setRows(newRows); offsetForJump.current = offsetFromFirstItem.current - offsetWithBuffer; offsetFromFirstItem.current = offsetWithBuffer; setLoadState("loadend"); })(); }, [loadMoreRows, loadState]); useEffect(() => { if (loadState !== "loadend") { return; } ref.current?.scrollToIndex(offsetForJump.current, { align: "start" }); setLoadState("ready"); }, [loadState]); useImperativeHandle(handlerRef, () => ({ scrollTo }), [scrollTo]); return ( <div className="h-[300px] overflow-y-auto"> <Virtualizer ref={ref} onScroll={onScroll} shift={shift}> {rows.map((row) => { const index = Number(row.split("#")[1]); return ( <div key={index} style={indexBasedStyle(index)}> {row} </div> ); })} </Virtualizer> </div> ); };
結果、今回はこちらのライブラリを使ってプロダクトのコードを書いていく方針に決めました。
おわりに
以上、プロダクトに双方向な仮想無限スクロールを導入するまでの技術選定と、その実装の詳細についての紹介でした。
ライブラリ選定の段階でここまで作ることは稀だと思いますが、今回のように「先行事例が少なすぎて実現できるのか自信がない」という場合には、小さく始めるのはよいアプローチだったように思います。 結果として、2つのライブラリの特性の違いを把握でき、プロダクトの要求に合った選択ができました。 (ライブラリの作者である inokawa さんには、この場を借りてお礼申し上げます!ありがとうございます!)
この記事が、複雑な挙動をする無限仮想スクロールを実装したいと思っている方の一助になれば幸いです。