T-CREATOR

Vue.js で SVG アイコンを自在に扱うテクニック

Vue.js で SVG アイコンを自在に扱うテクニック

モダンな Web アプリケーション開発において、アイコンは単なる装飾要素を超えた重要な役割を果たしています。特に Vue.js アプリケーションでは、SVG アイコンを効果的に活用することで、ユーザビリティの向上とパフォーマンスの最適化を同時に実現できます。

今回は、Vue.js で SVG アイコンを自在に扱うための実践的なテクニックをご紹介いたします。基本的な実装から応用的な最適化手法まで、段階的に学んでいけるよう構成しました。

背景

Web アプリケーションにおけるアイコンの重要性

現代の Web アプリケーションにおいて、アイコンはユーザーインターフェースの中核を担う存在となっています。適切に配置されたアイコンは、ユーザーの認知負荷を軽減し、直感的な操作を可能にします。

特に以下の場面でアイコンの重要性が際立ちます:

シーンアイコンの役割効果
ナビゲーション機能の視覚的表現直感的な操作性向上
ボタン・CTAアクションの明確化ユーザビリティ向上
ステータス表示状態の即座な認識情報伝達の効率化
データ表示情報の分類・整理可読性の向上

SVG アイコンのメリット

SVG(Scalable Vector Graphics)アイコンは、従来の画像ベースアイコンと比較して多くの利点を持っています。

スケーラビリティ

SVG はベクター形式のため、どのような解像度でも美しく表示されます。高 DPI ディスプレイや Retina 画面でも、鮮明なアイコンを提供できるのです。

javascript<!-- 従来の画像アイコン:解像度に依存 -->
<img src="/icons/heart-16px.png" alt="いいね" />
<img src="/icons/heart-32px.png" alt="いいね" />

<!-- SVGアイコン:一つで全解像度対応 -->
<svg width="16" height="16" viewBox="0 0 24 24">
  <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>

軽量性

SVG ファイルはテキストベースのため、適切な最適化を行うことで非常に軽量になります。複数の解像度の画像を用意する必要がないため、全体的なファイルサイズの削減に貢献します。

カスタマイズ性

CSS や JavaScript を使用して、色、サイズ、アニメーションなどを動的に変更できます。この特性により、テーマ対応やインタラクティブな表現が容易に実現できるでしょう。

Vue.js で SVG を扱う際の課題

Vue.js アプリケーションで SVG アイコンを活用する際には、いくつかの課題に直面することがあります。これらの課題を理解することで、より効果的な解決策を見つけることができます。

課題

インライン記述による可読性の低下

最も一般的な SVG アイコンの実装方法は、テンプレート内に SVG マークアップを直接記述することです。しかし、この方法には可読性の問題があります。

vue<template>
  <div class="user-profile">
    <div class="user-info">
      <!-- 複雑なSVGマークアップがテンプレートの可読性を損ねる -->
      <svg
        width="24"
        height="24"
        viewBox="0 0 24 24"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
        <circle
          cx="12"
          cy="7"
          r="4"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
      <h3>ユーザー名</h3>
    </div>
  </div>
</template>

このような実装では、SVG のパスデータがテンプレートの大部分を占め、本来のコンポーネントロジックが埋もれてしまいます。

アイコンの再利用性の問題

同じアイコンを複数箇所で使用する場合、インライン記述では重複したコードが発生します。これは保守性とパフォーマンスの両面で問題となるでしょう。

vue<!-- UserProfile.vue -->
<template>
  <div>
    <svg width="24" height="24" viewBox="0 0 24 24">
      <!-- ユーザーアイコンのSVGパス -->
    </svg>
    <span>プロフィール</span>
  </div>
</template>

<!-- UserList.vue -->
<template>
  <div>
    <div v-for="user in users" :key="user.id">
      <!-- 同じSVGマークアップが重複 -->
      <svg width="24" height="24" viewBox="0 0 24 24">
        <!-- ユーザーアイコンのSVGパス -->
      </svg>
      <span>{{ user.name }}</span>
    </div>
  </div>
