T-CREATOR

Vue.js スクリプトセットアップ完全理解:`<script setup>` とコンパイルマクロの実力

Vue.js スクリプトセットアップ完全理解:`<script setup>` とコンパイルマクロの実力

Vue.js の世界は常に進化し続けており、開発者の生産性向上とアプリケーションのパフォーマンス最適化を目指しています。Vue 3.2 で正式にリリースされた <script setup> は、まさにその理念を体現した革新的な機能です。

従来の Options API から Composition API への移行過程で生まれた <script setup> は、Vue.js 開発における新たなパラダイムシフトを示しています。コンパイル時最適化とランタイムパフォーマンスの向上を同時に実現し、開発者体験を飛躍的に改善する画期的なアプローチといえるでしょう。

この記事では、<script setup> の核心機能からコンパイルマクロの詳細な活用方法まで、実践的な例とともに完全解説いたします。Vue.js の未来を担う重要な技術について、一緒に理解を深めていきませんか。

背景

Vue 3 登場と開発パラダイムの変化

Vue 3 の登場は、フロントエンド開発における大きな転換点となりました。特に注目すべきは、Composition API の導入によってコンポーネントロジックの再利用性が大幅に向上したことです。

従来の Options API では、データ、メソッド、ライフサイクルフックが分離されており、関連するロジックが複数の場所に散らばる問題がありました。Composition API は、この課題を根本的に解決し、機能ごとにロジックをまとめて記述できる仕組みを提供しています。

Vue 3 のコンパイラーは、静的解析による最適化を強化し、より効率的なバンドルサイズとランタイムパフォーマンスを実現しました。このコンパイル時最適化こそが、<script setup> の基盤となる重要な技術革新なのです。

<script setup> が解決する従来の課題

Composition API は優れた機能でしたが、実際の開発現場では冗長な記述が必要でした。例えば、テンプレートで使用する変数や関数を明示的に return する必要があり、コードの可読性と保守性に課題を残していました。

javascript// 従来のComposition API
export default {
  setup() {
    const count = ref(0);
    const increment = () => {
      count.value++;
    };

    // テンプレートで使用するものを明示的にreturn
    return {
      count,
      increment,
    };
  },
};

この冗長性は、特に大規模なコンポーネントで顕著になり、開発効率の低下を招いていました。<script setup> は、このような課題を解決するために生まれた次世代のソリューションです。

コンパイル時最適化の重要性

モダンなフロントエンド開発では、バンドルサイズの最適化とランタイムパフォーマンスの向上が不可欠です。Vue.js は、コンパイル時に静的解析を行い、不要なコードを削除したり、効率的なコードに変換したりする仕組みを持っています。

<script setup> は、この仕組みを最大限活用するために設計されており、開発者が意識しなくても自動的に最適化されたコードが生成されます。これにより、開発生産性とアプリケーションパフォーマンスの両立が可能になったのです。

以下の図は、Vue.js の進化過程とコンパイル時最適化の関係を示しています:

mermaidflowchart TD
    vue2[Vue 2 Options API] -->|課題| composition[Vue 3 Composition API]
    composition -->|冗長性解決| setup[script setup]
    setup -->|最適化| compiler[コンパイル時最適化]

    vue2 -->|分離された記述| problem1[ロジック分散]
    composition -->|明示的return| problem2[冗長な記述]
    setup -->|自動最適化| solution[効率的なコード生成]

    compiler -->|結果| bundle[小さなバンドル]
    compiler -->|結果| performance[高いパフォーマンス]

このように、Vue.js の各バージョンで蓄積された課題を段階的に解決し、最終的に <script setup> という理想的な解決策にたどり着いたのです。

課題

Options API の限界

Options API は Vue.js の初期から採用されている伝統的なアプローチですが、複雑なコンポーネントを構築する際にいくつかの制約が明らかになりました。

最も大きな問題は、関連するロジックが複数のオプション(data、methods、computed、watch など)に分散してしまうことです。例えば、ユーザー認証に関する機能を実装する場合、以下のような分散が発生します:

