T-CREATOR

Vue 3 の Teleport と Suspense 活用ガイド

Vue 3 の Teleport と Suspense 活用ガイド

Vue 3 で新たに追加された Teleport と Suspense は、モダンな Web アプリケーション開発において非常に重要な機能です。これらの機能を理解し活用することで、より良いユーザー体験を提供できるようになります。

従来の Vue 2 では、モーダルやツールチップの実装で z-index の問題に悩まされたり、非同期データの読み込み状態管理が複雑になったりしていました。しかし、Vue 3 の Teleport と Suspense がこれらの課題を解決し、開発者の悩みを軽減してくれます。

本記事では、これらの新機能の基本概念から実践的な活用方法まで、体系的に学べるように構成しています。実際のコード例やエラーハンドリングも含めて、すぐに現場で使える知識をお届けします。

背景

Vue 3 における新機能の必要性

Vue 3 の設計において、開発者が長年抱えていた課題を解決するため、Teleport と Suspense という革新的な機能が追加されました。

Vue 2 までの開発では、以下のような問題に直面することがよくありました:

#課題従来の解決策問題点
1モーダルの z-index 問題CSS の z-index を手動調整メンテナンス困難
2非同期データ読み込み状態管理各コンポーネントで loading 状態を管理コードの重複
3ツールチップの表示位置制御絶対位置指定と JavaScript 計算レスポンシブ対応が困難
4ページ遷移時の非同期処理手動で Promise を管理エラーハンドリングが複雑

これらの課題は、実際の開発現場で多くの開発者が体験し、時間とエネルギーを消費していた問題でした。Vue 3 の Teleport と Suspense は、これらの問題を根本的に解決するために設計されています。

Teleport とは

DOM 要素の転送機能の基本概念

Teleport は、Vue 3 で新しく追加されたビルトインコンポーネントで、コンポーネントの一部を別の場所の DOM に「転送」する機能です。

まず、Teleport の基本的な使用方法を見てみましょう:

vue<template>
  <div class="container">
    <h1>メインコンテンツ</h1>
    <button @click="showModal = true">
      モーダルを開く
    </button>

    <!-- Teleportを使用してモーダルをbodyに転送 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay">
        <div class="modal">
          <h2>モーダルコンテンツ</h2>
          <button @click="showModal = false">閉じる</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

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

const showModal = ref(false);
</script>

このコードでは、<Teleport to="body">を使用することで、モーダルの内容が実際には<body>要素の直下に挿入されます。これにより、CSS の z-index やレイアウトの問題を回避できます。

Teleport の仕組み

Teleport は以下のように動作します:

  1. 仮想 DOM 内では通常通り記述:コンポーネントの構造として自然に記述
  2. 実際の DOM では指定した場所に挿入to属性で指定したセレクタの場所に移動
  3. Vue のリアクティブシステムは維持:親コンポーネントの状態変更も反映

これにより、開発者は直感的にコンポーネントを設計しながら、実際の DOM 構造は最適化できるのです。

Suspense とは

非同期コンポーネントの読み込み管理

Suspense は、非同期コンポーネントの読み込み状態を管理するビルトインコンポーネントです。React の Suspense からインスピレーションを得て、Vue 3 に実装されました。

以下は、Suspense の基本的な使用例です:

vue<template>
  <div class="app">
    <h1>非同期コンポーネントの例</h1>

    <Suspense>
      <!-- 非同期コンポーネント -->
      <template #default>
        <AsyncUserProfile :userId="userId" />
      </template>

      <!-- 読み込み中のフォールバック -->
      <template #fallback>
        <div class="loading">
          <div class="spinner"></div>
          <p>ユーザー情報を読み込み中...</p>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import AsyncUserProfile from './AsyncUserProfile.vue';

const userId = ref(123);
</script>

非同期コンポーネントの例(AsyncUserProfile.vue):

vue<template>
  <div class="user-profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
  </div>
</template>

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