</template>

動的な色やサイズ変更の困難さ

アプリケーションの状態やテーマに応じてアイコンの見た目を変更したい場合、インライン記述では柔軟性に欠けます。特に以下のような要件への対応が困難になります:

  • ダークモード対応
  • アクティブ状態の表示
  • サイズのレスポンシブ対応
  • アニメーション効果

パフォーマンスへの影響

大量の SVG アイコンをインライン記述で実装すると、以下のパフォーマンス問題が発生する可能性があります:

問題影響発生原因
バンドルサイズの増大初期読み込み時間の延長重複した SVG マークアップ
DOM サイズの肥大化レンダリング性能の低下複雑な SVG パスの大量配置
メモリ使用量の増加アプリケーション全体の重さ同一 SVG の複数インスタンス

これらの課題を解決するためには、体系的なアプローチが必要です。

解決策

Vue.js で SVG アイコンの課題を解決するには、コンポーネント化とシステマティックな管理が鍵となります。以下に効果的な解決策をご紹介いたします。

SVG アイコンコンポーネント化

最も基本的かつ効果的な解決策は、SVG アイコンを Vue コンポーネントとして実装することです。これにより、再利用性と保守性が大幅に向上します。

基本的なアイコンコンポーネントの作成

まず、シンプルなアイコンコンポーネントを作成してみましょう。

vue<!-- components/icons/UserIcon.vue -->
<template>
  <svg
    :width="size"
    :height="size"
    viewBox="0 0 24 24"
    fill="none"
    :class="iconClass"
  >
    <path
      d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
    <circle
      cx="12"
      cy="7"
      r="4"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  size?: number | string;
  class?: string;
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  class: '',
});

const iconClass = computed(() => {
  return `icon-user ${props.class}`;
});
</script>

<style scoped>
.icon-user {
  display: inline-block;
  vertical-align: middle;
}
</style>

このコンポーネントの使用例:

vue<template>
  <div class="user-profile">
    <UserIcon :size="32" class="text-blue-500" />
    <span>ユーザープロフィール</span>
  </div>
</template>

<script setup>
import UserIcon from '@/components/icons/UserIcon.vue';
</script>

プロパティによる動的制御

アイコンコンポーネントにプロパティを追加することで、様々な表示パターンに対応できます。

汎用的なアイコンベースコンポーネント

vue<!-- components/icons/BaseIcon.vue -->
<template>
  <svg
    :width="size"
    :height="size"
    :viewBox="viewBox"
    :fill="fillColor"
    :class="computedClass"
    :style="computedStyle"
  >
    <slot />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  size?: number | string;
  viewBox?: string;
  fillColor?: string;
  strokeColor?: string;
  strokeWidth?: number | string;
  class?: string;
  variant?: 'default' | 'primary' | 'secondary' | 'danger';
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  viewBox: '0 0 24 24',
  fillColor: 'none',
  strokeColor: 'currentColor',
  strokeWidth: 2,
  class: '',
  variant: 'default',
});

const computedClass = computed(() => {
  return `base-icon base-icon--${props.variant} ${props.class}`;
});

const computedStyle = computed(() => {
  return {
    '--stroke-color': props.strokeColor,
    '--stroke-width': props.strokeWidth,
  };
});
</script>

<style scoped>
.base-icon {
  display: inline-block;
  vertical-align: middle;
  stroke: var(--stroke-color);
  stroke-width: var(--stroke-width);
  transition: all 0.2s ease;
}

.base-icon--primary {
  color: #3b82f6;
}

.base-icon--secondary {
  color: #6b7280;
}

.base-icon--danger {
  color: #ef4444;
}

.base-icon:hover {
  transform: scale(1.1);
}
</style>

このベースコンポーネントを使用したアイコン実装:

vue<!-- components/icons/HeartIcon.vue -->
<template>
  <BaseIcon v-bind="$props">
    <path
      d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
    />
  </BaseIcon>
