T-CREATOR

Vue.js で始める Atomic Design とコンポーネント設計

Vue.js で始める Atomic Design とコンポーネント設計

Vue.js プロジェクトでコンポーネント設計を考えるとき、多くの開発者が直面する課題があります。コンポーネントの粒度をどう決めるか、再利用性をどう高めるか、チーム開発での一貫性をどう保つか。これらの課題を解決するのが Atomic Design という設計思想です。

この記事では、Vue.js で Atomic Design を実践する方法を、実際のコード例と共に詳しく解説します。初心者の方でも理解しやすいように、段階的に進めていきましょう。

Atomic Design とは何か

Atomic Design は、Brad Frost が提唱したデザインシステムの構築方法です。化学の原子・分子・有機体の概念を UI デザインに応用したもので、以下の 5 つの階層で構成されています。

階層説明
Atoms(原子)最小単位の UI 要素ボタン、入力フィールド、ラベル
Molecules(分子)複数の原子を組み合わせた要素検索フォーム、カードヘッダー
Organisms(有機体)複数の分子を組み合わせた複雑な要素ヘッダー、サイドバー、商品リスト
Templates(テンプレート)ページの構造を定義レイアウト、グリッドシステム
Pages(ページ)実際のコンテンツを含むページ商品詳細ページ、ログインページ

この設計思想の最大のメリットは、コンポーネントの再利用性と保守性を大幅に向上させることです。

Vue.js プロジェクトでの Atomic Design 実装

プロジェクト構造の設計

まず、Atomic Design に基づいたディレクトリ構造を作成しましょう。

bashsrc/
├── components/
│   ├── atoms/
│   │   ├── BaseButton.vue
│   │   ├── BaseInput.vue
│   │   └── BaseLabel.vue
│   ├── molecules/
│   │   ├── SearchForm.vue
│   │   ├── ProductCard.vue
│   │   └── UserProfile.vue
│   ├── organisms/
│   │   ├── Header.vue
│   │   ├── Sidebar.vue
│   │   └── ProductList.vue
│   ├── templates/
│   │   ├── DefaultLayout.vue
│   │   └── DashboardLayout.vue
│   └── pages/
│       ├── HomePage.vue
│       └── ProductDetailPage.vue

この構造により、各コンポーネントの役割が明確になり、開発チーム全体で一貫した設計が可能になります。

Atoms(原子)レベルのコンポーネント作成

最も基本的な UI 要素から始めましょう。まず、再利用可能なボタンコンポーネントを作成します。

vue<!-- src/components/atoms/BaseButton.vue -->
<template>
  <button
    :class="buttonClasses"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

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

// Props の定義
const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) =>
      ['primary', 'secondary', 'danger'].includes(value),
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) =>
      ['small', 'medium', 'large'].includes(value),
  },
  disabled: {
    type: Boolean,
    default: false,
  },
});

// Emits の定義
const emit = defineEmits(['click']);

// 動的クラス名の計算
const buttonClasses = computed(() => [
  'base-button',
  `base-button--${props.variant}`,
  `base-button--${props.size}`,
  { 'base-button--disabled': props.disabled },
]);

// クリックハンドラー
const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event);
  }
};
</script>

<style scoped>
.base-button {
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease;
}

.base-button--primary {
  background-color: #007bff;
  color: white;
}

.base-button--primary:hover {
  background-color: #0056b3;
}

.base-button--secondary {
  background-color: #6c757d;
  color: white;
}

.base-button--danger {
  background-color: #dc3545;
  color: white;
}

.base-button--small {
  padding: 8px 16px;
  font-size: 14px;
}

.base-button--medium {
  padding: 12px 24px;
  font-size: 16px;
}

.base-button--large {
  padding: 16px 32px;
  font-size: 18px;
}

.base-button--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

この BaseButton コンポーネントは、variant、size、disabled の props を受け取り、様々なスタイルのボタンを生成できます。slot を使用することで、ボタンのテキストを柔軟に設定できるのも特徴です。

次に、入力フィールドのコンポーネントを作成しましょう。

vue<!-- src/components/atoms/BaseInput.vue -->
<template>
  <div class="base-input-wrapper">
    <label
      v-if="label"
      :for="inputId"
      class="base-input__label"
    >
      {{ label }}
      <span v-if="required" class="base-input__required"
        >*</span
      >
    </label>
    <input
      :id="inputId"
      :type="type"
      :value="modelValue"
      :placeholder="placeholder"
      :disabled="disabled"
      :class="inputClasses"
      @input="handleInput"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <div v-if="error" class="base-input__error">
      {{ error }}
    </div>
  </div>