javascript// Options APIでの分散したロジック
export default {
  data() {
    return {
      user: null, // 認証関連
      isLoading: false, // 認証関連
      products: [], // 商品関連
      cart: [], // カート関連
    };
  },
  computed: {
    isAuthenticated() {
      // 認証関連
      return !!this.user;
    },
    cartTotal() {
      // カート関連
      return this.cart.reduce(
        (sum, item) => sum + item.price,
        0
      );
    },
  },
  methods: {
    async login() {
      // 認証関連
      this.isLoading = true;
      // ログイン処理
    },
    addToCart(product) {
      // カート関連
      this.cart.push(product);
    },
  },
};

このような分散は、機能の理解と保守を困難にし、チーム開発での作業効率を低下させる要因となっていました。

Composition API の冗長性

Composition API は Options API の課題を解決しましたが、新たな問題も生み出しました。最も顕著なのは、テンプレートで使用するすべての変数と関数を明示的に return する必要があることです。

javascript// Composition APIの冗長な記述例
import { ref, computed, onMounted } from 'vue';

export default {
  setup() {
    // 認証関連のロジック
    const user = ref(null);
    const isLoading = ref(false);
    const isAuthenticated = computed(() => !!user.value);

    const login = async () => {
      isLoading.value = true;
      // ログイン処理
    };

    // 商品関連のロジック
    const products = ref([]);
    const fetchProducts = async () => {
      // 商品取得処理
    };

    onMounted(() => {
      fetchProducts();
    });

    // すべてを明示的にreturnする必要がある
    return {
      user,
      isLoading,
      isAuthenticated,
      login,
      products,
      fetchProducts,
    };
  },
};

このパターンでは、setup 関数の最後で大量の return 文を記述する必要があり、変数や関数の追加・削除のたびに return 文の更新も必要でした。

TypeScript 統合の複雑さ

TypeScript との統合において、Composition API は型推論の面で課題を抱えていました。特に、setup 関数内で定義した変数の型を適切に推論させるために、複雑な型定義が必要でした。

typescript// TypeScriptでの複雑な型定義例
interface User {
  id: number;
  name: string;
  email: string;
}

interface ProductItem {
  id: number;
  name: string;
  price: number;
}

export default defineComponent({
  setup(): {
    user: Ref<User | null>;
    isLoading: Ref<boolean>;
    isAuthenticated: ComputedRef<boolean>;
    login: () => Promise<void>;
    products: Ref<ProductItem[]>;
    fetchProducts: () => Promise<void>;
  } {
    // 実装部分
    const user = ref<User | null>(null);
    const isLoading = ref(false);
    // ...

    return {
      user,
      isLoading,
      isAuthenticated,
      login,
      products,
      fetchProducts,
    };
  },
});

この複雑な型定義は、開発者にとって大きな負担となり、TypeScript の恩恵を十分に受けられない状況を生み出していました。

パフォーマンス最適化の難しさ

従来のアプローチでは、開発者が明示的にパフォーマンス最適化を意識して実装する必要がありました。例えば、不要な再レンダリングを防ぐために、適切な場所で computedwatch を使い分ける必要がありました。

また、コンパイル時最適化の恩恵を受けるためには、特定の記述パターンに従う必要があり、これらの知識習得が開発者の学習コストを押し上げていました。

以下の図は、従来のアプローチで発生していた主要な課題を整理したものです:

mermaidflowchart LR
    options[Options API] -->|問題1| scatter[ロジック分散]
    composition[Composition API] -->|問題2| verbose[冗長な記述]
    typescript[TypeScript統合] -->|問題3| complex[複雑な型定義]
    performance[パフォーマンス] -->|問題4| manual[手動最適化]

    scatter -->|影響| maintenance[保守性低下]
    verbose -->|影響| productivity[生産性低下]
    complex -->|影響| learning[学習コスト増]
    manual -->|影響| errors[最適化ミス]

これらの課題は、Vue.js の普及と大規模開発での採用において重要な障壁となっていました。<script setup> は、これらすべての課題に対する統合的な解決策として開発されたのです。

解決策

<script setup> の革新的アプローチ

<script setup> は、従来の課題を根本的に解決する革新的なアプローチを採用しています。最も特徴的なのは、setup 関数を明示的に記述する必要がなく、スクリプトブロック内で直接変数や関数を定義できることです。

基本的な構文は驚くほどシンプルです:

