0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オプチャグラフ開発記⑦ 25カテゴリ×無限スクロールを滑らかに動かすためのパフォーマンス設計【React・Swiper.js】

Last updated at Posted at 2025-12-28

はじめに

オプチャグラフは、LINE OpenChatの統計情報を収集・分析・可視化するWebサービスです。

カテゴリ別ランキングページ

オプチャグラフのカテゴリ別ランキングページは、LINEアプリのオプチャカテゴリ画面(React・Swiper.js)を参考にしたUIを実装しています。

主な機能:

  • 25カテゴリのスワイプ切り替え(Swiper.js)
  • カテゴリごとの動的な時間軸・タグバー
  • スクロール時のヘッダー自動隠し
  • 無限スクロール(約20万件のデータ)

Screenshot_20251229_021605_LINE.jpg
右はLINEアプリの画面、左はオプチャグラフの画面です。
$\tiny{結構似ていますね。パクリかな?^^;}$


全体像:スワイプ遷移時に何が起きるか

まず、ユーザーがカテゴリをスワイプで切り替えるときの動作を整理します。

よくある状況

  1. ユーザーが「ゲーム」カテゴリでリストを500pxほどスクロールしている
  2. 時間軸・タグバーは画面外にスライドアウトして隠れている(スクロールで隠れる)
  3. この状態で隣の「スポーツ」カテゴリにスワイプする

Screenshot_20251229_022641_Gallery (1).jpg

期待する動作

  • 遷移先の「スポーツ」はリストが先頭から表示される
  • 時間軸・タグバーがスライドで降りてくる
  • スワイプ中は元の「ゲーム」がスクロール位置を維持したまま表示される
  • 全体が滑らかに動く

Screen_Recording_20251229_022043_Kiwi Browser(1).gif

全体が滑らかに動く事は特にこだわったポイントです。

オープンチャットのユーザー層は幅広いため、最新機種以外にも様々な機種のスマートフォンでユーザー体験を損なわないことが重要です。
ローエンド機種であってもしっかり操作できることをテストします。

素直に実装すると起きる問題

問題 原因
スワイプがカクつく 25カテゴリ全部をDOMに持っていて重い
スワイプ中に画面がガタつく 遷移元のスクロール位置がリセットされる
時間軸・タグバーが降りてくるときリストがガタつく バーの高さ分だけリストが押し下げられる

これらを解決するために、以下の3つの仕組みを組み合わせています。


1. 選択的レンダリング:今いるカテゴリ + 隣だけを描画する

考え方

25カテゴリ全部をレンダリングすると重いので、今いるカテゴリ + 左右の隣だけをレンダリングします。

カテゴリ1  カテゴリ2  [カテゴリ3]  カテゴリ4  カテゴリ5 ...
   空       ダミー     ← 今ここ →    ダミー      空
            (10件) 実データ(10n件)  (10件)
  • 現在のカテゴリ: 実データ10n件をレンダリング(API取得済み)
  • 左右の隣: 軽量なダミー10件(Skeleton)をレンダリング
  • それ以外の22カテゴリ: 何もレンダリングしない

本物のLINEアプリの実装もこれに似ているのですが、左右の列はダミーではなく実データをレンダリングしています。
そちらはバーが無い分よりシンプルです。

Screen_Recording_20251229_033402_Kiwi Browser(1).gif

スワイプ中にAPIからデータを取得し、遷移先のダミーが実データに変わります。
この瞬間が最も高負担なので、遷移先の初期データ件数は最小限にすることが望ましいです。

例えば、ダミー20件 → スワイプ中に20件の実データを取得 とした場合、ローエンド環境では一気にフレームレートが下がりました。
10件であれば余裕があるといった具合です。

実装

📁 OcListMainTabs.tsx

{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-observeruseInView で、現在スライドの左端・右端を監視します。

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 / nextInViewtrue になり、その瞬間にダミーが表示されます。


2. スクロール位置の維持:遷移元を現在位置に固定する

問題

「ゲーム」で500pxスクロールした状態から「スポーツ」に切り替えると、「スポーツ」は先頭から表示されます。

Screenshot_20251229_032317_Kiwi Browser.jpg

でもスワイプのアニメーション中は、遷移元の「ゲーム」が画面に表示され続ける必要があります。ここでスクロール位置がリセットされると、画面がガタつきます。

素直に実装するならスクロールを戻さなくても良いのですが、遷移先でバーを下ろすためはスクロールを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)}

動作の流れ

  1. スワイプ開始 → scrollY.current = 500 を記録
  2. 遷移元「ゲーム」に position: absolute; top: -500px を適用
  3. → 見た目上は同じ位置に固定される
  4. 遷移先「スポーツ」は scrollToTop() で先頭から表示
  5. スワイプ完了 → 固定を解除

3. バーの降下:リストの開始位置を固定する

問題

遷移先の「スポーツ」は先頭から表示されるので、時間軸・タグバーがスライドで降りてきます。

普通に実装すると、バーが降りてくるたびにリストが押し下げられてガタつきます。

Screen_Recording_20251229_022043_Kiwi Browser(1).gif

解決策

リストは常に「バーがある前提」の位置から始まるようにします。

① リスト側は常に固定の marginTop

📁 OcListMainTabs.tsx

<SwiperSlide>
  <div style={{ marginTop: '94px' }}>  {/* 常に94px */}
    {/* リストコンテンツ */}
  </div>
</SwiperSlide>

② 見えないスペーサーでバーの領域を確保

📁 CategoryListAppBar.tsx

<Box sx={{ height: hasTagBar ? 82 : 38 }} />

この Box は見えませんが、バーの領域を確保しています。バーが Slide で降りてきても、この領域の中に収まるのでリストを押し下げません。

③ タグバーはSlideアニメーションで降りてくる

function HideOnScroll({ children }) {
  const trigger = useScrollTrigger()
  return (
    <Slide direction='down' in={!trigger}>
      {children}
    </Slide>
  )
}

スクロール位置が先頭 → triggerfalse → バーが降りてくる。でもリストの marginTop は固定なので位置は変わらない。


4. 無限スクロール:追加された分だけレンダリングする

ここからはスワイプ遷移とは別の話で、1つのカテゴリ内での無限スクロールについてです。

問題

無限スクロールでデータが 10件 → 20件 → 30件 と増えていきます。

普通に実装すると、10件追加されるたびに全件を再レンダリングしてしまいます。

解決策:生成済みのJSX要素を使い回す

useRef でJSX要素の配列を保持し、新しく追加された分だけを配列に追加します。

📁 FetchOpenChatRankingList.tsx

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 で保持した配列は再レンダリングでも維持されるので、既存の要素を再生成せずに済みます。

先読みで読み込み待ちをなくす

Screen_Recording_20251229_031050_Kiwi Browser(1).gif

📁 InfiniteFetchApi.ts

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 で実際のコンテンツと同じ形のプレースホルダーを表示します。

📁 OpenChatListItem.tsx

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>
      ))}
    </>
  )
}

Screen_Recording_20251229_025637_One UI Home(1).gif

画像の遅延読み込み

<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 読み込み中が寂しい

どれも「必要なものだけを、必要なときに」という考え方に基づいています。


関連リンク

前回までの記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?