const props = defineProps(['userId']);
const user = ref(null);

// 非同期でユーザー情報を取得
const fetchUser = async (id) => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error('ユーザー情報の取得に失敗しました');
  }
  return response.json();
};

// setup内でawaitを使用すると、自動的に非同期コンポーネントになる
user.value = await fetchUser(props.userId);
</script>

Suspense の利点

Suspense を使用することで、以下の利点が得られます:

  1. 統一された読み込み状態管理:複数の非同期コンポーネントを一括管理
  2. エラーハンドリングの簡素化:ErrorBoundary と組み合わせて使用可能
  3. ネストした非同期処理への対応:複雑な依存関係も管理可能
  4. コードの簡潔性:boilerplate コードの削減

Teleport の基本実装

モーダル・ツールチップでの活用

実際の開発で Teleport を活用する場面で最も多いのは、モーダルやツールチップの実装です。ここでは、実践的な例を見てみましょう。

モーダルコンポーネントの実装

まず、再利用可能なモーダルコンポーネントを作成します:

vue<!-- components/Modal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div
        v-if="isVisible"
        class="modal-overlay"
        @click="onOverlayClick"
      >
        <div class="modal-container" @click.stop>
          <div class="modal-header">
            <slot name="header">
              <h3>{{ title }}</h3>
            </slot>
            <button class="close-btn" @click="close">
              ×
            </button>
          </div>
          <div class="modal-body">
            <slot></slot>
          </div>
          <div class="modal-footer" v-if="$slots.footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
import { watch } from 'vue';

const props = defineProps({
  modelValue: Boolean,
  title: String,
  closeOnOverlay: { type: Boolean, default: true },
});

const emit = defineEmits(['update:modelValue', 'close']);

const isVisible = computed(() => props.modelValue);

const close = () => {
  emit('update:modelValue', false);
  emit('close');
};

const onOverlayClick = () => {
  if (props.closeOnOverlay) {
    close();
  }
};

// モーダルが開いた時にbodyのスクロールを無効化
watch(isVisible, (newValue) => {
  if (newValue) {
    document.body.style.overflow = 'hidden';
  } else {
    document.body.style.overflow = '';
  }
});
</script>

対応する CSS スタイル:

css.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-container {
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  max-width: 90%;
  max-height: 90%;
  overflow: auto;
}

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

.close-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0.25rem;
}

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

.modal-enter-from,
.modal-leave-to {
  opacity: 0;
}

ツールチップの実装

次に、ツールチップコンポーネントを作成します:

vue<!-- components/Tooltip.vue -->
<template>
  <div
    class="tooltip-wrapper"
    @mouseenter="show"
    @mouseleave="hide"
  >
    <slot></slot>

    <Teleport to="body">
      <div
        v-if="isVisible"
        ref="tooltipRef"
        class="tooltip"
        :style="tooltipStyle"
        @mouseenter="show"
        @mouseleave="hide"
      >
        <div class="tooltip-content">
          <slot name="content">{{ content }}</slot>
        </div>
        <div
          class="tooltip-arrow"
          :style="arrowStyle"
        ></div>
      </div>
    </Teleport>
  </div>
</template>

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

const props = defineProps({
  content: String,
  position: { type: String, default: 'top' },
  delay: { type: Number, default: 0 },
});

const isVisible = ref(false);
const tooltipRef = ref(null);
const tooltipStyle = reactive({});
const arrowStyle = reactive({});

let showTimer = null;
let hideTimer = null;

const show = () => {
  clearTimeout(hideTimer);
  if (props.delay > 0) {
    showTimer = setTimeout(() => {
      isVisible.value = true;
      nextTick(() => updatePosition());
    }, props.delay);
  } else {
    isVisible.value = true;
    nextTick(() => updatePosition());
  }
};

const hide = () => {
  clearTimeout(showTimer);
  hideTimer = setTimeout(() => {
    isVisible.value = false;
  }, 100);
};