</template>

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

const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: '',
  },
  label: {
    type: String,
    default: '',
  },
  type: {
    type: String,
    default: 'text',
  },
  placeholder: {
    type: String,
    default: '',
  },
  required: {
    type: Boolean,
    default: false,
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  error: {
    type: String,
    default: '',
  },
});

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

// ユニークな ID の生成
const inputId = computed(
  () => `input-${Math.random().toString(36).substr(2, 9)}`
);

// 入力フィールドのクラス名
const inputClasses = computed(() => [
  'base-input',
  { 'base-input--error': props.error },
  { 'base-input--disabled': props.disabled },
]);

// イベントハンドラー
const handleInput = (event) => {
  emit('update:modelValue', event.target.value);
};

const handleFocus = (event) => {
  emit('focus', event);
};

const handleBlur = (event) => {
  emit('blur', event);
};
</script>

<style scoped>
.base-input-wrapper {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.base-input__label {
  font-weight: 500;
  color: #333;
}

.base-input__required {
  color: #dc3545;
}

.base-input {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.2s ease;
}

.base-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.base-input--error {
  border-color: #dc3545;
}

.base-input--error:focus {
  border-color: #dc3545;
  box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}

.base-input--disabled {
  background-color: #f8f9fa;
  cursor: not-allowed;
}

.base-input__error {
  color: #dc3545;
  font-size: 14px;
}
</style>

Molecules(分子)レベルのコンポーネント作成

原子レベルのコンポーネントを組み合わせて、より複雑な機能を持つコンポーネントを作成しましょう。

vue<!-- src/components/molecules/SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchQuery"
      placeholder="検索キーワードを入力..."
      :error="error"
      @focus="clearError"
    />
    <BaseButton
      type="submit"
      variant="primary"
      :disabled="!searchQuery.trim()"
    >
      検索
    </BaseButton>
  </form>
</template>

<script setup>
import { ref } from 'vue';
import BaseInput from '../atoms/BaseInput.vue';
import BaseButton from '../atoms/BaseButton.vue';

const props = defineProps({
  initialQuery: {
    type: String,
    default: '',
  },
});

const emit = defineEmits(['search']);

const searchQuery = ref(props.initialQuery);
const error = ref('');

const handleSubmit = () => {
  const query = searchQuery.value.trim();
  if (!query) {
    error.value = '検索キーワードを入力してください';
    return;
  }

  emit('search', query);
};

const clearError = () => {
  error.value = '';
};
</script>

<style scoped>
.search-form {
  display: flex;
  gap: 12px;
  align-items: flex-end;
}

.search-form .base-input-wrapper {
  flex: 1;
}
</style>

この SearchForm コンポーネントは、BaseInput と BaseButton を組み合わせて、検索機能を提供します。バリデーション機能も含まれており、空の検索クエリを防ぐことができます。

次に、商品カードのコンポーネントを作成しましょう。

vue<!-- src/components/molecules/ProductCard.vue -->
<template>
  <div class="product-card">
    <img
      :src="product.image"
      :alt="product.name"
      class="product-card__image"
      @error="handleImageError"
    />
    <div class="product-card__content">
      <h3 class="product-card__title">{{ product.name }}</h3>
      <p class="product-card__description">{{ product.description }}</p>
      <div class="product-card__price">¥{{ formatPrice(product.price) }}</div>
      <div class="product-card__actions">
        <BaseButton
          variant="primary"
          size="small"
          @click="handleAddToCart"
        >
          カートに追加
        </BaseButton>
        <BaseButton
          variant="secondary"
          size="small"
          @click="handleViewDetail"
        >
          詳細を見る
        </BaseButton>
      </div>
    </div>
  </div>
</template>

<script setup>
import BaseButton from '../atoms/BaseButton.vue'

const props = defineProps({
  product: {
    type: Object,
    required: true,
    validator: (product) => {
      return product.name && product.price !== undefined
    }
  }
})

const emit = defineEmits(['add-to-cart', 'view-detail'])

const formatPrice = (price) => {
  return price.toLocaleString()
}

const handleAddToCart = () => {
  emit('add-to-cart', props.product)
}

const handleViewDetail = () => {
  emit('view-detail', props.product)
}

const handleImageError = (event) => {
  event.target.src = '/images/placeholder.jpg'
}
</style>

<style scoped>
.product-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  transition: box-shadow 0.2s ease;
}