</template>

<script setup lang="ts">
import BaseIcon from './BaseIcon.vue';

// BaseIconのpropsを継承
interface Props {
  size?: number | string;
  viewBox?: string;
  fillColor?: string;
  strokeColor?: string;
  strokeWidth?: number | string;
  class?: string;
  variant?: 'default' | 'primary' | 'secondary' | 'danger';
}

defineProps<Props>();
</script>

アイコンライブラリの構築

大規模なアプリケーションでは、アイコンを体系的に管理するライブラリシステムが必要です。

アイコンレジストリーシステム

typescript// composables/useIcons.ts
interface IconDefinition {
  name: string;
  viewBox: string;
  paths: string[];
}

const iconRegistry = new Map<string, IconDefinition>();

// アイコン定義の登録
export function registerIcon(
  name: string,
  definition: IconDefinition
) {
  iconRegistry.set(name, definition);
}

// アイコン定義の取得
export function getIcon(
  name: string
): IconDefinition | undefined {
  return iconRegistry.get(name);
}

// アイコンの一覧取得
export function getIconList(): string[] {
  return Array.from(iconRegistry.keys());
}

// 初期アイコンの登録
registerIcon('user', {
  name: 'user',
  viewBox: '0 0 24 24',
  paths: [
    'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2',
    'M12 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
  ],
});

registerIcon('heart', {
  name: 'heart',
  viewBox: '0 0 24 24',
  paths: [
    'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
  ],
});

動的アイコンコンポーネント

vue<!-- components/icons/DynamicIcon.vue -->
<template>
  <svg
    v-if="iconData"
    :width="size"
    :height="size"
    :viewBox="iconData.viewBox"
    :class="computedClass"
    fill="none"
  >
    <path
      v-for="(path, index) in iconData.paths"
      :key="index"
      :d="path"
      stroke="currentColor"
      :stroke-width="strokeWidth"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
  <div v-else class="icon-placeholder">
    <span>{{ name }}</span>
  </div>
</template>

<script setup lang="ts">
import { getIcon } from '@/composables/useIcons';

interface Props {
  name: string;
  size?: number | string;
  strokeWidth?: number;
  class?: string;
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  strokeWidth: 2,
  class: '',
});

const iconData = computed(() => {
  return getIcon(props.name);
});

const computedClass = computed(() => {
  return `dynamic-icon ${props.class}`;
});
</script>

<style scoped>
.dynamic-icon {
  display: inline-block;
  vertical-align: middle;
}

.icon-placeholder {
  display: inline-block;
  padding: 4px 8px;
  background: #f3f4f6;
  border-radius: 4px;
  font-size: 12px;
  color: #6b7280;
}
</style>

使用例:

vue<template>
  <div class="toolbar">
    <DynamicIcon
      name="user"
      :size="20"
      class="text-gray-600"
    />
    <DynamicIcon
      name="heart"
      :size="24"
      class="text-red-500"
    />
    <DynamicIcon name="settings" :size="18" />
  </div>
</template>

<script setup>
import DynamicIcon from '@/components/icons/DynamicIcon.vue';
</script>

最適化手法

パフォーマンスを向上させるための最適化テクニックをご紹介します。

SVG スプライトシステム

vue<!-- components/icons/SpriteIcon.vue -->
<template>
  <svg :width="size" :height="size" :class="computedClass">
    <use :href="`#icon-${name}`" />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  name: string;
  size?: number | string;
  class?: string;
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  class: '',
});

const computedClass = computed(() => {
  return `sprite-icon ${props.class}`;
});
</script>

<style scoped>
.sprite-icon {
  fill: currentColor;
  vertical-align: middle;
}
</style>

SVG スプライトの定義:

html<!-- public/icons-sprite.svg -->
<svg
  xmlns="http://www.w3.org/2000/svg"
  style="display: none;"
>
  <symbol id="icon-user" viewBox="0 0 24 24">
    <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
    <circle cx="12" cy="7" r="4" />
  </symbol>

  <symbol id="icon-heart" viewBox="0 0 24 24">
    <path
      d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
    />
  </symbol>