const updatePosition = () => {
  if (!tooltipRef.value) return;

  const trigger = tooltipRef.value.previousElementSibling;
  const triggerRect = trigger.getBoundingClientRect();
  const tooltipRect =
    tooltipRef.value.getBoundingClientRect();

  let top, left;

  switch (props.position) {
    case 'top':
      top = triggerRect.top - tooltipRect.height - 8;
      left =
        triggerRect.left +
        (triggerRect.width - tooltipRect.width) / 2;
      break;
    case 'bottom':
      top = triggerRect.bottom + 8;
      left =
        triggerRect.left +
        (triggerRect.width - tooltipRect.width) / 2;
      break;
    case 'left':
      top =
        triggerRect.top +
        (triggerRect.height - tooltipRect.height) / 2;
      left = triggerRect.left - tooltipRect.width - 8;
      break;
    case 'right':
      top =
        triggerRect.top +
        (triggerRect.height - tooltipRect.height) / 2;
      left = triggerRect.right + 8;
      break;
  }

  // 画面外にはみ出さないよう調整
  if (left < 0) left = 8;
  if (left + tooltipRect.width > window.innerWidth) {
    left = window.innerWidth - tooltipRect.width - 8;
  }

  tooltipStyle.top = `${top}px`;
  tooltipStyle.left = `${left}px`;
};
</script>

Suspense の基本実装

ローディング状態の管理

Suspense を使用した実用的なローディング状態管理の例を見てみましょう。

基本的な Suspense の使用

vue<template>
  <div class="dashboard">
    <h1>ダッシュボード</h1>

    <Suspense>
      <template #default>
        <div class="dashboard-content">
          <UserStats />
          <RecentActivity />
          <ChartComponent />
        </div>
      </template>

      <template #fallback>
        <div class="loading-container">
          <div class="skeleton-loader">
            <div class="skeleton-header"></div>
            <div class="skeleton-stats">
              <div class="skeleton-card"></div>
              <div class="skeleton-card"></div>
              <div class="skeleton-card"></div>
            </div>
            <div class="skeleton-chart"></div>
          </div>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import UserStats from './UserStats.vue';
import RecentActivity from './RecentActivity.vue';
import ChartComponent from './ChartComponent.vue';
</script>

非同期コンポーネントの作成

UserStats コンポーネントの例:

vue<!-- components/UserStats.vue -->
<template>
  <div class="user-stats">
    <h2>ユーザー統計</h2>
    <div class="stats-grid">
      <div
        class="stat-card"
        v-for="stat in stats"
        :key="stat.label"
      >
        <div class="stat-value">{{ stat.value }}</div>
        <div class="stat-label">{{ stat.label }}</div>
      </div>
    </div>
  </div>
</template>

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

const stats = ref([]);

// 非同期でデータを取得
const fetchStats = async () => {
  try {
    const response = await fetch('/api/stats');
    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }
    return await response.json();
  } catch (error) {
    console.error('Stats fetch error:', error);
    throw error;
  }
};

// awaitを使用すると自動的に非同期コンポーネントになる
stats.value = await fetchStats();
</script>

エラーハンドリングと Suspense

Suspense と組み合わせて使用するエラーハンドリングの実装:

vue<template>
  <div class="app">
    <ErrorBoundary>
      <template #default>
        <Suspense>
          <template #default>
            <AsyncContent />
          </template>

          <template #fallback>
            <LoadingSpinner />
          </template>
        </Suspense>
      </template>

      <template #fallback="{ error, retry }">
        <div class="error-container">
          <h2>エラーが発生しました</h2>
          <p>{{ error.message }}</p>
          <button @click="retry">再試行</button>
        </div>
      </template>
    </ErrorBoundary>
  </div>
</template>

<script setup>
import { defineAsyncComponent } from 'vue';
import ErrorBoundary from './ErrorBoundary.vue';
import LoadingSpinner from './LoadingSpinner.vue';