vue<template>
  <div>
    <h1>{{ title }}</h1>
    <p>カウント: {{ count }}</p>
    <button @click="increment">増加</button>
  </div>
</template>

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

// 変数定義(自動的にテンプレートで利用可能)
const title = 'Vue.js Script Setup';
const count = ref(0);

// 関数定義(自動的にテンプレートで利用可能)
const increment = () => {
  count.value++;
};
</script>

この記述方法では、明示的な return 文は一切必要ありません。スクリプトブロック内で定義されたすべての変数と関数は、自動的にテンプレートで利用可能になります。

コンパイルマクロによる自動最適化

<script setup> の真の力は、コンパイルマクロにあります。これらのマクロは、開発時には通常の関数として記述できますが、コンパイル時に最適化されたコードに変換されます。

主要なコンパイルマクロを見てみましょう:

defineProps によるプロパティ定義

vue<script setup>
// プロパティの定義
const props = defineProps({
  title: {
    type: String,
    required: true,
  },
  count: {
    type: Number,
    default: 0,
  },
});

// プロパティの使用
console.log(props.title);
</script>

defineEmits によるイベント定義

vue<script setup>
// イベントの定義
const emit = defineEmits(['update', 'delete']);

// イベントの発火
const handleUpdate = () => {
  emit('update', { id: 1, name: 'Updated' });
};

const handleDelete = () => {
  emit('delete', 1);
};
</script>

defineExpose による外部公開

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

const count = ref(0);
const reset = () => {
  count.value = 0;
};

// 親コンポーネントからアクセス可能にする
defineExpose({
  count,
  reset,
});
</script>

これらのマクロは、コンパイル時に効率的なコードに変換され、ランタイムでのオーバーヘッドを最小限に抑えます。

型推論の強化

TypeScript との統合において、<script setup> は大幅な改善を実現しています。型推論が自動的に行われるため、多くの場合で明示的な型定義が不要になります。

vue<script setup lang="ts">
import { ref, computed } from 'vue';

// 型推論が自動的に働く
const count = ref(0); // Ref<number>
const message = ref('Hello'); // Ref<string>

// computed の型も自動推論
const doubleCount = computed(() => count.value * 2); // ComputedRef<number>

// プロパティの型定義もシンプル
interface Props {
  title: string;
  items: Array<{ id: number; name: string }>;
}

const props = defineProps<Props>();

// withDefaults でデフォルト値も型安全に
interface PropsWithDefaults {
  title?: string;
  count?: number;
}

const props = withDefaults(
  defineProps<PropsWithDefaults>(),
  {
    title: 'Default Title',
    count: 0,
  }
);
</script>

開発者体験の向上

<script setup> は、開発者体験の向上に特に注力しています。エディターサポートの強化により、自動補完、型チェック、リファクタリング支援が大幅に改善されています。

vue<script setup>
import { ref, onMounted } from 'vue';
import UserService from '@/services/UserService';

// 自動インポートにも対応
const users = ref([]);
const loading = ref(false);

// 非同期処理もシンプルに
const fetchUsers = async () => {
  loading.value = true;
  try {
    users.value = await UserService.getAll();
  } catch (error) {
    console.error('Failed to fetch users:', error);
  } finally {
    loading.value = false;
  }
};

// ライフサイクルフックも直接使用
onMounted(() => {
  fetchUsers();
});
</script>

Hot Module Replacement(HMR)の対応も強化されており、開発中の状態を保持したまま高速なリロードが可能です。

以下の図は、<script setup> による解決策の全体像を示しています:

mermaidflowchart TD
    setup[script setup] -->|機能1| auto[自動エクスポート]
    setup -->|機能2| macro[コンパイルマクロ]
    setup -->|機能3| type[型推論強化]
    setup -->|機能4| dx[開発者体験向上]

    auto -->|結果| simple[シンプルな記述]
    macro -->|結果| optimize[自動最適化]
    type -->|結果| safety[型安全性]
    dx -->|結果| productive[高い生産性]

    simple --> result[理想的なVue開発]
    optimize --> result
    safety --> result
    productive --> result

この統合的なアプローチにより、Vue.js 開発は新たな段階に入ったといえるでしょう。開発者は、パフォーマンスや型安全性を犠牲にすることなく、より直感的で効率的なコードを書けるようになりました。

