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?

【React】Virtual Scrollingで無限スクロールの復元を爆速化:X(旧Twitter)の実装を完全解説

Last updated at Posted at 2026-01-04

はじめに:3つのアプローチ

これまで、SPAでスクロール位置とデータを復元する方法を2つ紹介しました:

第1弾:復元パターン

  • SWRキャッシュでデータを復元
  • <ScrollRestoration />でスクロール位置を復元
  • 復元速度:100〜300ms

第2弾:保持パターン

  • display: none/blockで切り替え
  • コンポーネントをアンマウントせず状態を保持
  • 復元速度:~16ms(最大20倍高速)

第2.5弾:React 19.2の <Activity /> で保持パターン

  • display: none/block のパターンから、React 19.2の <Activity /> に移行

第3弾:Virtual Scrollingパターン(今回)

しかし、数千〜数万件の無限スクロールでは、どちらのアプローチも限界があります:

  • 復元パターン:全アイテムを再レンダリング → 遅い
  • 保持パターン:全アイテムが常にDOM上に存在 → メモリ不足

そこで登場するのが、Virtual Scrolling(仮想スクロール)です。

X(旧Twitter)が採用しているこの手法は:

  • ✅ ビューポート内の20-30個のみレンダリング
  • 復元速度:~50ms(高速)
  • ✅ 無限のアイテムでもメモリ一定

今回は、Xの実装を解析し、なぜVirtual Scrollingは復元が速いのかを完全解説します。

3つのアプローチの復元速度比較

アプローチ 復元速度 レンダリング要素数 メモリ使用量 適用場面
復元パターン 100-300ms 全要素(500個など) 中規模データ(〜500件)
保持パターン ~16ms 全要素(常にマウント) 少数ページ(3-5ページ)
Virtual Scrolling ~50ms ビューポート内のみ(20-30個) 大量データ(数千〜無限)

Virtual Scrollingとは

基本原理

Virtual Scrolling(仮想スクロール)とは、巨大なリストを高速に表示するための実装テクニックです。

通常の無限スクロール:

スクロールするほどDOM要素が増加
500個スクロール = 500個のDOM要素が常にメモリに存在

Virtual Scrolling:

ビューポート内のみレンダリング
500個スクロールしても = 20-30個のDOM要素(一定)

仕組みの図解

┌─────────────────┐
│  Item 1         │ ← 非表示(DOMに存在しない)
│  Item 2         │
│  ...            │
├─────────────────┤ ← ビューポート開始
│  Item 10  ✓     │ ← 表示(DOMに存在)
│  Item 11  ✓     │
│  Item 12  ✓     │
│  ...      ✓     │
│  Item 30  ✓     │
├─────────────────┤ ← ビューポート終了
│  Item 31        │ ← 非表示(DOMに存在しない)
│  Item 32        │
│  ...            │
└─────────────────┘

平たく言うと、現在見えているリストアイテムのみを描画する方法です。

スクロールすると、見えなくなったアイテムを削除し、新しく見えるアイテムを追加します。

仕組みを理解するシンプルな実装例

基本的なVirtual Scrollingの仕組みを理解するため、シンプルな実装を見てみましょう:

// スクロール位置から表示すべきアイテムを計算するカスタムフック
const useVirtualScroll = ({ containerHeight, itemHeight, items }) => {
  const [startIndex, setStartIndex] = useState(0)

  // 画面に表示するアイテム数(+余白分)
  const maxDisplayCount = Math.floor(containerHeight / itemHeight + 3)

  // スクロールイベント時に開始位置を更新
  const handleScroll = useCallback((e) => {
    const scrollTop = e.currentTarget.scrollTop
    const nextStartIndex = Math.floor(scrollTop / itemHeight)
    setStartIndex(nextStartIndex)
  }, [itemHeight])

  // 表示するアイテムのみ抽出
  const displayingItems = useMemo(
    () => items.slice(startIndex, startIndex + maxDisplayCount),
    [startIndex, maxDisplayCount]
  )

  return { handleScroll, displayingItems, startIndex }
}