.product-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product-card__image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.product-card__content {
  padding: 16px;
}

.product-card__title {
  margin: 0 0 8px 0;
  font-size: 18px;
  font-weight: 600;
}

.product-card__description {
  margin: 0 0 12px 0;
  color: #666;
  font-size: 14px;
  line-height: 1.4;
}

.product-card__price {
  font-size: 20px;
  font-weight: 700;
  color: #007bff;
  margin-bottom: 16px;
}

.product-card__actions {
  display: flex;
  gap: 8px;
}
</style>

Organisms(有機体)レベルのコンポーネント作成

分子レベルのコンポーネントを組み合わせて、より大きな機能単位を作成しましょう。

vue<!-- src/components/organisms/Header.vue -->
<template>
  <header class="header">
    <div class="header__container">
      <div class="header__logo">
        <router-link to="/" class="header__logo-link">
          <img
            src="/logo.svg"
            alt="ロゴ"
            class="header__logo-image"
          />
          <span class="header__logo-text">MyStore</span>
        </router-link>
      </div>

      <nav class="header__nav">
        <router-link
          v-for="item in navigationItems"
          :key="item.path"
          :to="item.path"
          class="header__nav-link"
          :class="{
            'header__nav-link--active': isActive(item.path),
          }"
        >
          {{ item.label }}
        </router-link>
      </nav>

      <div class="header__actions">
        <SearchForm @search="handleSearch" />
        <div class="header__user-menu">
          <BaseButton
            v-if="!isLoggedIn"
            variant="secondary"
            size="small"
            @click="handleLogin"
          >
            ログイン
          </BaseButton>
          <div v-else class="header__user-dropdown">
            <BaseButton
              variant="secondary"
              size="small"
              @click="toggleUserMenu"
            >
              {{ user.name }}
            </BaseButton>
            <div
              v-if="showUserMenu"
              class="header__user-dropdown-menu"
            >
              <a
                href="/profile"
                class="header__dropdown-item"
                >プロフィール</a
              >
              <a
                href="/orders"
                class="header__dropdown-item"
                >注文履歴</a
              >
              <button
                class="header__dropdown-item"
                @click="handleLogout"
              >
                ログアウト
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </header>
</template>

<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SearchForm from '../molecules/SearchForm.vue';
import BaseButton from '../atoms/BaseButton.vue';

const route = useRoute();
const router = useRouter();

const showUserMenu = ref(false);

// ナビゲーション項目の定義
const navigationItems = [
  { path: '/', label: 'ホーム' },
  { path: '/products', label: '商品一覧' },
  { path: '/categories', label: 'カテゴリー' },
  { path: '/about', label: '会社概要' },
];

// ユーザー情報(実際のアプリでは Vuex や Pinia から取得)
const isLoggedIn = ref(false);
const user = ref({ name: 'ユーザー名' });

const isActive = (path) => {
  return route.path === path;
};

const handleSearch = (query) => {
  router.push({ path: '/search', query: { q: query } });
};

const handleLogin = () => {
  router.push('/login');
};

const handleLogout = () => {
  // ログアウト処理
  isLoggedIn.value = false;
  showUserMenu.value = false;
};

const toggleUserMenu = () => {
  showUserMenu.value = !showUserMenu.value;
};
</script>

<style scoped>
.header {
  background-color: white;
  border-bottom: 1px solid #ddd;
  position: sticky;
  top: 0;
  z-index: 100;
}

.header__container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  display: flex;
  align-items: center;
  gap: 24px;
  height: 64px;
}

.header__logo-link {
  display: flex;
  align-items: center;
  gap: 8px;
  text-decoration: none;
  color: inherit;
}

.header__logo-image {
  height: 32px;
}

.header__logo-text {
  font-size: 20px;
  font-weight: 700;
  color: #007bff;
}

.header__nav {
  display: flex;
  gap: 24px;
}

.header__nav-link {
  text-decoration: none;
  color: #333;
  font-weight: 500;
  padding: 8px 0;
  border-bottom: 2px solid transparent;
  transition: border-color 0.2s ease;
}

.header__nav-link:hover,
.header__nav-link--active {
  border-bottom-color: #007bff;
}

.header__actions {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-left: auto;
}

.header__user-dropdown {
  position: relative;
}

.header__user-dropdown-menu {
  position: absolute;
  top: 100%;
  right: 0;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  min-width: 150px;
  z-index: 10;
}

.header__dropdown-item {
  display: block;
  width: 100%;
  padding: 12px 16px;
  text-decoration: none;
  color: #333;
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
}