具体例

基本的な <script setup> の使い方

実際の開発でよく使用される基本的なパターンから見ていきましょう。ここでは、ユーザー情報を表示・編集するコンポーネントを例に、<script setup> の基本的な使い方を説明します。

vue<template>
  <div class="user-profile">
    <h2>{{ user.name }}さんのプロフィール</h2>
    <div v-if="isEditing">
      <input v-model="editForm.name" placeholder="名前" />
      <input
        v-model="editForm.email"
        placeholder="メールアドレス"
      />
      <button @click="saveUser">保存</button>
      <button @click="cancelEdit">キャンセル</button>
    </div>
    <div v-else>
      <p>名前: {{ user.name }}</p>
      <p>メール: {{ user.email }}</p>
      <button @click="startEdit">編集</button>
    </div>
  </div>
</template>

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

// リアクティブなデータ定義
const user = reactive({
  name: '田中太郎',
  email: 'tanaka@example.com',
});

const isEditing = ref(false);
const editForm = reactive({
  name: '',
  email: '',
});

// 算出プロパティ
const canSave = computed(() => {
  return (
    editForm.name.trim() !== '' &&
    editForm.email.trim() !== ''
  );
});

// メソッド定義
const startEdit = () => {
  editForm.name = user.name;
  editForm.email = user.email;
  isEditing.value = true;
};

const saveUser = () => {
  if (canSave.value) {
    user.name = editForm.name;
    user.email = editForm.email;
    isEditing.value = false;
  }
};

const cancelEdit = () => {
  isEditing.value = false;
  editForm.name = '';
  editForm.email = '';
};
</script>

この例では、従来必要だった setup 関数や return 文が完全に不要になっています。すべての変数と関数は自動的にテンプレートで利用可能になります。

コンパイルマクロ活用パターン

次に、コンパイルマクロを活用したより実践的な例を見てみましょう。子コンポーネントとしてプロパティとイベントを扱うコンポーネントです。

vue<!-- UserCard.vue -->
<template>
  <div class="user-card" :class="cardClass">
    <img :src="user.avatar" :alt="user.name" />
    <h3>{{ user.name }}</h3>
    <p>{{ user.role }}</p>
    <button @click="handleEdit" :disabled="!canEdit">
      編集
    </button>
    <button @click="handleDelete" :disabled="!canDelete">
      削除
    </button>
  </div>
</template>

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

// インターフェース定義(TypeScript使用時)
interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: string
}

interface Props {
  user: User
  editable?: boolean
  deletable?: boolean
  size?: 'small' | 'medium' | 'large'
}

// プロパティの定義(デフォルト値付き)
const props = withDefaults(defineProps<Props>(), {
  editable: true,
  deletable: false,
  size: 'medium'
})

// イベントの定義
const emit = defineEmits<{
  edit: [user: User]
  delete: [userId: number]
  click: [user: User]
}>()

// 算出プロパティ
const canEdit = computed(() => props.editable && props.user.role !== 'admin')
const canDelete = computed(() => props.deletable && props.user.role !== 'admin')
const cardClass = computed(() => [
  'user-card',
  `user-card--${props.size}`,
  { 'user-card--editable': canEdit.value }
])

// イベントハンドラー
const handleEdit = () => {
  if (canEdit.value) {
    emit('edit', props.user)
  }
}

const handleDelete = () => {
  if (canDelete.value) {
    emit('delete', props.user.id)
  }
}

const handleCardClick = () => {
  emit('click', props.user)
}
</script>

親コンポーネントでの使用例

vue<template>
  <div class="user-list">
    <UserCard
      v-for="user in users"
      :key="user.id"
      :user="user"
      :editable="true"
      :deletable="hasDeletePermission"
      size="medium"
      @edit="handleUserEdit"
      @delete="handleUserDelete"
      @click="handleUserClick"
    />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import UserCard from './UserCard.vue';
import { userService } from '@/services/userService';

// データ定義
const users = ref([]);
const hasDeletePermission = ref(false);

// ライフサイクルフック
onMounted(async () => {
  await loadUsers();
  hasDeletePermission.value = await checkDeletePermission();
});

