🐱

Intersection Observer APIを使ったVueのカスタムディレクティブ

2023/02/06に公開

はじめまして。
株式会社ココナラ フロントエンド開発グループのいっちーです。
フロントエンド開発グループの投稿としては最初のブログとなるでしょうか。

フロントエンドの開発をしていると、特定の要素がビューポートに入ってきた際に画像の読み込みの開始やAPIの呼び出しなど何かしらの処理を実行したいケースがしばしば出てくるかと思います。
そんなときに利用されるWeb APIとしてIntersection Observer APIがありますが、それを利用したVueのカスタムディレクティブの実装例をご紹介します。

Intersection Observer APIとは

MDNによれば以下のように説明されています。

交差オブザーバー API (Intersection Observer API) は、ターゲットとなる要素が、祖先要素または文書の最上位のビューポートと交差する変化を非同期的に監視する方法を提供します。

引用: 交差オブザーバー API - Web API | MDN

端的にいうと、対象となる要素がオプションで指定された祖先要素やビューポートが占める領域への出入りを検知するためのAPIということになります。
(本記事では簡略化のためビューポートの前提で説明いたします。)

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

カスタムディレクティブとは

Vue.jsではv-ifv-forなどの標準ディレクティブの他に直接DOMに作用するようなカスタムディレクティブを定義することが可能です。

https://v3.ja.vuejs.org/guide/custom-directive.html

カスタムディレクティブの完成形イメージ

以下が今回作成するカスタムディレクティブの完成系イメージです。

<template>
  <div
    v-intersect
    @intersect="onIntersect"
  >
    Element
  </div>
</template>

<script lang="ts">
export default {
  setup() {
    const onIntersect = () => {
      console.log('ビューポート内に入りました')
    }

    return {
      onIntersect
    }
  }
}
</script>

v-intersectが作成したカスタムディレクティブで、これを指定することで要素がビューポートへ入ってきた際に@intersectイベントを発火できるようになります。
また、下記のようなケースにも対応できるように修飾子や値の指定ができるようにもしてみたいと思います。

  • イベントが1度だけ発火するようにしたい
  • ビューポートから出たときにもイベント発火するようにしたい
  • ビューポートから出たときだけイベント発火するようにしたい
  • 要素がビューポートに入っている領域の割合によってイベント発火のタイミングを設定したい

カスタムディレクティブの実装

以下がカスタムディレクティブ実装の全容になります。

import Vue, { VNode } from 'vue'

/**
 * intersectイベント発火
 * @param vnode 対象要素の仮想ノード
 * @param entry 対象要素の交差状態
 */
const emitIntersectEvent = (vnode: VNode, entry: IntersectionObserverEntry) => {
  const handlers = vnode.data?.on
  if (handlers && 'intersect' in handlers) {
    const handler = handlers['intersect']

    if (typeof handler === 'function') {
      handler(entry)
    } else {
      handler.forEach(f => f(entry))
    }
  }
}

/**
 * 要素の監視解除
 * @param el 対象要素
 */
const disconnect = (el: HTMLElement) => {
  const id = el.dataset.intersectionObserverId
  if (id) {
    observers[id]?.disconnect()
    delete observers[id]
  }
  el.removeAttribute('data-intersection-observer-id')
}

/** IntersectionObserverインスタンスの管理オブジェクト */
const observers: { [key: string]: IntersectionObserver } = {}
let currentId = 0

Vue.directive('intersect', {
  inserted(el, binding, vnode) {
    const { once, each, out } = binding.modifiers
    const observerOptions: IntersectionObserverInit = typeof binding.value === 'object' ? binding.value : {}

    /** イベント発火判定 */
    const isFiring = (entry: IntersectionObserverEntry) => {
      if (each) return true

      if (out) {
        return !entry.isIntersecting
      }

      return entry.isIntersecting
    }

    // IntersectionObserverインスタンスの生成
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (isFiring(entry)) {
          // イベント発火
          emitIntersectEvent(vnode, entry)

          if (once) {
            disconnect(el)
          }
        }
      })
    }, observerOptions)

    // IntersectionObserverインスタンスの管理
    const id = String(currentId++)
    observers[id] = observer
    el.dataset.intersectionObserverId = id

    // 対象要素の監視
    observer.observe(el)
  },
  unbind(el) {
    disconnect(el)
  }
})

insertedフックの実装

insertedフックではIntersectionObserverインスタンスの生成と要素の監視を開始します。

まず初めにディレクティブに指定された修飾子と値を取得します。
onceeachoutを指定することが可能でそれぞれ下記のような機能を持ちます。

修飾子 機能
once 1度だけイベントを発火する
each ビューポートの領域へ入ったときと出たときの両方イベントを発火する
out ビューポートの領域から出たときのみイベントを発火する

