バーチャルスクロール

※この記事は2025/10/07時点の情報です。

今回も前回同様に「vue-virtual-scroller」ライブラリを使用してバーチャルスクロールを実装してみました。
苦労した点ですが、RecycleScroller は仮想化の仕組み上、表示されるリストが変わると再描画され、 インデックスが 0 から再計算されるため、全体の中での正しいインデックスが維持されません。 そのため、グリッドの明細行の背景色を奇数行・偶数行で別の色にするために使用していたインデックスが取得できず0になってしまうため、 奇数・偶数が判定できないケースがあったので、それを考慮したサンプルを作成しました。

「vue-virtual-scroller」ライブラリは下記コマンドでインストールします。

npm install --save vue-virtual-scroller@next

インストールはこれで完了ですが、実装して「モジュール 'vue-virtual-scroller' の宣言ファイルが見つかりませんでした。」 というエラーになる場合はTypeScriptがvue-virtual-scrollerの型定義ファイルを見つけられないことが原因です。
この場合はtypesフォルダ内に新しいファイルを作成します。
例: types/vue-virtual-scroller.d.ts

declare module 'vue-virtual-scroller';

pages/virtualScroll.vue

<template>
  <ClientOnly>
    <!-- 1920×1080を基準に拡縮するラッパー -->
    <div class="scale-wrapper" :style="wrapperStyle">
      <div class="scroll-x-wrapper">
        <!-- 固定ヘッダー -->
        <div class="header-row">
          <div
            v-for="(col, idx) in columns"
            :key="col.key"
            class="header-cell"
            draggable="true"
            @dragstart="onDragStart(idx)"
            @dragover.prevent
            @drop="onDrop(idx)"
            @click="sortBy(col.key)"
          >
            {{ col.label }}
            <span v-if="sortKey === col.key">
              {{ sortAsc ? '▲' : '▼' }}
            </span>
          </div>
        </div>

        <!-- 仮想スクロール本体 -->
        <RecycleScroller
          class="body-wrapper"
          :items="sortedItems"
          :item-size="rowHeight"
          key-field="id"
          :buffer="400"
        >
          <template #default="{ item }">
            <div
              class="grid-row"
              :class="[
                { selected: selectedRowId === item.id },
                item._rowIndex % 2 === 0 ? 'even-row' : 'odd-row'
              ]"
              @click="selectRow(item)"
            >
              <div
                v-for="col in columns"
                :key="col.key"
                class="grid-cell"
              >
                {{ item[col.key] }}
              </div>
            </div>
          </template>
        </RecycleScroller>
      </div>
    </div>
  </ClientOnly>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

type Item = {
  id: number
  _rowIndex: number
  name: string
  age: number
  city: string
  col5: string
  col6: string
  col7: string
  col8: string
  col9: string
}

type Column = { key: keyof Item; label: string }

const columns = ref<Column[]>([
  { key: 'id', label: 'ID' },
  { key: 'name', label: 'Name' },
  { key: 'age', label: 'Age' },
  { key: 'city', label: 'City' },
  { key: 'col5', label: 'Col5' },
  { key: 'col6', label: 'Col6' },
  { key: 'col7', label: 'Col7' },
  { key: 'col8', label: 'Col8' },
  { key: 'col9', label: 'Col9' },
])

const rows: Item[] = Array.from({ length: 5000 }, (_, i) => ({
  id: i + 1,
  _rowIndex: i,
  name: `Name-${i + 1}`,
  age: 20 + (i % 60),
  city: `City-${i % 100}`,
  col5: `V5-${i}`,
  col6: `V6-${i}`,
  col7: `V7-${i}`,
  col8: `V8-${i}`,
  col9: `V9-${i}`,
}))