const AsyncContent = defineAsyncComponent({
  loader: () => import('./AsyncContent.vue'),
  errorComponent: () => import('./ErrorComponent.vue'),
  delay: 200,
  timeout: 5000,
});
</script>

Teleport 応用テクニック

複数ターゲット・条件付き転送

より高度な Teleport の使用方法を学びましょう。

条件付き転送の実装

画面サイズに応じて転送先を変更する例:

vue<template>
  <div class="responsive-component">
    <div class="main-content">
      <h1>メインコンテンツ</h1>
      <button @click="toggleSidebar">
        サイドバーを開く
      </button>
    </div>

    <!-- 画面サイズに応じて転送先を変更 -->
    <Teleport
      :to="sidebarTarget"
      :disabled="!shouldTeleport"
    >
      <div v-if="showSidebar" class="sidebar">
        <h2>サイドバー</h2>
        <nav>
          <ul>
            <li><a href="#home">ホーム</a></li>
            <li><a href="#about">について</a></li>
            <li><a href="#contact">お問い合わせ</a></li>
          </ul>
        </nav>
        <button @click="toggleSidebar">閉じる</button>
      </div>
    </Teleport>
  </div>
</template>

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

const showSidebar = ref(false);
const windowWidth = ref(window.innerWidth);

const shouldTeleport = computed(
  () => windowWidth.value < 768
);
const sidebarTarget = computed(() =>
  shouldTeleport.value ? 'body' : '.main-content'
);

const toggleSidebar = () => {
  showSidebar.value = !showSidebar.value;
};

const handleResize = () => {
  windowWidth.value = window.innerWidth;
};

onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
});
</script>

動的ターゲットの管理

複数のターゲットを動的に切り替える例:

vue<template>
  <div class="multi-target-example">
    <div class="zones">
      <div id="zone-1" class="drop-zone">ゾーン1</div>
      <div id="zone-2" class="drop-zone">ゾーン2</div>
      <div id="zone-3" class="drop-zone">ゾーン3</div>
    </div>

    <div class="controls">
      <button @click="moveToZone('zone-1')">
        ゾーン1に移動
      </button>
      <button @click="moveToZone('zone-2')">
        ゾーン2に移動
      </button>
      <button @click="moveToZone('zone-3')">
        ゾーン3に移動
      </button>
    </div>

    <Teleport :to="currentTarget">
      <div class="moveable-element">
        <p>移動可能な要素</p>
        <p>現在の場所: {{ currentZone }}</p>
      </div>
    </Teleport>
  </div>
</template>

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

const currentZone = ref('zone-1');
const currentTarget = computed(
  () => `#${currentZone.value}`
);

const moveToZone = (zoneId) => {
  currentZone.value = zoneId;
};
</script>

Suspense 応用テクニック

エラーハンドリング・ネストした非同期処理

より複雑な非同期処理のパターンを学びましょう。

ネストした Suspense の実装

vue<template>
  <div class="nested-suspense-example">
    <h1>ネストしたSuspenseの例</h1>

    <Suspense>
      <template #default>
        <UserProfile>
          <Suspense>
            <template #default>
              <UserPosts />
            </template>
            <template #fallback>
              <div class="posts-loading">
                投稿を読み込み中...
              </div>
            </template>
          </Suspense>
        </UserProfile>
      </template>

      <template #fallback>
        <div class="profile-loading">
          プロフィールを読み込み中...
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import UserProfile from './UserProfile.vue';
import UserPosts from './UserPosts.vue';
</script>

複数の非同期リソースの管理

複数の API を並行して呼び出す例:

vue<!-- components/Dashboard.vue -->
<template>
  <div class="dashboard">
    <h1>ダッシュボード</h1>
    <div class="dashboard-grid">
      <div class="user-info">
        <h2>{{ userInfo.name }}</h2>
        <p>{{ userInfo.email }}</p>
      </div>

      <div class="stats">
        <h3>統計情報</h3>
        <div
          class="stat-item"
          v-for="stat in stats"
          :key="stat.id"
        >
          <span>{{ stat.label }}</span>
          <span>{{ stat.value }}</span>
        </div>
      </div>

      <div class="recent-activity">
        <h3>最近のアクティビティ</h3>
        <ul>
          <li
            v-for="activity in activities"
            :key="activity.id"
          >
            {{ activity.description }}
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

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