// 仮想スクロールリストコンポーネント
const VirtualList = () => {
  const { displayingItems, handleScroll, startIndex } = useVirtualScroll({
    containerHeight: 500,
    itemHeight: 50,
    items: DATA, // 10,000個のアイテム
  })

  return (
    <div onScroll={handleScroll} style={{ height: 500, overflow: "scroll" }}>
      {/* 全体の高さを確保(スクロールバーのため) */}
      <div style={{ height: items.length * 50 }}>
        {/* リスト位置を調整 */}
        <ul style={{
          position: "relative",
          top: startIndex * 50  // ← これがポイント
        }}>
          {displayingItems.map((item) => (
            <li key={item} style={{ height: 50 }}>
              {item}
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

仕組みのポイント:

  1. 全体の高さを確保<div style={{ height: items.length * 50 }}>でスクロールバーを表示
  2. リスト位置を調整top: startIndex * 50でスクロール位置に合わせてリストを移動
  3. 表示アイテムのみレンダリングitems.slice(startIndex, startIndex + maxDisplayCount)

これにより、全件を表示しているように見せながら、実際には見えている範囲のみ描画しています。

参考:

なぜVirtual Scrollingは復元が速いのか

復元時の動作比較

復元パターン(全要素レンダリング):

// 500個全てを再レンダリング
復元時
1. SWRキャッシュからデータ取得10ms
2. 500個のコンポーネントをレンダリング200ms
3. スクロール位置を復元10ms
合計220ms

Virtual Scrollingパターン:

// ビューポート内の20個だけレンダリング
復元時
1. スクロール位置を設定5ms
2. その位置で表示すべきアイテムを計算10ms
3. 20個のコンポーネントをレンダリング30ms
合計45ms

 約5倍速い

数式で理解する

復元時間 ≈ レンダリング要素数 × 1要素あたりのレンダリング時間

復元パターン:500 × 0.4ms = 200ms
Virtual Scrolling:20 × 1.5ms = 30ms

Virtual Scrollingは、要素数が少ないため圧倒的に速いです。

Xの実装を解析

GitHubに公開されている twitter-virtual-scroller を元に、Xの実装を解析します。

twitter-virtual-scrollerリポジトリについて

このセクションで解析するコードは、GitHubで公開されている twitter-virtual-scroller リポジトリから取得しています。

リポジトリの特徴:

  • X(旧Twitter)のwebサイトから難読化されたコードをリバースエンジニアリング
  • ChatGPTで可読化し、手動でデバッグ・最適化
  • 学習目的のみの利用を想定

主要な実装ファイル:

1. アンカーベース戦略(高速復元の秘密)

通常のスクロール復元:

// 単純にscrollTopを設定
scrollTop = 5000  // 5000pxの位置に移動
// → どのアイテムを表示すべきか計算が必要

Xのアンカーベース戦略:

// 最も視認性が高いアイテムを「アンカー」として保存
interface RestorationAnchor {
  itemId: string           // "tweet_12345"
  distanceToViewportTop: number  // ビューポート上端からの距離(120px)
  isFocused: boolean       // フォーカスされているか
}

// 復元時:アンカーアイテムを基準に復元
function restore(anchor: RestorationAnchor) {
  const item = findItemById(anchor.itemId)
  const offset = calculateOffset(item)

  // アンカーアイテムをビューポート上端から120pxの位置に配置
  scrollTop = offset - anchor.distanceToViewportTop

  // アンカー周辺のアイテムだけレンダリング
  renderItemsAround(item)
}

なぜ速いのか:

  • アンカーアイテムを直接見つける(O(1))
  • そこから前後のアイテムだけレンダリング
  • 全アイテムの走査が不要

2. アンカーの選び方

getAnchor(viewportRect: Rectangle) {
  const candidates = this.getAnchorItemCandidates()

  // 最良のアンカーを選択(2つの基準)
  const bestCandidate = findTheBest(candidates, (item1, item2) => {
    const rect1 = new Rectangle(item1.offset, this.getHeight(item1.item))
    const rect2 = new Rectangle(item2.offset, this.getHeight(item2.item))

    // 優先順位1: 可視性(完全に見えているアイテムを優先)
    const visibilityDiff = isVisible(rect1) - isVisible(rect2)
    if (visibilityDiff !== 0) return visibilityDiff

    // 優先順位2: 可視高さ(より多く見えているアイテムを優先)
    return getVisibleHeight(rect2) - getVisibleHeight(rect1)
  })

  return new Anchor(bestCandidate.item.id, ...)
}

3. ビューポート内アイテムの計算

// 矩形の交差判定で表示すべきアイテムを決定
getItemsWithin(viewportRect: Rectangle) {
  return this.getFinalRenderedItems().filter(({ item, offset }) => {
    const itemRect = new Rectangle(offset, this.getHeight(item))
    return itemRect.doesIntersectWith(viewportRect)
  })
}

効率的な理由:

  • 全アイテムをチェックしない
  • レンダリング済みのアイテムのみチェック
  • 矩形の交差判定は高速(O(1))

4. position vs transform の使い分け

// 条件付き配置戦略
const positioningStyle = {
  top: shouldUseTopPositioning ? `${offset}px` : undefined,
  transform: shouldUseTopPositioning
    ? undefined
    : `translateY(${offset}px)`,
}
段階 使用プロパティ 理由
初期レンダリング top レイアウト計算が必要
スクロール中 transform GPU加速、リペイント回避

パフォーマンスの違い:

top: レイアウト再計算 → ペイント → 合成(遅い)
transform: 合成のみ(GPU加速、速い)

パフォーマンス最適化の3本柱

1. Throttling(スクロール中の更新制限)

// 100ms間隔に制限
this.scheduleCriticalUpdateThrottled = throttle(
  () => this.scheduleCriticalUpdate(),
  100,
  { trailing: true }
)

// スクロール中は100msごとに更新
onScroll = () => {
  this.scheduleCriticalUpdateThrottled()
}

効果:

Throttlingなし:スクロール中に100回/秒更新 → 重い
Throttlingあり:スクロール中に10回/秒更新 → 軽い(10分の1)

2. Debouncing(スクロール終了時の処理)

// スクロール停止200ms後に実行
this.updateScrollEnd = debounce(() => {
  this.previousScrollPosition = this.viewport.scrollY()
  this.isIdle = true
  onScrollEnd()
  this.scheduleCriticalUpdate()  // 詳細計算
}, 200)

効果:

  • スクロール中は簡易計算のみ
  • 停止後に詳細計算(測定、アンカー更新など)
  • ユーザー体験を損なわず、無駄な計算を削減

3. メモ化(計算結果のキャッシング)

// リスト同一参照時はマップ再構築をスキップ
this.getItemMapMemoized = memoize((list: DataItem[]) => {
  const map = new Map<string, DataItem>()
  list.forEach((item) => map.set(item.id, item))
  return map
})

// 使用例
const itemMap = this.getItemMapMemoized(this.props.list)
// listが同じ参照なら、前回のmapを返す(再計算なし)

効果:

メモ化なし:毎回Mapを再構築(O(n))
メモ化あり:同じリストなら即座に返す(O(1))

4. 遅延測定(アイドル時のみ測定)

const readyForMeasuring =
  (!hasAnimations && heightsReady && (this.isIdle || ...)) ||
  (heightsReady && this.isInitialAnchoring)

if (readyForMeasuring) {
  // DOM測定(高コスト)
  measureItemHeights()
}

効果:

  • スクロール中はDOM測定をスキップ
  • レイアウトスラッシング(強制同期レイアウト)を回避
  • 60fpsを維持

実装方法:主要ライブラリの比較

1. TanStack Virtual(推奨)

特徴:

  • ✅ 最新のベストプラクティス
  • ✅ フレームワーク非依存(React、Vue、Solidなど)
  • ✅ 動的な高さに対応
  • ✅ TypeScript完全対応

基本的な使い方:

import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualList() {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: 10000,  // アイテム総数
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // 推定高さ
    overscan: 5,  // ビューポート外にレンダリングする要素数
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            Item {virtualItem.index}
          </div>
        ))}
      </div>
    </div>
  )
}

復元の実装:

function VirtualListWithRestoration() {
  const parentRef = useRef<HTMLDivElement>(null)

  // スクロール位置を保存
  const [scrollOffset, setScrollOffset] = useState(0)

  const virtualizer = useVirtualizer({
    count: 10000,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    // 初期スクロール位置を設定
    initialOffset: scrollOffset,
  })

  // ページ遷移前にスクロール位置を保存
  useEffect(() => {
    const handleBeforeUnload = () => {
      sessionStorage.setItem('scrollOffset', String(virtualizer.scrollOffset))
    }

    window.addEventListener('beforeunload', handleBeforeUnload)
    return () => window.removeEventListener('beforeunload', handleBeforeUnload)
  }, [virtualizer.scrollOffset])

  // 初回マウント時に復元
  useEffect(() => {
    const savedOffset = sessionStorage.getItem('scrollOffset')
    if (savedOffset) {
      setScrollOffset(Number(savedOffset))
    }
  }, [])

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      {/* 前と同じ */}
    </div>
  )
}

