仮想スクロール型タイム
テーブルアプリを作った

「仮想スクロール」を使ったタイムテーブルアプリをHTML+CSS+JavaScriptで作ってみました。
スマホでスクロールするだけで過去から未来まで日付をまたいで予定表を見られます。
FacebookやTwitterのような無限スクロールの仕組みを作るには、「仮想スクロール」の仕組みが必要です。

関連記事

1. 仮想スクロールって何?

「仮想スクロール」は、画面に見えている部分だけを実際に描画し、見えていない部分はスクロールバーの高さだけを確保する技術です。

仮想スクロールの仕組み 仮想スクロールとは 画面に見えている部分だけを実際に描画し、 見えていない部分は高さだけを確保する技術 メモリ使用量の削減 スムーズな動作 従来の方法の問題点 大量データ(例: 10年分のタイムテーブル) = 87,600個の要素をDOMに追加 ブラウザの動作が重くなる 仮想スクロールの実装方法 1 全体の高さを設定 2 位置計算関数の実装 3 表示要素の動的管理 4 スクロール最適化 仮想コンテンツ全体(10年分) 画面表示範囲 バッファ領域 • 実際にDOMに存在するのは表示部分のみ • スクロールに合わせて要素を追加・削除 • requestAnimationFrameでパフォーマンス向上 実装例: 87,600→50個

大量のデータを表示したいとき、すべての要素をブラウザに描画すると動作が重くなります。
例えば、10年分(約3650日)の予定表をすべて描画すると、1日24時間で計算すると87,600個もの要素をDOMに追加することになります。これではブラウザが重くなってしまいます。

仮想スクロールでは、画面に表示されている50個程度の要素だけを描画し、スクロールに合わせて表示内容を入れ替えます。

1.1. アプリの基本設計

今回作ったアプリは、

  1. 24時間のタイムテーブル表示
  2. 日付をまたいだスムーズなスクロール
  3. 過去5年から未来5年までの予定表示
  4. スクロール位置に合わせた日付表示の更新
  5. ブラウザの標準スクロールと慣性効果を活用
仮想スクロール型タイムテーブルアプリの設計 アプリケーション構成 HTML構造 ・単一ページアプリケーション ・ヘッダー、スクローラー、ビューポート CSS設計 ・絶対位置配置 ・時間スロットと日付区切り JavaScript ・データキャッシュ ・仮想スクロール処理 モバイル対応 ・タッチ操作 ・慣性スクロール DOM構造設計 #app #date-header #virtual-scroller #virtual-content(10年分の高さを持つ) #viewport(要素の絶対配置コンテナ) キーコンポーネント設計 時間スロット 時間 予定 日付区切り 固定日付ヘッダー 位置計算と表示管理 ・スロットインデックス → Y座標 ・Y座標 → スロットインデックス ・バッファ付き表示範囲の計算 特徴 日付間の連続スクロール 慣性スクロールの活用 過去5年から未来5年まで対応 表示要素数:約50個(従来: 87,600個) 1日の高さ:約2,000px バッファサイズ:48時間分 仮想スクロール内の配置 表示領域

HTMLの基本構造は次のようになります:

<div id="app">
  <div id="date-header">日付が表示されます</div>
  <div id="virtual-scroller">
    <div id="virtual-content">
      <div id="viewport">
        <!-- 時間スロットがここに動的に配置される -->
      </div>
    </div>
  </div>
  <div class="instructions">スクロールして時間を変更</div>
</div>Code language: HTML, XML (xml)

2. 仮想スクロールの実装手順

2.1. スクロール領域のサイズ設定

まず、実際のコンテンツ(10年分)と同じ高さの仮想領域を作ります。

