🚀

Vue3のTeleportでモーダルやツールチップを簡単に実装する方法を調べてみた

に公開

Vue3のTeleportでモーダルやツールチップを簡単に実装する方法

Vue3のTeleportは、コンポーネントのテンプレートをDOMの別の場所に「テレポート」させる機能です。モーダル、ツールチップ、通知などの実装において、z-indexの問題やスタイリングの複雑さを解決してくれる強力な機能です。

Teleportとは?

Teleportは、Vue3で導入された機能で、コンポーネントのテンプレートの一部を、そのコンポーネントのDOMツリーの外側の任意の場所にレンダリングできます。

なぜTeleportが必要なのか?

従来のVueアプリケーションでは、コンポーネントのテンプレートは必ずそのコンポーネントのDOMツリー内にレンダリングされます。しかし、以下のような場面で問題が発生することがありました:

  1. z-indexの問題: モーダルやツールチップが親要素のoverflow: hiddenz-indexの影響を受ける
  2. スタイリングの複雑さ: 深いネストの中にある要素のスタイリングが困難
  3. アクセシビリティ: スクリーンリーダーが適切にコンテンツを認識できない場合がある

Teleportはこれらの問題を解決し、より柔軟なDOM構造を可能にします。

Vue2との比較

Vue2では、同様の機能を実現するために以下のような方法が使われていました:

// Vue2での実装例(Portal Vueライブラリ使用)
import { Portal } from 'portal-vue'

export default {
  components: { Portal },
  template: `
    <div>
      <Portal to="modal-container">
        <Modal v-if="showModal" />
      </Portal>
    </div>
  `
}

Vue3のTeleportは、このような外部ライブラリに依存せずに、フレームワークレベルでサポートされています。

基本的な使い方

<template>
  <div>
    <h1>メインコンテンツ</h1>
    <button @click="showModal = true">モーダルを開く</button>
    
    <!-- Teleportでbodyに直接レンダリング -->
    <Teleport to="body">
      <div v-if="showModal" class="modal">
        <div class="modal-content">
          <h2>モーダルタイトル</h2>
          <p>モーダルの内容です</p>
          <button @click="showModal = false">閉じる</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showModal = ref(false)
</script>

<style scoped>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
  max-width: 500px;
}
</style>

Teleportの内部動作

Teleportは内部的に以下のような処理を行います:

  1. 仮想DOMの管理: Teleportされた要素は、元のコンポーネントの仮想DOMツリー内で管理されます
  2. DOMの移動: 実際のDOM要素は指定されたターゲットに移動されます
  3. イベントの保持: イベントハンドラーやリアクティブなデータバインディングは維持されます
  4. ライフサイクルの管理: コンポーネントのライフサイクルは元のコンポーネントに従います
<template>
  <div>
    <!-- このコンポーネントのデータにアクセス可能 -->
    <Teleport to="body">
      <div class="teleported-content">
        <p>カウント: {{ count }}</p>
        <button @click="increment">カウントアップ</button>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

ターゲットの指定方法

Teleportのtoプロパティには、以下のような方法でターゲットを指定できます:

<template>
  <div>
    <!-- 1. CSSセレクター -->
    <Teleport to="#modal-container">
      <Modal />
    </Teleport>
    
    <!-- 2. クラスセレクター -->
    <Teleport to=".notification-area">
      <Notification />
    </Teleport>
    
    <!-- 3. タグセレクター -->
    <Teleport to="body">
      <Tooltip />
    </Teleport>
    
    <!-- 4. 複雑なセレクター -->
    <Teleport to="main .content-area">
      <FloatingButton />
    </Teleport>
  </div>
</template>

実用的な例:ツールチップコンポーネント

Teleportを使った再利用可能なツールチップコンポーネントを作成してみましょう。

<!-- Tooltip.vue -->
<template>
  <div class="tooltip-container" @mouseenter="show" @mouseleave="hide">
    <slot />
    
    <Teleport to="body">
      <div 
        v-if="visible" 
        class="tooltip"
        :style="tooltipStyle"
      >
        {{ text }}
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref, computed, nextTick } from 'vue'

const props = defineProps({
  text: {
    type: String,
    required: true
  },
  position: {
    type: String,
    default: 'top',
    validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value)
  }
})

const visible = ref(false)
const triggerElement = ref(null)