// ソート関連
const sortKey = ref<keyof Item>('id')
const sortAsc = ref(true)
function sortBy(key: keyof Item) {
  if (sortKey.value === key) sortAsc.value = !sortAsc.value
  else {
    sortKey.value = key
    sortAsc.value = true
  }
}
const sortedItems = computed(() => {
  const k = sortKey.value
  const sorted = [...rows].sort((a, b) => {
    const A = a[k]
    const B = b[k]
    return (A > B ? 1 : A < B ? -1 : 0) * (sortAsc.value ? 1 : -1)
  })
  // ソート結果に基づいて行番号を再設定
  return sorted.map((item, i) => ({ ...item, _rowIndex: i }))
})

// 列ドラッグ
let dragIndex = -1
function onDragStart(idx: number) {
  dragIndex = idx
}
function onDrop(idx: number) {
  if (dragIndex < 0 || dragIndex === idx) return
  const moved = columns.value.splice(dragIndex, 1)[0]
  columns.value.splice(idx, 0, moved)
  dragIndex = -1
}

// 行選択
const selectedRowId = ref<number | null>(null)
function selectRow(item: Item) {
  selectedRowId.value = item.id
  alert(JSON.stringify(item, null, 2)) // 選択行データをポップアップ表示
}

// -------------------- 追加: ウィンドウサイズ取得 (SSR対応) --------------------
const windowWidth = ref(1920)
const windowHeight = ref(1080)

function handleResize() {
  if (process.client) {
    windowWidth.value = window.innerWidth
    windowHeight.value = window.innerHeight
  }
}

onMounted(() => {
  if (process.client) {
    windowWidth.value = window.innerWidth
    windowHeight.value = window.innerHeight
    window.addEventListener('resize', handleResize)
  }
})

onUnmounted(() => {
  if (process.client) {
    window.removeEventListener('resize', handleResize)
  }
})

// 1920×1080を基準にスケーリング
const wrapperStyle = computed(() => {
  const scaleW = windowWidth.value / 1920
  const scaleH = windowHeight.value / 1080
  const scale = Math.min(scaleW, scaleH)
  return {
    width: '1920px',
    height: '1080px',
    transform: `scale(${scale})`,
    transformOrigin: 'top left',
  }
})
// ------------------------------------------------------------------------------

const rowHeight = 30
</script>

<style scoped>
/* スケーリング用ラッパー */
.scale-wrapper {
  width: 100vw;
  height: 100vh;
  overflow: auto;
  background: #222;
}

/* 既存スタイル */
.scroll-x-wrapper {
  overflow-x: auto;
  border: 1px solid #ccc;
  max-height: 600px;
  position: relative;
  min-width: max-content;
}

/* ヘッダー固定 */
.header-row {
  display: flex;
  position: sticky;
  top: 0;
  z-index: 10;
  background: #f0f0f0;
}
.header-cell {
  flex: 0 0 120px;
  padding: 0 8px;
  height: 30px;
  line-height: 30px;
  border: 1px solid #ccc;
  text-align: left;
  font-weight: bold;
  user-select: none;
  white-space: nowrap;
  cursor: pointer;
}

/* 仮想スクロール本体 */
.body-wrapper {
  height: 560px;
  color: #ffffff;
  overflow-y: auto;
}

/* 行とセル */
.grid-row {
  display: flex;
  cursor: pointer;
}
.grid-cell {
  flex: 0 0 120px;
  padding: 0 8px;
  border: 1px solid #ccc;
  height: 30px;
  line-height: 30px;
  white-space: nowrap;
}

/* 選択行の背景色 */
.selected {
  background-color: #cce5ff !important;
  color: #000;
}

/* 偶数・奇数行の背景色 */
.even-row {
  background-color: #2a2a2a;
}
.odd-row {
  background-color: #333333;
}

/* ホバー時 */
.grid-row:hover {
  background-color: #444444;
}
</style>

仮想グリッドはなかなか奥が深いですが、使いこなせると非常に強力な機能だと実感できます。
今回はここで終了ですが、今回のサンプルプログラムは理解できましたか?

仮想スクロールのサンプルプログラムを覚えられましたか?

体感的にスピードを感じられるほど効果が期待できるので、興味ある方はいろいろ試してみてくださいね!

管理人情報