はじめに
オプチャグラフは、LINE OpenChatの統計情報を収集・分析・可視化するWebサービスです。
カテゴリ別ランキングページ
オプチャグラフのカテゴリ別ランキングページは、LINEアプリのオプチャカテゴリ画面(React・Swiper.js)を参考にしたUIを実装しています。
主な機能:
- 25カテゴリのスワイプ切り替え(Swiper.js)
- カテゴリごとの動的な時間軸・タグバー
- スクロール時のヘッダー自動隠し
- 無限スクロール(約20万件のデータ)

右はLINEアプリの画面、左はオプチャグラフの画面です。
$\tiny{結構似ていますね。パクリかな?^^;}$
全体像:スワイプ遷移時に何が起きるか
まず、ユーザーがカテゴリをスワイプで切り替えるときの動作を整理します。
よくある状況
- ユーザーが「ゲーム」カテゴリでリストを500pxほどスクロールしている
- 時間軸・タグバーは画面外にスライドアウトして隠れている(スクロールで隠れる)
- この状態で隣の「スポーツ」カテゴリにスワイプする
期待する動作
- 遷移先の「スポーツ」はリストが先頭から表示される
- 時間軸・タグバーがスライドで降りてくる
- スワイプ中は元の「ゲーム」がスクロール位置を維持したまま表示される
- 全体が滑らかに動く
全体が滑らかに動く事は特にこだわったポイントです。
オープンチャットのユーザー層は幅広いため、最新機種以外にも様々な機種のスマートフォンでユーザー体験を損なわないことが重要です。
ローエンド機種であってもしっかり操作できることをテストします。
素直に実装すると起きる問題
| 問題 | 原因 |
|---|---|
| スワイプがカクつく | 25カテゴリ全部をDOMに持っていて重い |
| スワイプ中に画面がガタつく | 遷移元のスクロール位置がリセットされる |
| 時間軸・タグバーが降りてくるときリストがガタつく | バーの高さ分だけリストが押し下げられる |
これらを解決するために、以下の3つの仕組みを組み合わせています。
1. 選択的レンダリング:今いるカテゴリ + 隣だけを描画する
考え方
25カテゴリ全部をレンダリングすると重いので、今いるカテゴリ + 左右の隣だけをレンダリングします。
カテゴリ1 カテゴリ2 [カテゴリ3] カテゴリ4 カテゴリ5 ...
空 ダミー ← 今ここ → ダミー 空
(10件) 実データ(10n件) (10件)
- 現在のカテゴリ: 実データ10n件をレンダリング(API取得済み)
- 左右の隣: 軽量なダミー10件(Skeleton)をレンダリング
- それ以外の22カテゴリ: 何もレンダリングしない
本物のLINEアプリの実装もこれに似ているのですが、左右の列はダミーではなく実データをレンダリングしています。
そちらはバーが無い分よりシンプルです。
スワイプ中にAPIからデータを取得し、遷移先のダミーが実データに変わります。
この瞬間が最も高負担なので、遷移先の初期データ件数は最小限にすることが望ましいです。
例えば、ダミー20件 → スワイプ中に20件の実データを取得 とした場合、ローエンド環境では一気にフレームレートが下がりました。
10件であれば余裕があるといった具合です。
実装
{OPEN_CHAT_CATEGORY.map((el, i) => (
<SwiperSlide key={i}>
{(() => {
// 今いるカテゴリ → 実データ
if (i === cateIndex) {
return <OpenChatRankingList query={...} />
}
// 左隣が見えている → ダミー
if (prevInView && i === cateIndex - 1) {
return <DummyOpenChatRankingList />
}
// 右隣が見えている → ダミー
if (nextInView && i === cateIndex + 1) {
return <DummyOpenChatRankingList />
}
// それ以外 → 何も出さない
return null
})()}
</SwiperSlide>
))}
隣が「見えている」かどうかの判定
react-intersection-observer の useInView で、現在スライドの左端・右端を監視します。
const { ref: prevRef, inView: prevInView } = useInView()
const { ref: nextRef, inView: nextInView } = useInView()
// 現在のスライド内に透明な監視用要素を配置
<div ref={prevRef} style={{ position: 'absolute', left: 0 }} />
<div ref={nextRef} style={{ position: 'absolute', right: 0 }} />
スワイプを開始して端が画面に入ると prevInView / nextInView が true になり、その瞬間にダミーが表示されます。
2. スクロール位置の維持:遷移元を現在位置に固定する
問題
「ゲーム」で500pxスクロールした状態から「スポーツ」に切り替えると、「スポーツ」は先頭から表示されます。
でもスワイプのアニメーション中は、遷移元の「ゲーム」が画面に表示され続ける必要があります。ここでスクロール位置がリセットされると、画面がガタつきます。
素直に実装するならスクロールを戻さなくても良いのですが、遷移先でバーを下ろすためはスクロールを0に戻す処理が必要です。
この辺りは他に方法があるかもしれないです。
解決策
遷移中だけ、遷移元のスライドを position: absolute で現在のスクロール位置に固定します。
const scrollY = useRef(0)
// スワイプ開始時にスクロール位置を記録
onSlideChangeTransitionStart={() => {
scrollY.current = window.scrollY
setTIndex([cateIndex, query])
}}
// 遷移中は遷移元を現在位置に固定
style={{
...(tIndex && i === tIndex[0]
? { position: 'absolute', top: `${-scrollY.current}px` }
: {})
}}
// スワイプ完了後に固定を解除
onSlideChangeTransitionEnd={() => setTIndex(null)}
動作の流れ
- スワイプ開始 →
scrollY.current = 500を記録 - 遷移元「ゲーム」に
position: absolute; top: -500pxを適用 - → 見た目上は同じ位置に固定される
- 遷移先「スポーツ」は
scrollToTop()で先頭から表示 - スワイプ完了 → 固定を解除
3. バーの降下:リストの開始位置を固定する
問題
遷移先の「スポーツ」は先頭から表示されるので、時間軸・タグバーがスライドで降りてきます。
普通に実装すると、バーが降りてくるたびにリストが押し下げられてガタつきます。
解決策
リストは常に「バーがある前提」の位置から始まるようにします。
① リスト側は常に固定の marginTop
<SwiperSlide>
<div style={{ marginTop: '94px' }}> {/* 常に94px */}
{/* リストコンテンツ */}
</div>
</SwiperSlide>
② 見えないスペーサーでバーの領域を確保
<Box sx={{ height: hasTagBar ? 82 : 38 }} />
この Box は見えませんが、バーの領域を確保しています。バーが Slide で降りてきても、この領域の中に収まるのでリストを押し下げません。
③ タグバーはSlideアニメーションで降りてくる
function HideOnScroll({ children }) {
const trigger = useScrollTrigger()
return (
<Slide direction='down' in={!trigger}>
{children}
</Slide>
)
}
スクロール位置が先頭 → trigger が false → バーが降りてくる。でもリストの marginTop は固定なので位置は変わらない。
4. 無限スクロール:追加された分だけレンダリングする
ここからはスワイプ遷移とは別の話で、1つのカテゴリ内での無限スクロールについてです。
問題
無限スクロールでデータが 10件 → 20件 → 30件 と増えていきます。
普通に実装すると、10件追加されるたびに全件を再レンダリングしてしまいます。
解決策:生成済みのJSX要素を使い回す
useRef でJSX要素の配列を保持し、新しく追加された分だけを配列に追加します。
const ListContext = memo(function ListContext({ data, query }) {
// JSX要素の配列をRefで保持
const items = useRef<React.JSX.Element[]>([])
const curLen = items.current.length
const dataLen = data.length
// 同じクエリ & 同じ件数 → そのまま返す
if (query === prevQuery && curLen === dataLen) {
return <ol>{items.current}</ol>
}
// 新しく追加された分だけループで追加
for (let i = curLen; i < dataLen; i++) {
items.current[i] = (
<li key={data[i].id}>
<OpenChatListItem {...data[i]} />
</li>
)
}
return <ol>{items.current}</ol>
})
動作の流れ
1回目(10件): items = [] → 10件追加 → [JSX0, ..., JSX9]
2回目(20件): items = [JSX0, ..., JSX9] → 10件追加 → [JSX0, ..., JSX19]
↑ そのまま ↑ 新規
useRef で保持した配列は再レンダリングでも維持されるので、既存の要素を再生成せずに済みます。
先読みで読み込み待ちをなくす
const ROOT_MARGIN = isSP() ? '100px' : '500px'
const { ref, inView } = useInView({
rootMargin: ROOT_MARGIN, // 画面外100px/500pxに入ったらトリガー
})
useEffect(() => {
if (inView && hasMore) {
loadMore()
}
}, [inView])
rootMargin を大きめに取ることで、ユーザーが一番下に到達する前に次のデータが読み込まれます。
5. ダミー表示:Skeletonで「コンテンツがある感」を出す
Skeletonコンポーネント
データがまだ届いていないとき、Material-UIの Skeleton で実際のコンテンツと同じ形のプレースホルダーを表示します。
export function DummyOpenChatListItem({ len = 10 }) {
return (
<>
{new Array(len).fill(0).map((_, i) => (
<li className='openchat-item' key={i}>
<Skeleton variant='circular' width={48} height={48} />
<Skeleton height={19} />
<Skeleton height={13} />
<Skeleton height={13} />
<Skeleton width='50%' height={13} />
</li>
))}
</>
)
}
画像の遅延読み込み
<div className='item-img-outer'>
{/* 背景にSkeletonを配置 */}
<div style={{ opacity: 0.55 }}>
<Skeleton variant='circular' width='100%' height='100%' />
</div>
{/* 実際の画像 */}
<img src={imageUrl} loading='lazy' />
</div>
loading='lazy' で画面外の画像は読み込みを遅延させ、初期表示を高速化しています。
まとめ
スワイプ遷移時の3つの仕組み
| 仕組み | 解決する問題 |
|---|---|
| 選択的レンダリング | 25カテゴリ全部レンダリングすると重い |
| スクロール位置の固定 | 遷移中に遷移元がガタつく |
| marginTopの固定 | タグバーが降りてくるとリストがガタつく |
無限スクロールの仕組み
| 仕組み | 解決する問題 |
|---|---|
| JSXキャッシュ | 全件再レンダリングで遅くなる |
| 先読み | 読み込み待ちが発生する |
| Skeleton | 読み込み中が寂しい |
どれも「必要なものだけを、必要なときに」という考え方に基づいています。
関連リンク
前回までの記事
- ①データパイプライン - 25万件/毎時のクロール〜静的ファイル生成
- ②DBの差分検出 - 25万件のオプチャ更新を99%削減する仕組み
- ③バッチ設計 - 冪等性・再開可能性・障害耐性
- ④2025年の技術的チャレンジ振り返り - 多言語対応の実装・キーワードスパム対策等
- ⑤タグ機能 - オープンチャットを分類するタグ付けシステム
- ⑥キーワード羅列対策 - 検索用のキーワード羅列対策