const userInfo = ref({});
const stats = ref([]);
const activities = ref([]);

// 複数のAPIを並行して呼び出し
const fetchUserInfo = async () => {
  const response = await fetch('/api/user');
  if (!response.ok) {
    throw new Error('ユーザー情報の取得に失敗しました');
  }
  return response.json();
};

const fetchStats = async () => {
  const response = await fetch('/api/stats');
  if (!response.ok) {
    throw new Error('統計情報の取得に失敗しました');
  }
  return response.json();
};

const fetchActivities = async () => {
  const response = await fetch('/api/activities');
  if (!response.ok) {
    throw new Error('アクティビティの取得に失敗しました');
  }
  return response.json();
};

// Promise.allを使用して並行処理
const [userInfoData, statsData, activitiesData] =
  await Promise.all([
    fetchUserInfo(),
    fetchStats(),
    fetchActivities(),
  ]);

userInfo.value = userInfoData;
stats.value = statsData;
activities.value = activitiesData;
</script>

エラーハンドリングの実装

よくあるエラーとその対処法:

javascript// エラーハンドリングのベストプラクティス
const fetchDataWithErrorHandling = async () => {
  try {
    const response = await fetch('/api/data');

    // HTTPエラーステータスのチェック
    if (!response.ok) {
      switch (response.status) {
        case 404:
          throw new Error('データが見つかりません');
        case 401:
          throw new Error('認証が必要です');
        case 403:
          throw new Error('アクセス権限がありません');
        case 500:
          throw new Error('サーバーエラーが発生しました');
        default:
          throw new Error(`HTTP Error: ${response.status}`);
      }
    }

    const data = await response.json();
    return data;
  } catch (error) {
    // ネットワークエラーの場合
    if (error instanceof TypeError) {
      throw new Error('ネットワークエラーが発生しました');
    }

    // その他のエラー
    console.error('Data fetch error:', error);
    throw error;
  }
};

Teleport と Suspense の組み合わせ

動的コンポーネントでの連携

Teleport と Suspense を組み合わせることで、より高度な機能を実現できます。

動的モーダルの実装

vue<template>
  <div class="dynamic-modal-example">
    <div class="modal-triggers">
      <button @click="openModal('user-profile')">
        ユーザープロフィール
      </button>
      <button @click="openModal('settings')">設定</button>
      <button @click="openModal('analytics')">分析</button>
    </div>

    <Teleport to="body">
      <div
        v-if="activeModal"
        class="modal-overlay"
        @click="closeModal"
      >
        <div class="modal-container" @click.stop>
          <div class="modal-header">
            <h2>{{ modalTitle }}</h2>
            <button @click="closeModal">×</button>
          </div>

          <div class="modal-content">
            <Suspense>
              <template #default>
                <component :is="modalComponent" />
              </template>

              <template #fallback>
                <div class="modal-loading">
                  <div class="spinner"></div>
                  <p>コンテンツを読み込み中...</p>
                </div>
              </template>
            </Suspense>
          </div>
        </div>
      </div>
    </Teleport>
  </div>
</template>

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

const activeModal = ref(null);

const modalComponents = {
  'user-profile': defineAsyncComponent(() =>
    import('./UserProfileModal.vue')
  ),
  settings: defineAsyncComponent(() =>
    import('./SettingsModal.vue')
  ),
  analytics: defineAsyncComponent(() =>
    import('./AnalyticsModal.vue')
  ),
};

const modalTitles = {
  'user-profile': 'ユーザープロフィール',
  settings: '設定',
  analytics: '分析',
};

const modalComponent = computed(() => {
  return activeModal.value
    ? modalComponents[activeModal.value]
    : null;
});