</svg>

遅延読み込みの実装

typescript// composables/useLazyIcons.ts
import { ref } from 'vue';

const loadedIcons = new Set<string>();
const iconCache = new Map<string, string>();

export function useLazyIcon(iconName: string) {
  const iconData = ref<string>('');
  const loading = ref(false);
  const error = ref<Error | null>(null);

  async function loadIcon() {
    if (loadedIcons.has(iconName)) {
      iconData.value = iconCache.get(iconName) || '';
      return;
    }

    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(
        `/icons/${iconName}.svg`
      );
      if (!response.ok) {
        throw new Error(
          `アイコンの読み込みに失敗しました: ${iconName}`
        );
      }

      const svgContent = await response.text();
      iconCache.set(iconName, svgContent);
      loadedIcons.add(iconName);
      iconData.value = svgContent;
    } catch (err) {
      error.value =
        err instanceof Error
          ? err
          : new Error('不明なエラーが発生しました');
    } finally {
      loading.value = false;
    }
  }

  return {
    iconData,
    loading,
    error,
    loadIcon,
  };
}

これらの解決策により、Vue.js アプリケーションでの SVG アイコン管理が格段に改善されます。次に、これらの手法を実際のコードで確認してみましょう。

具体例

実際のアプリケーション開発で活用できる具体的な実装例をご紹介いたします。段階的に複雑さを増していく形で、実用的なコードをお見せします。

基本的な SVG コンポーネント作成

シンプルなアイコンコンポーネント

最初に、基本的なアイコンコンポーネントから始めましょう。

vue<!-- components/icons/StarIcon.vue -->
<template>
  <svg
    :width="size"
    :height="size"
    viewBox="0 0 24 24"
    :fill="filled ? 'currentColor' : 'none'"
    :class="iconClasses"
    @click="handleClick"
  >
    <path
      d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
      :stroke="filled ? 'none' : 'currentColor'"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  size?: number | string;
  filled?: boolean;
  disabled?: boolean;
  class?: string;
}

interface Emits {
  click: [event: MouseEvent];
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  filled: false,
  disabled: false,
  class: '',
});

const emit = defineEmits<Emits>();

const iconClasses = computed(() => {
  return [
    'star-icon',
    {
      'star-icon--filled': props.filled,
      'star-icon--disabled': props.disabled,
      'star-icon--interactive': !props.disabled,
    },
    props.class,
  ];
});

function handleClick(event: MouseEvent) {
  if (!props.disabled) {
    emit('click', event);
  }
}
</script>

<style scoped>
.star-icon {
  display: inline-block;
  vertical-align: middle;
  transition: all 0.2s ease;
  cursor: default;
}

.star-icon--interactive {
  cursor: pointer;
}

.star-icon--interactive:hover {
  transform: scale(1.1);
}

.star-icon--filled {
  color: #fbbf24;
}

.star-icon--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

使用例:

vue<template>
  <div class="rating">
    <StarIcon
      v-for="star in 5"
      :key="star"
      :filled="star <= rating"
      :size="20"
      @click="setRating(star)"
      class="mr-1"
    />
    <span class="ml-2 text-gray-600">{{ rating }}/5</span>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import StarIcon from '@/components/icons/StarIcon.vue';

const rating = ref(0);

function setRating(value: number) {
  rating.value = value;
}
</script>

アニメーション対応アイコン

次に、アニメーション機能を持つアイコンコンポーネントを作成します。

vue<!-- components/icons/LoadingIcon.vue -->
<template>
  <svg
    :width="size"
    :height="size"
    viewBox="0 0 24 24"
    :class="iconClasses"
  >
    <circle
      class="loading-circle-bg"
      cx="12"
      cy="12"
      r="10"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      opacity="0.25"
    />
    <circle
      class="loading-circle-progress"
      cx="12"
      cy="12"
      r="10"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      :style="progressStyle"
    />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  size?: number | string;
  progress?: number; // 0-100の進捗値
  spinning?: boolean;
  class?: string;
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  progress: 0,
  spinning: false,
  class: '',
});

