バーチャルスクロール

※この記事は2025/09/15時点の情報です。

前回に引き続き「vue-virtual-scroller」ライブラリを使用してバーチャルスクロールを実装してみました。
今回はTABLEタグの代わりにDIVタグを使用し、CSSでTABLEと同じ見た目にしています。
また、ヘッダーは固定とし、行を選択するとその行のデータをポップアップで表示させています。

「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>
    <div class="scroll-x-wrapper">
      <!-- 🔍 検索入力フォーム -->
      <div class="filter-bar">
        <input
          v-model="searchQuery"
          type="text"
          placeholder="キーワードで検索"
          class="filter-input"
        />
      </div>

      <!-- 固定ヘッダー -->
      <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 }"
            @click="selectRow(item)"
          >
            <div
              v-for="col in columns"
              :key="col.key"
              class="grid-cell"
            >
              {{ item[col.key] }}
            </div>
          </div>
        </template>
      </RecycleScroller>
    </div>
  </ClientOnly>
</template>

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

type Item = {
  id: 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,
  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 searchQuery = ref('')

const filteredItems = computed(() => {
  const q = searchQuery.value.trim().toLowerCase()
  if (!q) return rows
  return rows.filter(item =>
    Object.values(item).some(v =>
      String(v).toLowerCase().includes(q)
    )
  )
})

// ソート
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
  return [...filteredItems.value].sort((a, b) => {
    const A = a[k]
    const B = b[k]
    return (A > B ? 1 : A < B ? -1 : 0) * (sortAsc.value ? 1 : -1)
  })
})

// 列ドラッグ
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))
}

const rowHeight = 30
</script>

<style scoped>
.scroll-x-wrapper {
  overflow-x: auto;
  border: 1px solid #ccc;
  max-height: 600px;
  position: relative;
  min-width: max-content;
}

/* 🔍 検索バー */
.filter-bar {
  padding: 4px;
  background: #fafafa;
  border-bottom: 1px solid #ccc;
}
.filter-input {
  width: 200px;
  padding: 4px 8px;
  font-size: 14px;
}

/* ヘッダー固定 */
.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;
  font-weight: bold;
  user-select: none;
  white-space: nowrap;
  cursor: pointer;
}

/* 仮想スクロール本体 */
.body-wrapper {
  height: 560px;
  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;
}
</style>

仮想スクロールで大量データでも高速に表示することができ、
ドラッグ&ドロップで列の入れ替えもできるようになっていますので実用性が高いのではないかと思います。
今回はここで終了ですが、今回のサンプルプログラムは理解できましたか?

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

他にも様々な設定ができるので興味ある方はいろいろ試してみてくださいね!

管理人情報