const modalTitle = computed(() => {
  return activeModal.value
    ? modalTitles[activeModal.value]
    : '';
});

const openModal = (modalType) => {
  activeModal.value = modalType;
};

const closeModal = () => {
  activeModal.value = null;
};
</script>

非同期ツールチップの実装

vue<!-- components/AsyncTooltip.vue -->
<template>
  <div
    class="async-tooltip-wrapper"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
  >
    <slot></slot>

    <Teleport to="body">
      <div
        v-if="showTooltip"
        ref="tooltipElement"
        class="async-tooltip"
        :style="tooltipStyle"
      >
        <Suspense>
          <template #default>
            <component
              :is="tooltipComponent"
              v-bind="componentProps"
            />
          </template>

          <template #fallback>
            <div class="tooltip-loading">
              <div class="loading-spinner"></div>
            </div>
          </template>
        </Suspense>
      </div>
    </Teleport>
  </div>
</template>

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

const props = defineProps({
  tooltipType: String,
  tooltipProps: Object,
  delay: { type: Number, default: 500 },
});

const showTooltip = ref(false);
const tooltipElement = ref(null);
const tooltipStyle = ref({});

let showTimer = null;
let hideTimer = null;

const tooltipComponents = {
  'user-card': defineAsyncComponent(() =>
    import('./UserCardTooltip.vue')
  ),
  'product-info': defineAsyncComponent(() =>
    import('./ProductInfoTooltip.vue')
  ),
  'quick-stats': defineAsyncComponent(() =>
    import('./QuickStatsTooltip.vue')
  ),
};

const tooltipComponent = computed(() => {
  return props.tooltipType
    ? tooltipComponents[props.tooltipType]
    : null;
});

const componentProps = computed(
  () => props.tooltipProps || {}
);

const handleMouseEnter = () => {
  clearTimeout(hideTimer);
  showTimer = setTimeout(() => {
    showTooltip.value = true;
    nextTick(() => updateTooltipPosition());
  }, props.delay);
};

const handleMouseLeave = () => {
  clearTimeout(showTimer);
  hideTimer = setTimeout(() => {
    showTooltip.value = false;
  }, 100);
};

const updateTooltipPosition = () => {
  if (!tooltipElement.value) return;

  const trigger = tooltipElement.value.parentElement;
  const triggerRect = trigger.getBoundingClientRect();
  const tooltipRect =
    tooltipElement.value.getBoundingClientRect();

  const left =
    triggerRect.left +
    (triggerRect.width - tooltipRect.width) / 2;
  const top = triggerRect.top - tooltipRect.height - 8;

  tooltipStyle.value = {
    left: `${Math.max(8, left)}px`,
    top: `${Math.max(8, top)}px`,
    position: 'fixed',
    zIndex: 1000,
  };
};
</script>

パフォーマンス最適化

効率的な使用方法

Teleport と Suspense を使用する際のパフォーマンス最適化のポイントを学びましょう。

Teleport の最適化

vue<template>
  <div class="optimized-teleport">
    <!-- 不要な再レンダリングを避けるための工夫 -->
    <Teleport
      :to="teleportTarget"
      :disabled="!shouldTeleport"
    >
      <div v-if="showContent" class="teleported-content">
        <ExpensiveComponent v-if="shouldRenderExpensive" />
        <LightweightComponent v-else />
      </div>
    </Teleport>
  </div>
</template>

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

// shallowRefを使用して不要な深い監視を避ける
const expensiveData = shallowRef({});
const showContent = ref(false);

const shouldTeleport = computed(() => {
  // 条件を明確にして不要な転送を避ける
  return window.innerWidth < 768 && showContent.value;
});

const teleportTarget = computed(() => {
  // 動的なターゲット選択を最適化
  return shouldTeleport.value
    ? '#mobile-container'
    : '.desktop-container';
});

const shouldRenderExpensive = computed(() => {
  // 高コストなコンポーネントの条件分岐
  return expensiveData.value.length > 100;
});
</script>