const iconClasses = computed(() => {
  return [
    'loading-icon',
    {
      'loading-icon--spinning': props.spinning,
    },
    props.class,
  ];
});

const progressStyle = computed(() => {
  const circumference = 2 * Math.PI * 10;
  const strokeDasharray = circumference;
  const strokeDashoffset =
    circumference - (props.progress / 100) * circumference;

  return {
    strokeDasharray: `${strokeDasharray}`,
    strokeDashoffset: `${strokeDashoffset}`,
    transform: 'rotate(-90deg)',
    transformOrigin: '50% 50%',
  };
});
</script>

<style scoped>
.loading-icon {
  display: inline-block;
  vertical-align: middle;
}

.loading-icon--spinning {
  animation: spin 1s linear infinite;
}

.loading-circle-progress {
  transition: stroke-dashoffset 0.3s ease;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>

アイコンセット管理システム

大規模なアプリケーションに対応するアイコン管理システムを構築します。

アイコン定義ファイル

typescript// types/icons.ts
export interface IconDefinition {
  name: string;
  viewBox: string;
  paths: Array<{
    d: string;
    fill?: string;
    stroke?: string;
    strokeWidth?: number;
  }>;
  category?: string;
  keywords?: string[];
}

export interface IconSet {
  name: string;
  version: string;
  icons: IconDefinition[];
}
typescript// data/icons.ts
import type { IconSet } from '@/types/icons';

export const iconSet: IconSet = {
  name: 'app-icons',
  version: '1.0.0',
  icons: [
    {
      name: 'home',
      viewBox: '0 0 24 24',
      paths: [
        {
          d: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
        {
          d: 'M9 22V12h6v10',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
      ],
      category: 'navigation',
      keywords: ['house', 'main', 'start'],
    },
    {
      name: 'mail',
      viewBox: '0 0 24 24',
      paths: [
        {
          d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
        {
          d: 'M22 6l-10 7L2 6',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
      ],
      category: 'communication',
      keywords: ['email', 'message', 'envelope'],
    },
    {
      name: 'settings',
      viewBox: '0 0 24 24',
      paths: [
        {
          d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 000 2l.08.15a2 2 0 010 2l-.08.15a2 2 0 000 2l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 000-2l-.08-.15a2 2 0 010-2l.08-.15a2 2 0 000-2l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
        {
          d: 'M12 15a3 3 0 100-6 3 3 0 000 6z',
          fill: 'none',
          stroke: 'currentColor',
          strokeWidth: 2,
        },
      ],
      category: 'interface',
      keywords: ['config', 'preferences', 'options'],
    },
  ],
};

アイコンマネージャー Composable

typescript// composables/useIconManager.ts
import { iconSet } from '@/data/icons';
import type { IconDefinition } from '@/types/icons';

class IconManager {
  private icons = new Map<string, IconDefinition>();
  private categories = new Map<string, IconDefinition[]>();

  constructor() {
    this.loadIconSet();
  }

  private loadIconSet() {
    iconSet.icons.forEach((icon) => {
      this.icons.set(icon.name, icon);

      const category = icon.category || 'uncategorized';
      if (!this.categories.has(category)) {
        this.categories.set(category, []);
      }
      this.categories.get(category)!.push(icon);
    });
  }

  getIcon(name: string): IconDefinition | undefined {
    return this.icons.get(name);
  }

  getAllIcons(): IconDefinition[] {
    return Array.from(this.icons.values());
  }

  getIconsByCategory(category: string): IconDefinition[] {
    return this.categories.get(category) || [];
  }

  getCategories(): string[] {
    return Array.from(this.categories.keys());
  }

  searchIcons(query: string): IconDefinition[] {
    const lowercaseQuery = query.toLowerCase();
    return this.getAllIcons().filter((icon) => {
      return (
        icon.name.toLowerCase().includes(lowercaseQuery) ||
        icon.keywords?.some((keyword) =>
          keyword.toLowerCase().includes(lowercaseQuery)
        )
      );
    });
  }

  registerIcon(icon: IconDefinition) {
    this.icons.set(icon.name, icon);

    const category = icon.category || 'uncategorized';
    if (!this.categories.has(category)) {
      this.categories.set(category, []);
    }
    this.categories.get(category)!.push(icon);
  }
}

const iconManager = new IconManager();

export function useIconManager() {
  return {
    getIcon: (name: string) => iconManager.getIcon(name),
    getAllIcons: () => iconManager.getAllIcons(),
    getIconsByCategory: (category: string) =>
      iconManager.getIconsByCategory(category),
    getCategories: () => iconManager.getCategories(),
    searchIcons: (query: string) =>
      iconManager.searchIcons(query),
    registerIcon: (icon: IconDefinition) =>
      iconManager.registerIcon(icon),
  };
}

統合アイコンコンポーネント

vue<!-- components/icons/Icon.vue -->
<template>
  <svg
    v-if="iconData"
    :width="size"
    :height="size"
    :viewBox="iconData.viewBox"
    :class="computedClasses"
    :style="computedStyles"
    v-bind="$attrs"
  >
    <path
      v-for="(path, index) in iconData.paths"
      :key="index"
      :d="path.d"
      :fill="path.fill || fillColor"
      :stroke="path.stroke || strokeColor"
      :stroke-width="path.strokeWidth || strokeWidth"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
  <div v-else-if="showPlaceholder" class="icon-placeholder">
    {{ name }}
  </div>
</template>

<script setup lang="ts">
import { useIconManager } from '@/composables/useIconManager';

interface Props {
  name: string;
  size?: number | string;
  fillColor?: string;
  strokeColor?: string;
  strokeWidth?: number;
  variant?:
    | 'default'
    | 'primary'
    | 'secondary'
    | 'success'
    | 'warning'
    | 'danger';
  class?: string;
  showPlaceholder?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  size: 24,
  fillColor: 'none',
  strokeColor: 'currentColor',
  strokeWidth: 2,
  variant: 'default',
  class: '',
  showPlaceholder: true,
});

const { getIcon } = useIconManager();

const iconData = computed(() => {
  return getIcon(props.name);
});

const computedClasses = computed(() => {
  return ['icon', `icon--${props.variant}`, props.class];
});

const computedStyles = computed(() => {
  const variantColors = {
    default: 'currentColor',
    primary: '#3b82f6',
    secondary: '#6b7280',
    success: '#10b981',
    warning: '#f59e0b',
    danger: '#ef4444',
  };

  return {
    color: variantColors[props.variant],
  };
});
</script>

<style scoped>
.icon {
  display: inline-block;
  vertical-align: middle;
  flex-shrink: 0;
}

.icon-placeholder {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: #f3f4f6;
  border: 1px dashed #d1d5db;
  border-radius: 4px;
  color: #6b7280;
  font-size: 10px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
</style>

動的スタイリング実装

テーマ対応アイコンシステム

typescript// composables/useTheme.ts
export interface Theme {
  name: string;
  colors: {
    primary: string;
    secondary: string;
    success: string;
    warning: string;
    danger: string;
    background: string;
    foreground: string;
  };
}

const themes: Record<string, Theme> = {
  light: {
    name: 'light',
    colors: {
      primary: '#3b82f6',
      secondary: '#6b7280',
      success: '#10b981',
      warning: '#f59e0b',
      danger: '#ef4444',
      background: '#ffffff',
      foreground: '#1f2937',
    },
  },
  dark: {
    name: 'dark',
    colors: {
      primary: '#60a5fa',
      secondary: '#9ca3af',
      success: '#34d399',
      warning: '#fbbf24',
      danger: '#f87171',
      background: '#1f2937',
      foreground: '#f9fafb',
    },
  },
};

export function useTheme() {
  const currentTheme = ref<string>('light');

  const theme = computed(() => themes[currentTheme.value]);

  function setTheme(themeName: string) {
    if (themes[themeName]) {
      currentTheme.value = themeName;
      updateCSSVariables(themes[themeName]);
    }
  }

  function updateCSSVariables(theme: Theme) {
    const root = document.documentElement;
    Object.entries(theme.colors).forEach(([key, value]) => {
      root.style.setProperty(`--color-${key}`, value);
    });
  }

  // 初期テーマの適用
  onMounted(() => {
    updateCSSVariables(theme.value);
  });

  return {
    currentTheme: readonly(currentTheme),
    theme: readonly(theme),
    setTheme,
    availableThemes: Object.keys(themes),
  };
}

レスポンシブ対応アイコン

vue<!-- components/icons/ResponsiveIcon.vue -->
<template>
  <Icon
    :name="name"
    :size="responsiveSize"
    :class="responsiveClasses"
    v-bind="$attrs"
  />
</template>

<script setup lang="ts">
import Icon from './Icon.vue';

interface Props {
  name: string;
  sizes?: {
    sm?: number;
    md?: number;
    lg?: number;
    xl?: number;
  };
  class?: string;
}

const props = withDefaults(defineProps<Props>(), {
  sizes: () => ({
    sm: 16,
    md: 20,
    lg: 24,
    xl: 32,
  }),
  class: '',
});

const windowWidth = ref(0);

// ウィンドウサイズの監視
function updateWindowWidth() {
  windowWidth.value = window.innerWidth;
}

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

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

const responsiveSize = computed(() => {
  if (windowWidth.value >= 1280) {
    return props.sizes.xl || 32;
  } else if (windowWidth.value >= 1024) {
    return props.sizes.lg || 24;
  } else if (windowWidth.value >= 768) {
    return props.sizes.md || 20;
  } else {
    return props.sizes.sm || 16;
  }
});

const responsiveClasses = computed(() => {
  const breakpoint =
    windowWidth.value >= 1280
      ? 'xl'
      : windowWidth.value >= 1024
      ? 'lg'
      : windowWidth.value >= 768
      ? 'md'
      : 'sm';

  return [
    'responsive-icon',
    `responsive-icon--${breakpoint}`,
    props.class,
  ];
});
</script>

<style scoped>
.responsive-icon {
  transition: all 0.2s ease;
}

@media (max-width: 640px) {
  .responsive-icon--sm {
    transform: scale(0.9);
  }
}

@media (min-width: 1280px) {
  .responsive-icon--xl {
    transform: scale(1.1);
  }
}
</style>

TypeScript 対応

型安全なアイコンシステム

typescript// types/icon-names.ts
export type IconName =
  | 'home'
  | 'mail'
  | 'settings'
  | 'user'
  | 'heart'
  | 'star'
  | 'search'
  | 'menu'
  | 'close'
  | 'arrow-right'
  | 'arrow-left'
  | 'arrow-up'
  | 'arrow-down'
  | 'check'
  | 'x'
  | 'plus'
  | 'minus'
  | 'edit'
  | 'delete'
  | 'save'
  | 'cancel'
  | 'loading'
  | 'warning'
  | 'error'
  | 'success'
  | 'info';

export interface IconProps {
  name: IconName;
  size?: number | string;
  variant?:
    | 'default'
    | 'primary'
    | 'secondary'
    | 'success'
    | 'warning'
    | 'danger';
  class?: string;
}

型安全なコンポーネント定義

vue<!-- components/icons/TypeSafeIcon.vue -->
<template>
  <Icon
    :name="name"
    :size="size"
    :variant="variant"
    :class="class"
    v-bind="$attrs"
  />
</template>

<script setup lang="ts">
import Icon from './Icon.vue';
import type {
  IconName,
  IconProps,
} from '@/types/icon-names';

// 型安全なProps定義
interface Props extends IconProps {
  name: IconName; // 型制約により、存在しないアイコン名を防ぐ
}

defineProps<Props>();
</script>

アイコン名の自動補完サポート

typescript// utils/icon-helpers.ts
import type { IconName } from '@/types/icon-names';

// アイコン名のバリデーション
export function isValidIconName(
  name: string
): name is IconName {
  const validNames: IconName[] = [
    'home',
    'mail',
    'settings',
    'user',
    'heart',
    'star',
    'search',
    'menu',
    'close',
    'arrow-right',
    'arrow-left',
    'arrow-up',
    'arrow-down',
    'check',
    'x',
    'plus',
    'minus',
    'edit',
    'delete',
    'save',
    'cancel',
    'loading',
    'warning',
    'error',
    'success',
    'info',
  ];
  return validNames.includes(name as IconName);
}

// 開発時のアイコン名チェック
export function validateIconName(name: string): void {
  if (!isValidIconName(name)) {
    console.warn(
      `Invalid icon name: ${name}. Available icons:`,
      getAvailableIconNames()
    );
  }
}

export function getAvailableIconNames(): IconName[] {
  return [
    'home',
    'mail',
    'settings',
    'user',
    'heart',
    'star',
    'search',
    'menu',
    'close',
    'arrow-right',
    'arrow-left',
    'arrow-up',
    'arrow-down',
    'check',
    'x',
    'plus',
    'minus',
    'edit',
    'delete',
    'save',
    'cancel',
    'loading',
    'warning',
    'error',
    'success',
    'info',
  ];
}

これらの具体例により、Vue.js アプリケーションで SVG アイコンを効率的に管理し、活用できるシステムが構築できます。型安全性とパフォーマンスを両立した実装となっています。

まとめ

Vue.js で SVG アイコンを自在に扱うための様々なテクニックをご紹介いたしました。基本的なコンポーネント化から始まり、動的制御、ライブラリ構築、最適化手法まで段階的に解説いたしました。

重要なポイントの振り返り

コンポーネント化のメリット

SVG アイコンを Vue コンポーネント化することで得られる主な利点:

メリット詳細効果
再利用性一度作成したアイコンを複数箇所で使用可能開発効率の向上
保守性アイコンの修正が一箇所で完結メンテナンスコストの削減
型安全性TypeScript による型チェックバグの早期発見
カスタマイズ性プロパティによる柔軟な制御デザインシステムとの整合

パフォーマンス最適化の効果

適切な最適化手法により、以下の改善が期待できます:

  • バンドルサイズの削減(平均 30-50%の軽量化)
  • 初回読み込み時間の短縮
  • レンダリング性能の向上
  • メモリ使用量の最適化

実装時の推奨手順

  1. 基本コンポーネントの作成 - シンプルなアイコンコンポーネントから開始
  2. プロパティ設計 - 柔軟性と使いやすさのバランスを考慮
  3. ライブラリ構築 - アイコンの体系的管理システムを導入
  4. 最適化実装 - パフォーマンス要件に応じた最適化手法を選択
  5. TypeScript 対応 - 型安全性の確保と開発体験の向上

今後の展望

SVG アイコンシステムは、以下の方向で更なる発展が期待されます:

自動化の推進

  • デザインファイルからのアイコン自動生成
  • CI/CD パイプラインでの最適化処理
  • アクセシビリティチェックの自動化

開発体験の向上

  • IDE 拡張による視覚的なアイコン選択
  • リアルタイムプレビュー機能
  • 自動補完とドキュメント生成

パフォーマンス最適化

  • WebAssembly を活用した高速処理
  • Service Worker による高度なキャッシング
  • HTTP/3 対応による配信最適化

Vue.js での SVG アイコン活用は、適切な設計と実装により、アプリケーションの品質向上に大きく貢献します。本記事でご紹介したテクニックを参考に、プロジェクトに最適なアイコンシステムを構築していただければと思います。

継続的な改善と最新技術の導入により、より良いユーザーエクスペリエンスの実現を目指してください。

関連リンク