// メソッド定義
const loadUsers = async () => {
  try {
    users.value = await userService.getUsers();
  } catch (error) {
    console.error('ユーザー読み込みエラー:', error);
  }
};

const handleUserEdit = (user) => {
  console.log('編集:', user);
  // 編集ダイアログを開く処理
};

const handleUserDelete = async (userId) => {
  if (confirm('本当に削除しますか?')) {
    try {
      await userService.deleteUser(userId);
      await loadUsers(); // リストを再読み込み
    } catch (error) {
      console.error('削除エラー:', error);
    }
  }
};

const handleUserClick = (user) => {
  console.log('クリック:', user);
  // ユーザー詳細ページへ遷移
};

const checkDeletePermission = async () => {
  // 権限チェックの処理
  return true;
};
</script>

TypeScript との組み合わせ

TypeScript を使用した場合の型安全な実装例を見てみましょう。API からデータを取得して表示するコンポーネントです。

vue<template>
  <div class="product-list">
    <div v-if="loading" class="loading">読み込み中...</div>
    <div v-else-if="error" class="error">
      エラーが発生しました: {{ error.message }}
      <button @click="retry">再試行</button>
    </div>
    <div v-else>
      <div class="filters">
        <select
          v-model="selectedCategory"
          @change="filterProducts"
        >
          <option value="">すべてのカテゴリ</option>
          <option
            v-for="category in categories"
            :key="category"
            :value="category"
          >
            {{ category }}
          </option>
        </select>
        <input
          v-model="searchQuery"
          placeholder="商品を検索..."
          @input="searchProducts"
        />
      </div>
      <div class="products">
        <ProductCard
          v-for="product in filteredProducts"
          :key="product.id"
          :product="product"
          @add-to-cart="addToCart"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import ProductCard from './ProductCard.vue';
import { productService } from '@/services/productService';

// 型定義
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  description: string;
  imageUrl: string;
  stock: number;
}

interface ApiError {
  message: string;
  code: number;
}

// プロパティ定義
interface Props {
  categoryFilter?: string;
  maxResults?: number;
}

const props = withDefaults(defineProps<Props>(), {
  categoryFilter: '',
  maxResults: 50,
});

// イベント定義
const emit = defineEmits<{
  'product-added': [product: Product, quantity: number];
  'load-complete': [count: number];
  'error-occurred': [error: ApiError];
}>();

// リアクティブデータ
const products = ref<Product[]>([]);
const loading = ref(false);
const error = ref<ApiError | null>(null);
const selectedCategory = ref('');
const searchQuery = ref('');

// 算出プロパティ
const categories = computed(() => {
  const uniqueCategories = [
    ...new Set(products.value.map((p) => p.category)),
  ];
  return uniqueCategories.sort();
});

const filteredProducts = computed(() => {
  let result = products.value;

  // カテゴリフィルター
  if (selectedCategory.value) {
    result = result.filter(
      (p) => p.category === selectedCategory.value
    );
  }

  // プロパティによるカテゴリフィルター
  if (props.categoryFilter) {
    result = result.filter(
      (p) => p.category === props.categoryFilter
    );
  }

  // 検索クエリフィルター
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase();
    result = result.filter(
      (p) =>
        p.name.toLowerCase().includes(query) ||
        p.description.toLowerCase().includes(query)
    );
  }

  // 最大結果数の制限
  return result.slice(0, props.maxResults);
});

// メソッド
const loadProducts = async (): Promise<void> => {
  loading.value = true;
  error.value = null;

  try {
    const response = await productService.getProducts();
    products.value = response.data;
    emit('load-complete', response.data.length);
  } catch (err) {
    const apiError: ApiError = {
      message:
        err instanceof Error
          ? err.message
          : '不明なエラーが発生しました',
      code: err?.status || 500,
    };
    error.value = apiError;
    emit('error-occurred', apiError);
  } finally {
    loading.value = false;
  }
};

const retry = (): void => {
  loadProducts();
};

const filterProducts = (): void => {
  // フィルタリングは算出プロパティで自動実行されるため、
  // ここでは追加の処理があれば実装
  console.log(
    'カテゴリフィルター変更:',
    selectedCategory.value
  );
};