const tooltipStyle = computed(() => {
  if (!triggerElement.value) return {}
  
  const rect = triggerElement.value.getBoundingClientRect()
  const styles = {
    position: 'fixed',
    zIndex: 9999,
    backgroundColor: '#333',
    color: 'white',
    padding: '8px 12px',
    borderRadius: '4px',
    fontSize: '14px',
    pointerEvents: 'none'
  }
  
  switch (props.position) {
    case 'top':
      styles.top = `${rect.top - 40}px`
      styles.left = `${rect.left + rect.width / 2}px`
      styles.transform = 'translateX(-50%)'
      break
    case 'bottom':
      styles.top = `${rect.bottom + 10}px`
      styles.left = `${rect.left + rect.width / 2}px`
      styles.transform = 'translateX(-50%)'
      break
    case 'left':
      styles.top = `${rect.top + rect.height / 2}px`
      styles.left = `${rect.left - 10}px`
      styles.transform = 'translateY(-50%)'
      break
    case 'right':
      styles.top = `${rect.top + rect.height / 2}px`
      styles.left = `${rect.right + 10}px`
      styles.transform = 'translateY(-50%)'
      break
  }
  
  return styles
})

const show = async () => {
  visible.value = true
  await nextTick()
  triggerElement.value = document.querySelector('.tooltip-container')
}

const hide = () => {
  visible.value = false
}
</script>

使用例:

<template>
  <div>
    <Tooltip text="これはツールチップです" position="top">
      <button>ホバーしてください</button>
    </Tooltip>
    
    <Tooltip text="右側に表示されます" position="right">
      <span>右側のツールチップ</span>
    </Tooltip>
  </div>
</template>

<script setup>
import Tooltip from './Tooltip.vue'
</script>

条件付きTeleport

Teleportは条件付きで使用することもできます:

<template>
  <div>
    <button @click="toggleTeleport">
      {{ useTeleport ? '通常レンダリング' : 'Teleport使用' }}
    </button>
    
    <Teleport v-if="useTeleport" to="body">
      <div class="floating-element">
        これはbodyに直接レンダリングされます
      </div>
    </Teleport>
    
    <div v-else class="floating-element">
      これは通常のDOMツリー内にレンダリングされます
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const useTeleport = ref(true)

const toggleTeleport = () => {
  useTeleport.value = !useTeleport.value
}
</script>

複数のTeleport

複数のTeleportを同じターゲットに送ることも可能です:

<template>
  <div>
    <Teleport to="#notifications">
      <div class="notification success">成功メッセージ</div>
    </Teleport>
    
    <Teleport to="#notifications">
      <div class="notification error">エラーメッセージ</div>
    </Teleport>
  </div>
</template>

ベストプラクティス

1. 適切なターゲットの選択

<!-- 良い例:明確なターゲット -->
<Teleport to="#modal-container">
  <Modal />
</Teleport>

<!-- 避けるべき:bodyに直接送る場合の注意 -->
<Teleport to="body">
  <Modal />
</Teleport>

2. アクセシビリティの考慮

<template>
  <Teleport to="body">
    <div 
      v-if="showModal"
      class="modal"
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">モーダルタイトル</h2>
      <p>モーダルの内容</p>
    </div>
  </Teleport>
</template>

3. クリーンアップの処理

<script setup>
import { onUnmounted } from 'vue'

// コンポーネントがアンマウントされる際のクリーンアップ
onUnmounted(() => {
  // 必要に応じてTeleportで作成された要素のクリーンアップ
})
</script>

パフォーマンスの考慮事項

DOM操作の最適化

Teleportは内部的にDOM操作を行いますが、以下の点に注意することでパフォーマンスを最適化できます:

<template>
  <div>
    <!-- 良い例:条件付きでTeleportを使用 -->
    <Teleport v-if="shouldShowModal" to="body">
      <Modal />
    </Teleport>
    
    <!-- 避けるべき:常にTeleportを使用 -->
    <Teleport to="body">
      <Modal v-if="shouldShowModal" />
    </Teleport>
  </div>
</template>

メモリリークの防止

<script setup>
import { ref, onUnmounted } from 'vue'

const showModal = ref(false)

// コンポーネントのアンマウント時にクリーンアップ
onUnmounted(() => {
  // モーダルが開いている場合は閉じる
  if (showModal.value) {
    showModal.value = false
  }
  
  // 必要に応じてTeleportで作成された要素を削除
  const teleportedElements = document.querySelectorAll('[data-teleported]')
  teleportedElements.forEach(el => el.remove())
})
</script>