2. React Virtuoso

特徴:

  • ✅ 動的な高さに自動対応
  • ✅ 無限スクロール機能内蔵
  • ✅ シンプルなAPI

基本的な使い方:

import { Virtuoso } from 'react-virtuoso'

function VirtualList() {
  return (
    <Virtuoso
      style={{ height: '600px' }}
      totalCount={10000}
      itemContent={(index) => <div>Item {index}</div>}
    />
  )
}

復元の実装:

function VirtualListWithRestoration() {
  const virtuosoRef = useRef(null)
  const [scrollState, setScrollState] = useState(null)

  // スクロール状態を保存
  const handleStateChange = (state) => {
    setScrollState(state)
    sessionStorage.setItem('virtuosoState', JSON.stringify(state))
  }

  // 初回マウント時に復元
  useEffect(() => {
    const savedState = sessionStorage.getItem('virtuosoState')
    if (savedState) {
      setScrollState(JSON.parse(savedState))
    }
  }, [])

  return (
    <Virtuoso
      ref={virtuosoRef}
      style={{ height: '600px' }}
      totalCount={10000}
      itemContent={(index) => <div>Item {index}</div>}
      // 状態の変更を監視
      rangeChanged={handleStateChange}
      // 初期状態を復元
      restoreStateFrom={scrollState}
    />
  )
}