eachおよびoutの指定がない場合はデフォルトでビューポートへ入ったときのみイベントを発火します。

ディレクティブの値にはIntersectionObserverコンストラクタの第2引数であるオプションを渡せるようになっており、イベント発火のタイミングを「要素の半分がビューポートに入ったとき」や「要素がビューポートに入る50px手前」などにコントロールすることができます。

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API#交差オブザーバーの作成

次にIntersectionObserverのインスタンスを生成します。コールバック関数内ではemitIntersectEvent関数を実行してintersectイベントに登録されたイベントハンドラを実行しています。
イベントハンドラには引数としてIntersectionObserverEntryを渡すことでイベントハンドラ側でその時の要素の交差状態を受け取れるようになっています。

https://developer.mozilla.org/ja/docs/Web/API/IntersectionObserverEntry

最後にIntersectionObserver.observeで要素の監視を開始します。
このとき事前にIntersectionObserverインスタンスを管理オブジェクトに追加し、払い出したIDを要素のdata属性に設定しておきます。これはunbindフックの際に要素を監視対象から外すのにIntersectionObserverインスタンスを紐付けしておく必要があるためです。

unbindフックの実装

カスタムディレクティブのunbindフックではdisconnect関数を実行して要素の監視解除を行います。

カスタムディレクティブの使用例

作成したカスタムディレクティブの使用例をいくつかご紹介します。

修飾子、値を指定しない場合

<template>
  <div
    v-intersect
    @intersect="onIntersect"
  >
    Element
  </div>
</template>

<script lang="ts">
export default {
  setup() {
    const onIntersect = () => {
      console.log('ビューポート内に入りました')
    }

    return {
      onIntersect
    }
  }
}
</script>

要素がビューポート内に入ったときのみイベント発火します。
v-intersectディレクティブと@intersectイベントへのイベントハンドラを指定するだけのシンプルな実装となっています。

イベントを1回のみ発火させる場合

<template>
  <div
    v-intersect.once
    @intersect="onIntersect"
  >
    Element
  </div>
</template>

<script lang="ts">
export default {
  setup() {
    let count = 0
    const onIntersect = () => {
      // 2回目以降は呼ばれることがない
      console.log(`${++count}回ビューポート内に入りました`)
    }

    return {
      onIntersect
    }
  }
}
</script>

once修飾子を付加して1度だけイベントが発火するようにしており、onIntersectは2回目以降は呼ばれることがありません。

ビューポートに入ったときと出たときの両方イベント発火させる場合

<template>
  <div
    v-intersect.each
    @intersect="onIntersect"
  >
    Element
  </div>
</template>

<script lang="ts">
export default {
  setup() {
    const onIntersect = (entry: IntersectionObserverEntry) => {
      if (entry.isIntersecting) {
        console.log('ビューポート内に入りました')
      } else {
        console.log('ビューポート外に出ました')
      }
    }

    return {
      onIntersect
    }
  }
}
</script>

each修飾子を付加してビューポートに入ったときと出たときの両方でイベント発火するようにしています。
イベントハンドラは引数として受け取ったIntersectionObserverEntryから要素がビューポート内外のどちらにあるのかが判別できます。

要素の50%がビューポートに入ったときにイベント発火させる場合

<template>
  <div
    v-intersect="{ threshold: 0.5 }"
    @intersect="onIntersect"
  >
    Element
  </div>
</template>

<script lang="ts">
export default {
  setup() {
    const onIntersect = (entry: IntersectionObserverEntry) => {
      const ratio = entry.intersectionRatio
      console.log(`要素の${ratio * 100}%がビューポート内に入りました`) // -> 要素の50%がビューポート内に入りました
    }

    return {
      onIntersect
    }
  }
}
</script>

ディレクティブの値にIntersectionObserverコンストラクタのオプションを指定しています。
ここでは{ threshold: 0.5 }を指定することで要素の50%がビューポートに入ったときにイベント発火するようにしています。
IntersectionObserverEntry.intersectionRatioから要素がどれくらいの割合でビューポートと交差しているかを取得できます。

最後に

今回はIntersection Observer APIを使ったVueのカスタムディレクティブの実装例についてご紹介いたしました。
このようにWeb APIを使って共通のVueのカスタムディレクティブを作成することで開発の効率化を図ることができるかと思います。

本記事がVue.jsで開発されている方のご参考になれば幸いです。


本記事をご覧になって興味を持たれた方、もっと詳しい話を聞いてみたい方はぜひ下記のカジュアル面談応募フォームよりご応募ください!
https://open.talentio.com/r/1/c/coconala/pages/70417

募集求人についてはこちらからご確認ください。
https://coconala.co.jp/recruit/engineer

私の所属するフロントエンド開発グループでもエンジニアを募集中です!ご応募お待ちしております!
https://open.talentio.com/r/1/c/coconala/pages/49717

Discussion