function setVirtualContentSize() {
  // 過去5年から未来5年まで = 10年分
  const totalDays = 365 * 10;
  const contentHeight = totalDays * DAY_HEIGHT;

  // 仮想コンテンツの高さを設定
  virtualContent.style.height = `${contentHeight}px`;

  // 初期スクロール位置を現在日時に設定
  const initialDayOffset = Math.floor(totalDays / 2);
  const todayHour = today.getHours();
  const initialSlotIndex = initialDayOffset * SLOTS_PER_DAY + todayHour;
  const initialScrollTop = calculatePositionForIndex(initialSlotIndex) - Math.floor(virtualScroller.clientHeight / 2);

  virtualScroller.scrollTop = initialScrollTop;
}Code language: JavaScript (javascript)

この関数では、10年分の日数に1日の高さをかけて全体の高さを計算し、スクロール領域に設定しています。また、初期表示位置として今日の日付と現在時刻が中央に来るようにスクロール位置を調整しています。

2.2. インデックス⇔位置の相互変換

仮想スクロールでは、どの位置にどの要素を表示するかの計算が重要です。スロットインデックス(通し番号)と画面上の位置(Y座標)を相互に変換する関数を作ります。

// スロットインデックスからY座標を計算
function calculatePositionForIndex(slotIndex) {
  const dayOffset = Math.floor(slotIndex / SLOTS_PER_DAY);
  const hourOffset = slotIndex % SLOTS_PER_DAY;

  return dayOffset * DAY_HEIGHT + hourOffset * (SLOT_HEIGHT + SLOT_MARGIN) + DIVIDER_HEIGHT;
}

// Y座標からスロットインデックスを逆算
function calculateIndexFromPosition(position) {
  const dayOffset = Math.floor(position / DAY_HEIGHT);
  const positionInDay = position % DAY_HEIGHT;

  // 日付区切り分を考慮
  if (positionInDay < DIVIDER_HEIGHT) {
    return (dayOffset * SLOTS_PER_DAY) - 1;
  }

  const hourOffset = Math.floor((positionInDay - DIVIDER_HEIGHT) / (SLOT_HEIGHT + SLOT_MARGIN));
  return dayOffset * SLOTS_PER_DAY + Math.min(hourOffset, SLOTS_PER_DAY - 1);
}Code language: JavaScript (javascript)

これにより、スクロール位置からどのスロットを表示すべきかを計算できます。日付区切りの位置についても正確に考慮しています。

2.3. 表示要素の動的管理

スクロールに合わせて表示する要素を追加・削除する中核となる関数です。

function updateVisibleItems() {
  const scrollTop = virtualScroller.scrollTop;
  const visibleHeight = virtualScroller.clientHeight;

  // バッファを含めた表示範囲
  const topPosition = Math.max(0, scrollTop - (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN));
  const bottomPosition = scrollTop + visibleHeight + (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN);

  // 表示範囲のスロットインデックス
  const startSlotIndex = calculateIndexFromPosition(topPosition);
  const endSlotIndex = calculateIndexFromPosition(bottomPosition);

  // 最上部に表示されているスロットの日付でヘッダーを更新
  const topVisibleSlotIndex = calculateIndexFromPosition(scrollTop);
  const { date } = getDateTimeForSlot(topVisibleSlotIndex);
  if (!date.getTime || currentVisibleDate.getTime() !== date.getTime()) {
    currentVisibleDate = date;
    updateDateDisplay(date);
  }

  // 非表示になった要素を削除
  renderedItems.forEach((element, key) => {
    // キーからインデックスを取得する処理
    // ...

    // 表示範囲外になったら削除
    if (slotIndex < startSlotIndex - SLOTS_PER_DAY || slotIndex > endSlotIndex + SLOTS_PER_DAY) {
      element.remove();
      renderedItems.delete(key);
    }
  });

  // 表示範囲の日付区切りを追加
  // ...

  // 表示範囲内の時間スロットを追加
  for (let i = startSlotIndex; i <= endSlotIndex; i++) {
    if (i < 0) continue;

    const slotKey = `slot-${i}`;

    if (!renderedItems.has(slotKey)) {
      const timeSlot = createTimeSlot(i);
      const position = calculatePositionForIndex(i);
      positionItemInViewport(timeSlot, position);
      viewport.appendChild(timeSlot);
      renderedItems.set(slotKey, timeSlot);
    }
  }
}Code language: JavaScript (javascript)