const searchProducts = (): void => {
  // 検索は算出プロパティで自動実行されるため、
  // ここでは追加の処理があれば実装
  console.log('検索クエリ変更:', searchQuery.value);
};

const addToCart = (product: Product): void => {
  const quantity = 1; // デフォルト数量
  emit('product-added', product, quantity);
  console.log(`${product.name} をカートに追加しました`);
};

// ライフサイクルフック
onMounted(() => {
  loadProducts();
});

// ウォッチャー
watch(
  () => props.categoryFilter,
  (newCategory) => {
    selectedCategory.value = newCategory;
  },
  { immediate: true }
);

// 外部に公開するメソッド(親コンポーネントから呼び出し可能)
defineExpose({
  loadProducts,
  retry,
  products: computed(() => products.value),
  loading: computed(() => loading.value),
});
</script>

パフォーマンス最適化事例

<script setup> を使用したパフォーマンス最適化の実装例を見てみましょう。大量のデータを効率的に処理するコンポーネントです。

vue<template>
  <div class="data-table">
    <div class="table-controls">
      <input
        v-model="searchTerm"
        placeholder="検索..."
        class="search-input"
      />
      <select v-model="sortColumn" class="sort-select">
        <option value="">ソートなし</option>
        <option value="name">名前</option>
        <option value="email">メール</option>
        <option value="createdAt">作成日</option>
      </select>
      <button
        @click="toggleSortOrder"
        class="sort-order-btn"
      >
        {{ sortOrder === 'asc' ? '昇順' : '降順' }}
      </button>
    </div>

    <div class="table-info">
      表示中: {{ displayedItems.length }} /
      {{ totalCount }} (フィルター前:
      {{ filteredItems.length }})
    </div>

    <div class="virtual-table" ref="tableContainer">
      <div class="table-header">
        <div class="table-cell">名前</div>
        <div class="table-cell">メール</div>
        <div class="table-cell">作成日</div>
        <div class="table-cell">操作</div>
      </div>

      <div
        class="table-body"
        :style="{ height: `${containerHeight}px` }"
        @scroll="handleScroll"
      >
        <div
          :style="{
            height: `${totalHeight}px`,
            position: 'relative',
          }"
        >
          <div
            v-for="item in visibleItems"
            :key="item.id"
            class="table-row"
            :style="{
              position: 'absolute',
              top: `${item._index * itemHeight}px`,
              height: `${itemHeight}px`,
            }"
          >
            <div class="table-cell">{{ item.name }}</div>
            <div class="table-cell">{{ item.email }}</div>
            <div class="table-cell">
              {{ formatDate(item.createdAt) }}
            </div>
            <div class="table-cell">
              <button @click="editItem(item)">編集</button>
              <button @click="deleteItem(item.id)">
                削除
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onUnmounted,
  nextTick,
  watch,
} from 'vue';

interface DataItem {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
  _index?: number; // 仮想スクロール用のインデックス
}

interface Props {
  data: DataItem[];
  pageSize?: number;
}

const props = withDefaults(defineProps<Props>(), {
  pageSize: 50,
});

const emit = defineEmits<{
  edit: [item: DataItem];
  delete: [id: number];
  'selection-change': [selectedIds: number[]];
}>();

// リアクティブデータ
const searchTerm = ref('');
const sortColumn = ref('');
const sortOrder = ref<'asc' | 'desc'>('asc');
const tableContainer = ref<HTMLElement>();
const scrollTop = ref(0);
const containerHeight = ref(400);
const itemHeight = 50;
const totalCount = computed(() => props.data.length);

// 仮想スクロール用の算出プロパティ
const filteredItems = computed(() => {
  let items = [...props.data];

  // 検索フィルター
  if (searchTerm.value) {
    const term = searchTerm.value.toLowerCase();
    items = items.filter(
      (item) =>
        item.name.toLowerCase().includes(term) ||
        item.email.toLowerCase().includes(term)
    );
  }

  // ソート
  if (sortColumn.value) {
    items.sort((a, b) => {
      const aVal = a[sortColumn.value as keyof DataItem];
      const bVal = b[sortColumn.value as keyof DataItem];

      let comparison = 0;
      if (aVal < bVal) comparison = -1;
      if (aVal > bVal) comparison = 1;

      return sortOrder.value === 'asc'
        ? comparison
        : -comparison;
    });
  }

  // インデックスを追加
  return items.map((item, index) => ({
    ...item,
    _index: index,
  }));
});