.header__dropdown-item:hover {
  background-color: #f8f9fa;
}
</style>

Templates(テンプレート)レベルのコンポーネント作成

ページの構造を定義するテンプレートコンポーネントを作成しましょう。

vue<!-- src/components/templates/DefaultLayout.vue -->
<template>
  <div class="default-layout">
    <Header />
    <main class="default-layout__main">
      <div class="default-layout__container">
        <slot />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script setup>
import Header from '../organisms/Header.vue';
import Footer from '../organisms/Footer.vue';
</script>

<style scoped>
.default-layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.default-layout__main {
  flex: 1;
  padding: 24px 0;
}

.default-layout__container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}
</style>

Pages(ページ)レベルのコンポーネント作成

最後に、実際のコンテンツを含むページコンポーネントを作成しましょう。

vue<!-- src/components/pages/HomePage.vue -->
<template>
  <DefaultLayout>
    <section class="hero-section">
      <h1 class="hero-section__title">
        最高品質の商品をお届けします
      </h1>
      <p class="hero-section__subtitle">
        豊富な商品ラインナップから、お気に入りの商品を見つけてください
      </p>
      <BaseButton
        variant="primary"
        size="large"
        @click="handleShopNow"
      >
        今すぐショッピング
      </BaseButton>
    </section>

    <section class="featured-products">
      <h2 class="section-title">おすすめ商品</h2>
      <div class="products-grid">
        <ProductCard
          v-for="product in featuredProducts"
          :key="product.id"
          :product="product"
          @add-to-cart="handleAddToCart"
          @view-detail="handleViewDetail"
        />
      </div>
    </section>

    <section class="categories-section">
      <h2 class="section-title">カテゴリーから探す</h2>
      <div class="categories-grid">
        <div
          v-for="category in categories"
          :key="category.id"
          class="category-card"
          @click="handleCategoryClick(category)"
        >
          <img
            :src="category.image"
            :alt="category.name"
            class="category-card__image"
          />
          <h3 class="category-card__title">
            {{ category.name }}
          </h3>
        </div>
      </div>
    </section>
  </DefaultLayout>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import DefaultLayout from '../templates/DefaultLayout.vue';
import ProductCard from '../molecules/ProductCard.vue';
import BaseButton from '../atoms/BaseButton.vue';

const router = useRouter();

// サンプルデータ
const featuredProducts = ref([
  {
    id: 1,
    name: 'プレミアム商品A',
    description: '最高品質の商品です',
    price: 15000,
    image: '/images/product-a.jpg',
  },
  {
    id: 2,
    name: 'プレミアム商品B',
    description: '人気の商品です',
    price: 12000,
    image: '/images/product-b.jpg',
  },
  {
    id: 3,
    name: 'プレミアム商品C',
    description: 'おすすめの商品です',
    price: 8000,
    image: '/images/product-c.jpg',
  },
]);

const categories = ref([
  {
    id: 1,
    name: 'エレクトロニクス',
    image: '/images/category-electronics.jpg',
  },
  {
    id: 2,
    name: 'ファッション',
    image: '/images/category-fashion.jpg',
  },
  {
    id: 3,
    name: 'ホーム&ガーデン',
    image: '/images/category-home.jpg',
  },
]);

const handleShopNow = () => {
  router.push('/products');
};

const handleAddToCart = (product) => {
  // カートに追加する処理
  console.log('カートに追加:', product);
};

const handleViewDetail = (product) => {
  router.push(`/products/${product.id}`);
};

const handleCategoryClick = (category) => {
  router.push(`/categories/${category.id}`);
};

onMounted(() => {
  // ページ読み込み時の処理
  console.log('ホームページが読み込まれました');
});
</script>

<style scoped>
.hero-section {
  text-align: center;
  padding: 60px 0;
  background: linear-gradient(
    135deg,
    #667eea 0%,
    #764ba2 100%
  );
  color: white;
  margin: -24px -20px 48px -20px;
}

.hero-section__title {
  font-size: 48px;
  font-weight: 700;
  margin-bottom: 16px;
}

.hero-section__subtitle {
  font-size: 20px;
  margin-bottom: 32px;
  opacity: 0.9;
}

.section-title {
  font-size: 32px;
  font-weight: 600;
  margin-bottom: 32px;
  text-align: center;
}

.products-grid {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(300px, 1fr)
  );
  gap: 24px;
  margin-bottom: 60px;
}

.categories-grid {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(250px, 1fr)
  );
  gap: 24px;
}