この関数では次のことを行っています:

  1. 現在のスクロール位置から表示範囲を計算
  2. 画面上部に表示されているスロットの日付で日付ヘッダーを更新
  3. 表示範囲外になった要素をDOMから削除
  4. 表示範囲内の日付区切りと時間スロットを追加

バッファを設定することで、スクロール中も滑らかに表示できるようにしています。

2.4. スクロールイベントの最適化

スクロールイベントは頻繁に発生するため、パフォーマンスを考慮して最適化します。

let scrollTimeout = null;
virtualScroller.addEventListener('scroll', function() {
  if (scrollTimeout) return;

  scrollTimeout = requestAnimationFrame(() => {
    updateVisibleItems();
    scrollTimeout = null;
  });
}, { passive: true });Code language: JavaScript (javascript)

requestAnimationFrameを使うことで、ブラウザの描画タイミングに合わせて処理を実行し、スムーズなスクロール体験を実現しています。

3. 工夫したポイント

仮想スクロール型タイムテーブルアプリの工夫ポイント パフォーマンス最適化 仮想スクロール実装 87,600個 → 50個に要素削減 レンダリング最適化 requestAnimationFrameでスロットリング メモリ使用効率 表示範囲外の要素を削除 DOM操作の最小化 キャッシュの活用 再計算の回避 UX改善 スムーズなスクロール体験 ブラウザネイティブのスクロール活用 日付境界をまたいだ連続スクロール 標準の慣性スクロールによる自然な操作感 過去5年から未来5年までをシームレスに表示 データ管理 効率的なデータ構造 スロットインデックスベースの統一管理 Mapオブジェクトを活用したキャッシュ オンデマンドのデータ生成と再利用 位置計算の最適化 インデックス⇔位置の相互変換 スロットインデックスからY座標を正確に計算 Y座標からスロットインデックスへの逆算 日付区切りの位置も考慮した正確な計算 「必要なものだけを描画する」設計思想の徹底 パフォーマンス UX 最適化

3.1. 効率的なDOM管理

表示範囲内の要素のみをDOMに追加し、範囲外になった要素は削除することで、メモリ使用量を最小限に抑えています。例えば10年分87,600個もの要素のうち、実際にDOMに存在するのは50個程度です。

3.2. 日付ヘッダーの正確な更新

画面最上部に表示されているスロットの日付に基づいて日付ヘッダーを更新しています。これにより、スクロール中も常に正確な日付が表示されます。

// 最上部に表示されているスロットの日付でヘッダーを更新
const topVisibleSlotIndex = calculateIndexFromPosition(scrollTop);
const { date } = getDateTimeForSlot(topVisibleSlotIndex);
if (!date.getTime || currentVisibleDate.getTime() !== date.getTime()) {
  currentVisibleDate = date;
  updateDateDisplay(date);
}Code language: JavaScript (javascript)

3.3. バッファサイズの最適化

表示範囲の前後に2日分のバッファを設定することで、高速スクロール時でもスムーズな表示を実現しています。バッファが大きすぎるとメモリ使用量が増え、小さすぎるとスクロール中に空白が表示されることがあるため、適切なサイズを設定することが重要です。

3.4. 絶対位置による配置

すべての要素はposition: absoluteで正確な位置に配置しています。これにより、通常のDOM配置に比べて描画処理が効率化され、スムーズなスクロールが可能になります。

4. まとめ

仮想スクロールを使うと、数万件のデータを持つタイムテーブルでも軽快に動作するアプリを作れます。今回紹介したテクニックは、長いリスト、カレンダー、ニュースフィードなど、大量のデータを表示する多くのアプリケーションで応用できます。

インデックスと位置の相互変換、表示要素の動的管理、スクロールイベントの最適化など、仮想スクロールの基本を理解すれば、さまざまなアプリケーションに活用できるでしょう。

皆さんも是非、仮想スクロールを使って大量データを扱うアプリを作ってみてください!

4.1. 参考:完全なソースコード