3. react-window

特徴:

  • ✅ 軽量(7.9KB gzipped)
  • ✅ シンプルで高速
  • ❌ 動的な高さは手動対応

基本的な使い方:

import { FixedSizeList } from 'react-window'

function VirtualList() {
  return (
    <FixedSizeList
      height={600}
      itemCount={10000}
      itemSize={100}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>Item {index}</div>
      )}
    </FixedSizeList>
  )
}

ライブラリ比較表

ライブラリ バンドルサイズ 動的高さ 無限スクロール TypeScript 推奨度
TanStack Virtual 18KB 別途実装 ⭐⭐⭐⭐⭐
React Virtuoso 41KB ⭐⭐⭐⭐
react-window 7.9KB ❌(手動) 別途実装 ⭐⭐⭐

無限スクロールとの組み合わせ

Virtual Scrollingに無限スクロールを追加する方法:

TanStack Virtual + SWR の例

import { useVirtualizer } from '@tanstack/react-virtual'
import useSWRInfinite from 'swr/infinite'

function InfiniteVirtualList() {
  const parentRef = useRef<HTMLDivElement>(null)

  // 無限スクロールのデータフェッチ
  const { data, size, setSize } = useSWRInfinite(
    (index) => `/api/items?page=${index}`,
    fetcher
  )

  // 全アイテムを平坦化
  const allItems = data ? data.flat() : []

  const virtualizer = useVirtualizer({
    count: allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 5,
  })

  // 末尾に近づいたら次のページを読み込む
  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse()

    if (!lastItem) return

    // 末尾から5個以内に到達したら次ページ読み込み
    if (lastItem.index >= allItems.length - 5) {
      setSize(size + 1)
    }
  }, [virtualizer.getVirtualItems(), allItems.length, size, setSize])

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const item = allItems[virtualItem.index]
          return (
            <div
              key={virtualItem.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              {item ? item.name : 'Loading...'}
            </div>
          )
        })}
      </div>
    </div>
  )
}

3つのアプローチの使い分けガイド

データ量別の推奨アプローチ

データ量 推奨アプローチ 理由
小(〜100件) 復元パターン(SWRキャッシュ) シンプルで十分
中(100〜500件) 保持パターン(display:none/block) 最速の復元
大(500件〜) Virtual Scrolling メモリ効率と速度のバランス
超大(数千〜無限) Virtual Scrolling 唯一の選択肢

復元速度とメモリの関係

                    復元速度
                      ↑
                      |
   保持パターン(16ms) |     ← 最速だがメモリ使用量大
                      |
Virtual Scrolling(50ms) |    ← バランス型
                      |
   復元パターン(200ms) |     ← 遅いがメモリ効率良
                      |
                      └──────────→ メモリ使用量

実装コストと効果の比較

アプローチ 実装コスト 復元速度 メモリ効率 おすすめ度
復元パターン 小規模データ向け
保持パターン ページ切り替え向け
Virtual Scrolling 大量データ向け

まとめ

SPAでスクロール位置とデータを復元する3つのアプローチを紹介しました:

速度比較

アプローチ 復元速度 レンダリング要素数
復元パターン 100-300ms 全要素(500個など)
保持パターン ~16ms 全要素(常にマウント)
Virtual Scrolling ~50ms ビューポート内のみ(20-30個)

それぞれの特徴

復元パターン(第1弾):

  • SWRキャッシュでデータを復元
  • 実装が簡単
  • 小〜中規模データ向け

保持パターン(第2弾):

  • display: none/blockで状態保持
  • 最速の復元(~16ms)
  • ページ切り替え向け(3-5ページ)

Virtual Scrollingパターン(第3弾・今回):

  • ビューポート内のみレンダリング
  • 高速な復元(~50ms)+ メモリ効率
  • 大量データに最適(数千〜無限)

Xの実装から学んだこと

  1. アンカーベース戦略で高速復元
  2. position vs transformの使い分けでパフォーマンス向上
  3. Throttling/Debouncing/メモ化で無駄な計算を削減

推奨される戦略

データ量に応じて、適切なアプローチを選択:

  • 小規模(〜100件):復元パターン
  • 中規模(100〜500件):保持パターン
  • 大規模(500件〜):Virtual Scrolling

3つのアプローチを組み合わせることで、あらゆるデータ量に対応できます。

参考リンク

公式ドキュメント

参考記事・リポジトリ

前回の記事


この記事が「無限スクロールの復元が遅い」「大量データでメモリ不足」という方の参考になれば幸いです!

質問やフィードバックがあれば、コメント欄でお気軽にどうぞ 👋

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?