バーチャルスクロール
※この記事は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>
仮想グリッドはなかなか奥が深いですが、使いこなせると非常に強力な機能だと実感できます。
今回はここで終了ですが、今回のサンプルプログラムは理解できましたか?
体感的にスピードを感じられるほど効果が期待できるので、興味ある方はいろいろ試してみてくださいね!