はじめに: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>
)
}
仕組みのポイント:
-
全体の高さを確保:
<div style={{ height: items.length * 50 }}>でスクロールバーを表示 -
リスト位置を調整:
top: startIndex * 50でスクロール位置に合わせてリストを移動 -
表示アイテムのみレンダリング:
items.slice(startIndex, startIndex + maxDisplayCount)
これにより、全件を表示しているように見せながら、実際には見えている範囲のみ描画しています。
参考:
- React の仮想スクロールを理解する - より詳しい原理の解説
- react-virtual-scroll-example - 動作するサンプルコード
なぜ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で可読化し、手動でデバッグ・最適化
- 学習目的のみの利用を想定
主要な実装ファイル:
- VirtualScroller.tsx - メインコンポーネント
- VirtualScrollerRenderer.tsx - レンダリング処理
- Viewport.ts - ビューポート管理
- Anchor.ts - アンカーベース戦略の実装
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の実装から学んだこと
- アンカーベース戦略で高速復元
- position vs transformの使い分けでパフォーマンス向上
- Throttling/Debouncing/メモ化で無駄な計算を削減
推奨される戦略
データ量に応じて、適切なアプローチを選択:
- 小規模(〜100件):復元パターン
- 中規模(100〜500件):保持パターン
- 大規模(500件〜):Virtual Scrolling
3つのアプローチを組み合わせることで、あらゆるデータ量に対応できます。
参考リンク
公式ドキュメント
参考記事・リポジトリ
- twitter-virtual-scroller - Xの実装を再現
- React の仮想スクロールを理解する - 仮想スクロールの基本原理を独自実装で理解
- react-virtual-scroll-example - シンプルな仮想スクロールのサンプルコード
- The Magic of Virtual Scroll in React
- Virtual and Infinite Scrolling in React
前回の記事
この記事が「無限スクロールの復元が遅い」「大量データでメモリ不足」という方の参考になれば幸いです!
質問やフィードバックがあれば、コメント欄でお気軽にどうぞ 👋