Suspense の最適化

vue<template>
  <div class="optimized-suspense">
    <Suspense>
      <template #default>
        <LazyComponent :key="componentKey" />
      </template>

      <template #fallback>
        <OptimizedLoader />
      </template>
    </Suspense>
  </div>
</template>

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

const currentRoute = ref('home');

// キャッシュ機能付きの非同期コンポーネント
const LazyComponent = defineAsyncComponent({
  loader: () => import('./LazyComponent.vue'),
  loadingComponent: () => import('./LoadingComponent.vue'),
  errorComponent: () => import('./ErrorComponent.vue'),
  delay: 200, // 200ms後にローディングを表示
  timeout: 5000, // 5秒でタイムアウト
  suspensible: true, // Suspenseと連携
});

// キーを使用してコンポーネントのキャッシュを制御
const componentKey = computed(() => {
  return `${currentRoute.value}-${Date.now()}`;
});
</script>

メモリリークの防止

javascript// メモリリークを防ぐためのベストプラクティス
import { ref, onUnmounted } from 'vue';

export default {
  setup() {
    const timers = [];
    const eventListeners = [];

    const addTimer = (callback, delay) => {
      const timer = setTimeout(callback, delay);
      timers.push(timer);
      return timer;
    };

    const addEventListener = (element, event, handler) => {
      element.addEventListener(event, handler);
      eventListeners.push({ element, event, handler });
    };

    // コンポーネントのアンマウント時にクリーンアップ
    onUnmounted(() => {
      // タイマーのクリア
      timers.forEach((timer) => clearTimeout(timer));

      // イベントリスナーの削除
      eventListeners.forEach(
        ({ element, event, handler }) => {
          element.removeEventListener(event, handler);
        }
      );

      // DOM要素の参照をクリア
      if (document.body.style.overflow === 'hidden') {
        document.body.style.overflow = '';
      }
    });

    return {
      addTimer,
      addEventListener,
    };
  },
};

バンドルサイズの最適化

javascript// 動的インポートを使用してバンドルサイズを最適化
const optimizedComponents = {
  // 必要な時だけロード
  modal: () => import('./Modal.vue'),
  tooltip: () => import('./Tooltip.vue'),

  // 条件付きロード
  heavyComponent: () => {
    if (process.env.NODE_ENV === 'production') {
      return import('./HeavyComponent.vue');
    }
    return import('./LightweightComponent.vue');
  },
};

// プリロード機能
const preloadComponents = async () => {
  // ユーザーの操作を予測してプリロード
  if (window.innerWidth > 768) {
    await import('./DesktopModal.vue');
  } else {
    await import('./MobileModal.vue');
  }
};

まとめ

Vue 3 の Teleport と Suspense は、モダンな Web アプリケーション開発において、これまで困難だった問題を解決する強力な機能です。

重要なポイント

機能主な用途解決する問題
Teleportモーダル、ツールチップ、ポップアップz-index 問題、レイアウト制約
Suspense非同期コンポーネント、データ読み込みローディング状態管理、UX 向上

これらの機能を組み合わせることで、以下のような価値を提供できます:

  1. 開発効率の向上:boilerplate コードの削減
  2. ユーザー体験の改善:スムーズなローディング、直感的な UI
  3. 保守性の向上:統一されたパターン、エラーハンドリング
  4. パフォーマンスの最適化:効率的なレンダリング、メモリ使用量の削減

今後の学習のために

Vue 3 の Teleport と Suspense をマスターすることで、より高品質なアプリケーションを開発できるようになります。継続的な学習と実践を通じて、これらの機能を自在に活用できるようになることでしょう。

開発において新しい機能を学ぶ際は、まず基本的な概念を理解し、小さな例から始めて、徐々に複雑な実装に挑戦することが大切です。この記事が、あなたの Vue 3 開発の旅において、有用な道しるべとなることを願っています。

関連リンク