バーチャルスクロール

※この記事は2024/11/30時点の情報です。

リストやテーブルなどのコンポーネントに大量のデータを表示すると結構時間が掛かりますよね?
バーチャルスクロールは、画面に表示される部分だけをレンダリングし、見えない部分は破棄または非表示にすることでパフォーマンスを最適化する技術です。

ユーザーがスクロールするたびにスクロール位置を計算し、新たに表示すべきアイテムだけをDOMに追加、既に見えなくなったアイテムを削除します。
これにより、ユーザーには全データが表示されているように見えるという仕組みです。とは言え、自分で実装するのは大変なのでライブラリを使用します。
今回は「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

■ RecycleScroller
このコンポーネントは、表示可能なアイテムのみをDOMにレンダリングし、見えない部分は破棄することでパフォーマンスを向上させます。

■ item-size
各行の高さを指定することで、正確なスクロール位置を計算します。

■ items
大量のデータを渡すために、10000件のダミーデータを作成しています。

■ filteredItems
filter()メソッドを利用してデータを絞り込めるようにしています。

<template>
  <div>
    <!-- 絞り込み用の入力フィールド -->
    <input
      v-model="searchQuery"
      type="text"
      placeholder="検索条件を入力"
      class="search-input"
    />

    <!-- 横スクロールを許可したテーブルコンテナ -->
    <div class="table-wrapper" ref="tableWrapper" @scroll="onHorizontalScroll">
      <div class="virtual-table">
        <RecycleScroller
          :items="filteredItems"
          :item-size="50"
          class="table-container"
          direction="vertical"
          ref="virtualScroller"
          @scroll="onVerticalScroll"
        >
          <template #default="{ item, index }">
            <div class="table-row" :key="index">
              <div class="table-cell" v-for="n in 20" :key="n">
                {{ item.name }} - {{ n }}
              </div>
            </div>
          </template>
        </RecycleScroller>
      </div>
      <!-- 縦スクロールバー -->
      <div ref="customScrollBar" class="vertical-scroll-bar" @mousedown="onMouseDown">
        <div ref="scrollThumb" class="scroll-thumb" :style="thumbStyle"></div>
      </div>
    </div>
  </div>
</template>

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

const items = Array.from({ length: 10000 }, (_, i) => ({
  id: i + 1,
  name: `Item ${i + 1}`,
  value: Math.random().toFixed(2),
}));

const searchQuery = ref('');
const filteredItems = computed(() => {
  if (!searchQuery.value) {
    return items;
  }
  return items.filter((item) =>
    item.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  );
});

const tableWrapper = ref<HTMLDivElement | null>(null);
const customScrollBar = ref<HTMLDivElement | null>(null);
const scrollThumb = ref<HTMLDivElement | null>(null);
const virtualScroller = ref<any>(null);

const thumbStyle = ref({
  height: '50px',
  top: '0px',
});

let isDragging = false;
let startY = 0;
let startTop = 0;

const updateThumbPosition = () => {
  if (!virtualScroller.value || !customScrollBar.value) return;

  const scrollHeight = virtualScroller.value.$el.scrollHeight;
  const clientHeight = virtualScroller.value.$el.clientHeight;
  const thumbHeight = Math.max((clientHeight / scrollHeight) * clientHeight, 20);
  const scrollTop = virtualScroller.value.$el.scrollTop;

  thumbStyle.value = {
    height: `${thumbHeight}px`,
    top: `${(scrollTop / scrollHeight) * clientHeight}px`,
  };
};

const onVerticalScroll = () => {
  updateThumbPosition();
};

const onHorizontalScroll = () => {
  if (tableWrapper.value && customScrollBar.value) {
    const scrollLeft = tableWrapper.value.scrollLeft;
    customScrollBar.value.style.right = `-${scrollLeft}px`;
  }
};

const onMouseDown = (event: MouseEvent) => {
  isDragging = true;
  startY = event.clientY;
  startTop = parseFloat(thumbStyle.value.top);
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

const onMouseMove = (event: MouseEvent) => {
  if (!isDragging || !virtualScroller.value) return;

  const deltaY = event.clientY - startY;
  const scrollHeight = virtualScroller.value.$el.scrollHeight;
  const clientHeight = virtualScroller.value.$el.clientHeight;
  const newTop = Math.min(
    clientHeight - parseFloat(thumbStyle.value.height),
    Math.max(0, startTop + deltaY)
  );
  thumbStyle.value.top = `${newTop}px`;

  const scrollRatio = newTop / clientHeight;
  virtualScroller.value.$el.scrollTop = scrollRatio * scrollHeight;
};

const onMouseUp = () => {
  isDragging = false;
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
};

onMounted(() => {
  updateThumbPosition();
});
</script>

<style scoped>
.search-input {
  margin-bottom: 10px;
  padding: 8px;
  width: 100%;
  box-sizing: border-box;
}

.table-wrapper {
  width: 100%;
  overflow: auto;
  border: 1px solid #ccc;
  position: relative;
}

.virtual-table {
  min-width: 2000px;
}

.table-container {
  height: 400px;
  overflow: hidden; /* 標準スクロールバーを隠す */
}

.table-row {
  display: flex;
  border-bottom: 1px solid #e0e0e0;
}

.table-cell {
  flex: 1 0 150px;
  padding: 0 8px;
  border-right: 1px solid #ddd;
  text-align: left;
}

.table-cell:last-child {
  border-right: none;
}

.vertical-scroll-bar {
  position: absolute;
  top: 0;
  right: 0;
  width: 10px;
  height: 100%;
  background: rgba(0, 0, 0, 0.1);
  cursor: pointer;
}

.scroll-thumb {
  position: absolute;
  width: 100%;
  background: rgba(0, 0, 0, 0.4);
  border-radius: 5px;
}
</style>

以下が実行結果です。とても高速に表示されました!

バーチャルスクロールで高速化

「vue-virtual-scroller」ライブラリを利用すると非常に簡単に実装できました! 大量データが原因でページが表示されるのが遅くて悩んでいる方は是非お試しください。
今回はここで終了ですが、今回のサンプルプログラムは理解できましたか?

大量データを高速表示するプログラムを覚えられましたか?

覚えていて損は無いと思いますので、いろいろ自分で試してみて少しずつ理解を深めてみてくださいね!

管理人情報