高度な使用例

動的ターゲットの指定

<template>
  <div>
    <select v-model="targetContainer">
      <option value="body">Body</option>
      <option value="#modal-container">Modal Container</option>
      <option value="#notification-area">Notification Area</option>
    </select>
    
    <Teleport :to="targetContainer">
      <div class="dynamic-content">
        動的にターゲットが変更されるコンテンツ
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const targetContainer = ref('body')
</script>

ネストしたTeleport

<template>
  <div>
    <Teleport to="body">
      <div class="outer-modal">
        <h2>外側のモーダル</h2>
        
        <!-- ネストしたTeleport -->
        <Teleport to="#inner-container">
          <div class="inner-modal">
            <h3>内側のモーダル</h3>
            <p>これは別のコンテナにテレポートされます</p>
          </div>
        </Teleport>
      </div>
    </Teleport>
  </div>
</template>

カスタムフックでのTeleport管理

// composables/useTeleport.js
import { ref, onUnmounted } from 'vue'

export function useTeleport(target = 'body') {
  const isActive = ref(false)
  const teleportTarget = ref(target)
  
  const show = () => {
    isActive.value = true
  }
  
  const hide = () => {
    isActive.value = false
  }
  
  const toggle = () => {
    isActive.value = !isActive.value
  }
  
  const changeTarget = (newTarget) => {
    teleportTarget.value = newTarget
  }
  
  // クリーンアップ
  onUnmounted(() => {
    if (isActive.value) {
      hide()
    }
  })
  
  return {
    isActive,
    teleportTarget,
    show,
    hide,
    toggle,
    changeTarget
  }
}

使用例:

<template>
  <div>
    <button @click="show">モーダルを表示</button>
    
    <Teleport :to="teleportTarget">
      <div v-if="isActive" class="modal">
        <h2>カスタムフックで管理されたモーダル</h2>
        <button @click="hide">閉じる</button>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { useTeleport } from '@/composables/useTeleport'

const { isActive, teleportTarget, show, hide } = useTeleport('body')
</script>

トラブルシューティング

よくある問題と解決方法

1. ターゲット要素が見つからない

<template>
  <div>
    <!-- 問題:ターゲット要素が存在しない -->
    <Teleport to="#non-existent">
      <div>この要素は表示されません</div>
    </Teleport>
    
    <!-- 解決方法:条件付きでTeleportを使用 -->
    <Teleport v-if="targetExists" to="#modal-container">
      <div>この要素は表示されます</div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const targetExists = ref(false)

onMounted(() => {
  // ターゲット要素の存在を確認
  targetExists.value = !!document.querySelector('#modal-container')
})
</script>

2. スタイリングの問題

<template>
  <Teleport to="body">
    <div class="teleported-element">
      <!-- スコープされたスタイルは適用されない -->
      <p class="scoped-style">このスタイルは適用されません</p>
    </div>
  </Teleport>
</template>

<style scoped>
.scoped-style {
  color: red; /* これは適用されない */
}
</style>

<style>
/* グローバルスタイルを使用 */
.teleported-element .scoped-style {
  color: red; /* これは適用される */
}
</style>

3. イベントの伝播問題

<template>
  <div @click="parentClick">
    <Teleport to="body">
      <div @click="teleportedClick">
        <!-- このクリックイベントは親に伝播しない -->
        <button>クリック</button>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
const parentClick = () => {
  console.log('親要素がクリックされました')
}

const teleportedClick = (event) => {
  console.log('テレポートされた要素がクリックされました')
  // 必要に応じて手動でイベントを伝播
  // event.stopPropagation()
}
</script>

注意点

  1. SSRでの使用: TeleportはSSRでも動作しますが、クライアントサイドでのハイドレーション時に注意が必要です。

  2. ターゲット要素の存在: ターゲット要素が存在しない場合、Teleportは無効になります。

  3. スタイリング: Teleportされた要素は、元のコンポーネントのスコープ外にあるため、CSSスコープの影響を受けません。

  4. イベントの伝播: Teleportされた要素のイベントは、元のコンポーネントのDOMツリーに伝播しません。

  5. アクセシビリティ: キーボードナビゲーションやフォーカス管理に注意が必要です。

実践的なプロジェクト例

通知システムの実装

