🌊

Vue.js + ブラウザのネイティブ APIを用いたテーブル表示の高速化

に公開

こんにちは、booostのOTAです。

今回は、Vue.js + ブラウザのネイティブ API Intersection Observer を用いたテーブル表示の高速化についてご紹介します。

Intersection Observer API - Web APIs | MDN

Vue.js による大量テーブル描画がボトルネックに

あるページの初期表示に 15 秒以上かかるという課題が発生していました。調査の結果、バックエンドのレスポンスは 1 秒未満と非常に速いにも関わらず、フロントエンド側で 1000 行以上のデータを Vue.js により一括で描画していたことが主な原因であると判明しました。

Vue.js のリアクティブシステムは、すべての DOM 要素に対して状態の追跡と更新を行うため、初期描画に極端な時間がかかっていたのです。

Intersection Observer + slice による改善策

パフォーマンス改善のアプローチとして、フロントエンド側で以下のような「疑似レイジーローディング」を実装しました。(一般公開用に一部抽象化)

<template>
  <tbody>
    <tr
      v-for="(element, index) in computedList"
      :key="index"
      :ref="isLazyLoadingActive && index === computedList.length - 1 ? 'lazyRow' : null"
    >
      <slot name="data" :data="element" :index="index" />
    </tr>
  </tbody>
</template>

<script>
export default {
  name: 'LazyTBody',
  inject: ['useTBodyLazyLoading'],
  props: {
    list: { type: Array, required: true, default: () => [] },
    useLazyLoading: { type: Boolean, default: false },
    initialCount: { type: Number, default: 50 },
    loadCount: { type: Number, default: 30 }
  },
  data () {
    return {
      displayCount: this.initialCount,
      observer: null
    }
  },
  computed: {
    isLazyLoadingActive () {
      return this.useTBodyLazyLoading || this.useLazyLoading
    },
    computedList () {
      return this.isLazyLoadingActive
        ? this.list.slice(0, this.displayCount)
        : this.list
    }
  },
  mounted () {
    this.setupObserver()
  },
  updated () {
    this.$nextTick(() => {
      if (this.$refs.lazyRow && !this.observer) {
        this.setupObserver()
      }
    })
  },
  beforeUnmount () {
    if (this.observer) this.observer.disconnect()
  },
  methods: {
    loadMore () {
      if (this.displayCount < this.list.length) {
        this.displayCount += this.loadCount
        if (this.observer) {
          this.observer.disconnect()
          this.$nextTick(this.setupObserver)
        }
      }
    },
    setupObserver () {
      if (!this.$refs.lazyRow) return
      this.observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) this.loadMore()
        })
      })
      this.observer.observe(this.$refs.lazyRow)
    }
  }
}
</script>
  • 初期表示では JavaScript の slice 関数を用いて、先頭から一定行数(例:50 行)のみを描画
  • スクロールを検知するために、ブラウザのネイティブ API である Intersection Observer API を活用
  • テーブル末尾の要素がビューポートに入ったことを検出したタイミングで、描画する行数を props で受け取った単位(例:50 行)ずつ増加
  • Vue.js のリアクティビティを活かし、自然な形でテーブルが拡張されていくようにレンダリング

この仕組みにより、描画対象の行数を柔軟に制御できるようになり、異なるテーブル構成や利用シーンにも適用可能となりました。

パフォーマンス改善の効果

この改善を実施した結果、初期描画にかかっていた時間は 15 秒以上から 2 秒未満へと大幅に短縮されました。すべてのデータを一括で描画せず、スクロールに応じて順次表示することで、DOM 操作のコストを抑えつつ、ユーザーにとってもスムーズな閲覧体験を実現しました。

結びに

フロントエンドのレンダリング処理がボトルネックになるケースは意外と多く、特に Vue.js のようなリアクティブなフレームワークでは、データ量が増えるほどパフォーマンスへの影響が顕著になります。

今回のように Intersection Observer API と slice を組み合わせることで、シンプルかつ柔軟に描画処理を最適化することが可能です。もし同様の課題に直面している場合は、このアプローチが解決のヒントになるかもしれません。

最後までお読みいただき、ありがとうございました。

Booost

Discussion