.category-card {
  position: relative;
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.category-card:hover {
  transform: translateY(-4px);
}

.category-card__image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.category-card__title {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  margin: 0;
  padding: 16px;
  font-size: 18px;
  font-weight: 600;
}
</style>

よくあるエラーとトラブルシューティング

1. コンポーネントの循環依存エラー

bashError: Circular dependency detected

このエラーは、コンポーネントが互いに参照し合っている場合に発生します。

解決方法:

  • 共通のロジックを composables に分離する
  • 依存関係を見直し、循環を避ける
javascript// composables/useValidation.js
import { ref } from 'vue';

export function useValidation() {
  const errors = ref({});

  const validate = (rules, data) => {
    errors.value = {};
    // バリデーションロジック
  };

  return {
    errors,
    validate,
  };
}

2. Props の型エラー

bash[Vue warn]: Invalid prop: type check failed for prop "variant"

解決方法:

  • Props の型定義を正確に行う
  • validator 関数を活用する
vue<script setup>
const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => {
      return ['primary', 'secondary', 'danger'].includes(
        value
      );
    },
  },
});
</script>

3. スロットの未定義エラー

bash[Vue warn]: Slot "default" invoked outside of a component context

解決方法:

  • スロットの存在確認を行う
vue<template>
  <div class="component">
    <slot name="header" />
    <div v-if="$slots.default" class="content">
      <slot />
    </div>
    <slot name="footer" />
  </div>
</template>

Atomic Design のベストプラクティス

1. 命名規則の統一

コンポーネントの命名は一貫性を保つことが重要です。

bash# 良い例
BaseButton.vue
BaseInput.vue
ProductCard.vue
UserProfile.vue

# 避けるべき例
button.vue
input-field.vue
product-card.vue
user-profile.vue

2. Props の設計原則

  • 必要最小限の props にする
  • デフォルト値を設定する
  • 型チェックを行う
  • バリデーションを追加する
vue<script setup>
const props = defineProps({
  // 必須の props
  title: {
    type: String,
    required: true,
  },

  // オプションの props(デフォルト値付き)
  variant: {
    type: String,
    default: 'primary',
    validator: (value) =>
      ['primary', 'secondary'].includes(value),
  },

  // 複数の型を許可
  value: {
    type: [String, Number],
    default: '',
  },
});
</script>

3. イベントの設計

  • 意味のあるイベント名を使用する
  • 必要なデータを渡す
  • カスタムイベントを活用する
vue<script setup>
const emit = defineEmits([
  'update:modelValue',
  'submit',
  'cancel',
]);

const handleSubmit = () => {
  emit('submit', {
    data: formData.value,
    timestamp: Date.now(),
  });
};
</script>

パフォーマンス最適化のポイント

1. コンポーネントの遅延読み込み

大きなコンポーネントは遅延読み込みを検討しましょう。

javascript// router/index.js
const HomePage = () =>
  import('../components/pages/HomePage.vue');
const ProductDetailPage = () =>
  import('../components/pages/ProductDetailPage.vue');

2. 不要な再レンダリングの防止

v-memocomputed を活用して、不要な再レンダリングを防ぎます。

vue<template>
  <div>
    <ProductCard
      v-for="product in products"
      :key="product.id"
      :product="product"
      v-memo="[product.id, product.price]"
    />
  </div>
</template>

3. スタイルの最適化

CSS の重複を避け、効率的なスタイリングを行います。

vue<style scoped>
/* 共通のスタイルは CSS 変数として定義 */
:root {
  --primary-color: #007bff;
  --border-radius: 4px;
  --transition: all 0.2s ease;
}

.component {
  border-radius: var(--border-radius);
  transition: var(--transition);
}
</style>

まとめ

Vue.js で Atomic Design を実践することで、以下のようなメリットを得ることができます。

再利用性の向上

  • 小さなコンポーネントから大きなコンポーネントまで、一貫した設計で再利用が容易になります。

保守性の向上

  • 各コンポーネントの役割が明確になり、修正や機能追加が簡単になります。

チーム開発の効率化

  • 統一された設計思想により、チーム全体での開発効率が向上します。

品質の向上

  • コンポーネントのテストが容易になり、バグの早期発見が可能になります。

Atomic Design は、最初は複雑に感じるかもしれませんが、一度身につけると、Vue.js プロジェクトの開発効率と品質を大幅に向上させることができます。この記事で紹介した実践例を参考に、あなたのプロジェクトでも Atomic Design を導入してみてください。

関連リンク