const totalHeight = computed(
  () => filteredItems.value.length * itemHeight
);

const visibleStartIndex = computed(() => {
  return Math.floor(scrollTop.value / itemHeight);
});

const visibleEndIndex = computed(() => {
  const visible = Math.ceil(
    containerHeight.value / itemHeight
  );
  return Math.min(
    visibleStartIndex.value + visible + 5, // バッファーとして5行追加
    filteredItems.value.length
  );
});

const visibleItems = computed(() => {
  return filteredItems.value.slice(
    visibleStartIndex.value,
    visibleEndIndex.value
  );
});

const displayedItems = computed(() => {
  return filteredItems.value.slice(0, props.pageSize);
});

// デバウンス機能付きの検索
const debouncedSearch = (() => {
  let timeout: NodeJS.Timeout;
  return (callback: () => void, delay: number) => {
    clearTimeout(timeout);
    timeout = setTimeout(callback, delay);
  };
})();

// メソッド
const handleScroll = (event: Event) => {
  const target = event.target as HTMLElement;
  scrollTop.value = target.scrollTop;
};

const toggleSortOrder = () => {
  sortOrder.value =
    sortOrder.value === 'asc' ? 'desc' : 'asc';
};

const formatDate = (date: Date): string => {
  return new Intl.DateTimeFormat('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  }).format(new Date(date));
};

const editItem = (item: DataItem) => {
  emit('edit', item);
};

const deleteItem = (id: number) => {
  if (confirm('本当に削除しますか?')) {
    emit('delete', id);
  }
};

const updateContainerHeight = () => {
  if (tableContainer.value) {
    containerHeight.value =
      tableContainer.value.clientHeight - 100; // ヘッダー分を除く
  }
};

// ライフサイクルとイベントリスナー
onMounted(async () => {
  await nextTick();
  updateContainerHeight();
  window.addEventListener('resize', updateContainerHeight);
});

onUnmounted(() => {
  window.removeEventListener(
    'resize',
    updateContainerHeight
  );
});

// ウォッチャー(デバウンス付き検索)
watch(searchTerm, () => {
  debouncedSearch(() => {
    console.log('検索実行:', searchTerm.value);
  }, 300);
});

// パフォーマンス監視
watch(
  filteredItems,
  (newItems) => {
    console.log(
      'フィルター済みアイテム数:',
      newItems.length
    );

    // パフォーマンス測定
    if (newItems.length > 1000) {
      console.warn(
        '大量のデータが表示されています。仮想スクロールが有効です。'
      );
    }
  },
  { immediate: true }
);

// 外部公開
defineExpose({
  updateContainerHeight,
  scrollToTop: () => {
    if (tableContainer.value) {
      tableContainer.value.scrollTop = 0;
    }
  },
  getSelectedItems: () => filteredItems.value,
  getTotalCount: () => totalCount.value,
});
</script>

<style scoped>
.data-table {
  height: 100%;
  display: flex;
  flex-direction: column;
}

.table-controls {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
  padding: 1rem;
  background: #f5f5f5;
  border-radius: 4px;
}

.virtual-table {
  flex: 1;
  overflow: hidden;
  border: 1px solid #ddd;
}

.table-header {
  display: flex;
  background: #f8f9fa;
  border-bottom: 2px solid #dee2e6;
  font-weight: bold;
}

.table-body {
  overflow-y: auto;
  position: relative;
}

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

.table-row:hover {
  background: #f8f9fa;
}

.table-cell {
  flex: 1;
  padding: 0.75rem;
  border-right: 1px solid #eee;
}

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

.table-info {
  padding: 0.5rem;
  font-size: 0.9rem;
  color: #666;
  background: #fafafa;
}
</style>

この実装例では、以下のパフォーマンス最適化技術を活用しています:

  1. 仮想スクロール: 大量データでも表示領域の要素のみを描画
  2. デバウンス: 検索入力の過度な処理を防止
  3. 算出プロパティのキャッシュ: 依存データが変更されない限り再計算しない
  4. 効率的なソート・フィルタリング: 必要最小限の処理で済むアルゴリズム

以下の図は、<script setup> を使った最適化アプローチを示しています:

mermaidflowchart LR
    input[ユーザー入力] -->|デバウンス| process[処理実行]
    process -->|算出プロパティ| cache[キャッシュ活用]
    cache -->|仮想スクロール| render[効率的描画]

    data[大量データ] -->|フィルタリング| filtered[絞り込み済み]
    filtered -->|ソート| sorted[ソート済み]
    sorted -->|仮想化| virtual[表示領域のみ]
    virtual --> render

    render -->|結果| ui[高速なUI]

これらの具体例を通して、<script setup> がいかに実用的で強力な機能であるかがお分かりいただけたでしょう。従来のアプローチと比較して、コードの簡潔性とパフォーマンスの両立が実現されています。

まとめ

<script setup> 導入のメリット

<script setup> の導入により、Vue.js 開発は大きく進歩しました。最も顕著なメリットは、開発生産性の向上です。従来必要だった冗長な記述が大幅に削減され、より直感的でシンプルなコードが書けるようになりました。

開発者は、setup 関数や明示的な return 文を書く必要がなくなり、コンポーネントのロジックに集中できます。これにより、新規機能の実装やバグ修正にかかる時間が大幅に短縮されています。

パフォーマンス面での改善も見逃せません。コンパイル時最適化により、バンドルサイズの削減とランタイムパフォーマンスの向上が自動的に実現されます。開発者が特別な最適化を意識しなくても、効率的なコードが生成される仕組みは画期的です。

TypeScript 統合の飛躍的改善により、型安全な開発がより身近になりました。型推論の強化により、多くの場面で明示的な型定義が不要になり、TypeScript の恩恵をより簡単に受けられるようになっています。

コンパイルマクロによる機能拡張は、Vue.js の新たな可能性を示しています。definePropsdefineEmitsdefineExpose などのマクロにより、コンポーネント間の連携がより型安全で効率的になりました。

以下の比較表で、従来のアプローチとの違いを整理します:

項目従来の Composition API<script setup>
記述量多い(setup 関数、return 文)少ない(直接定義)
型推論限定的強力
バンドルサイズ標準最適化済み
学習コスト高い低い
開発速度普通高速
エラー発生率高い低い

今後の Vue.js 開発での位置づけ

<script setup> は、今後の Vue.js 開発における標準的なアプローチとして位置づけられています。Vue.js コミュニティでは、新規プロジェクトでの採用を強く推奨しており、公式ドキュメントでも主要な記述方法として紹介されています。

エコシステムとの統合も進んでおり、Vite、Nuxt.js、Quasar などの主要なツールやフレームワークが <script setup> をフルサポートしています。これにより、プロジェクト全体での一貫した開発体験が提供されています。

教育面での影響も大きく、Vue.js を学習する新しい開発者にとって、より理解しやすい入り口となっています。従来の複雑な概念を理解する必要がなく、直感的にコンポーネント開発を始められるため、学習曲線が大幅に改善されています。

大規模開発での採用も進んでおり、企業レベルでの導入事例が増加しています。チーム開発における一貫性の向上、新メンバーのオンボーディングの簡略化、コードレビューの効率化など、実用的なメリットが実証されています。

Vue.js の将来的な発展においても、<script setup> は中核的な役割を担うと予想されます。今後のバージョンアップでは、さらなる最適化や新機能の追加が期待されており、Vue.js エコシステム全体の進化を牽引する存在となるでしょう。

移行戦略については、既存プロジェクトでも段階的な導入が可能です。新しいコンポーネントから <script setup> を採用し、既存コンポーネントは必要に応じてリファクタリングするアプローチが効果的です。

現代のフロントエンド開発において、開発者体験とアプリケーションパフォーマンスの両立は不可欠な要件です。<script setup> は、この課題に対する Vue.js の明確な回答といえるでしょう。

Vue.js を使用する開発者の皆様には、ぜひ <script setup> の活用をご検討いただき、より効率的で楽しい開発体験を実感していただきたいと思います。未来の Vue.js 開発は、きっとさらに素晴らしいものになることでしょう。

関連リンク