<!-- components/NotificationSystem.vue -->
<template>
  <div>
    <button @click="addNotification('success', '操作が完了しました')">
      成功通知
    </button>
    <button @click="addNotification('error', 'エラーが発生しました')">
      エラー通知
    </button>
    
    <!-- 通知をbodyにテレポート -->
    <Teleport to="body">
      <div class="notification-container">
        <TransitionGroup name="notification" tag="div">
          <div
            v-for="notification in notifications"
            :key="notification.id"
            :class="['notification', `notification-${notification.type}`]"
          >
            <span>{{ notification.message }}</span>
            <button @click="removeNotification(notification.id)">×</button>
          </div>
        </TransitionGroup>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const notifications = ref([])
let nextId = 1

const addNotification = (type, message) => {
  const notification = {
    id: nextId++,
    type,
    message,
    timestamp: Date.now()
  }
  
  notifications.value.push(notification)
  
  // 5秒後に自動削除
  setTimeout(() => {
    removeNotification(notification.id)
  }, 5000)
}

const removeNotification = (id) => {
  const index = notifications.value.findIndex(n => n.id === id)
  if (index > -1) {
    notifications.value.splice(index, 1)
  }
}
</script>

<style>
.notification-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 10000;
}

.notification {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  margin-bottom: 8px;
  border-radius: 4px;
  min-width: 300px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.notification-success {
  background-color: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}

.notification-error {
  background-color: #f8d7da;
  color: #721c24;
  border: 1px solid #f5c6cb;
}

.notification-enter-active,
.notification-leave-active {
  transition: all 0.3s ease;
}

.notification-enter-from {
  opacity: 0;
  transform: translateX(100%);
}

.notification-leave-to {
  opacity: 0;
  transform: translateX(100%);
}
</style>

モーダル管理システム

<!-- composables/useModal.js -->
import { ref, reactive } from 'vue'

const modals = reactive(new Map())

export function useModal(id) {
  const isOpen = ref(false)
  
  const open = () => {
    isOpen.value = true
    modals.set(id, true)
    document.body.style.overflow = 'hidden'
  }
  
  const close = () => {
    isOpen.value = false
    modals.delete(id)
    if (modals.size === 0) {
      document.body.style.overflow = ''
    }
  }
  
  const toggle = () => {
    if (isOpen.value) {
      close()
    } else {
      open()
    }
  }
  
  return {
    isOpen,
    open,
    close,
    toggle
  }
}
<!-- components/Modal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div v-if="isOpen" class="modal-overlay" @click="close">
        <div class="modal-content" @click.stop>
          <div class="modal-header">
            <h2>{{ title }}</h2>
            <button class="close-button" @click="close">×</button>
          </div>
          <div class="modal-body">
            <slot />
          </div>
          <div class="modal-footer">
            <slot name="footer">
              <button @click="close">閉じる</button>
            </slot>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
import { useModal } from '@/composables/useModal'

const props = defineProps({
  id: {
    type: String,
    required: true
  },
  title: {
    type: String,
    default: 'モーダル'
  }
})

const { isOpen, close } = useModal(props.id)
</script>

<style>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  max-height: 80vh;
  overflow-y: auto;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px;
  border-bottom: 1px solid #eee;
}

.modal-body {
  padding: 20px;
}

.modal-footer {
  padding: 20px;
  border-top: 1px solid #eee;
  text-align: right;
}

.close-button {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
}

.modal-enter-active,
.modal-leave-active {
  transition: opacity 0.3s ease;
}

.modal-enter-from,
.modal-leave-to {
  opacity: 0;
}
</style>

まとめ

Vue3のTeleportは、モーダル、ツールチップ、通知などの実装において非常に便利な機能です。DOMの構造を柔軟に制御でき、z-indexの問題やスタイリングの複雑さを解決してくれます。

主要なポイント

  1. DOMの柔軟性: コンポーネントのテンプレートを任意の場所にレンダリング可能
  2. パフォーマンス: 適切に使用することで、DOM操作を最適化
  3. 保守性: 再利用可能なコンポーネントの作成が容易
  4. アクセシビリティ: 適切な実装により、ユーザビリティを向上

実装時の注意点

  • ターゲット要素の存在確認
  • スタイリングの適切な管理
  • メモリリークの防止
  • イベントの伝播の理解

適切に使用することで、より保守性の高いコンポーネントを作成できるでしょう。ぜひ実際のプロジェクトで試してみてください!

GitHubで編集を提案

Discussion