Vue.js

プログレッシブJavaScriptフレームワーク。段階的に導入可能で学習コストが低く、双方向データバインディングとコンポーネントシステムを提供する。

JavaScriptフレームワークリアクティブプログレッシブコンポーネントSPAUI

GitHub概要

vuejs/core

🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

ホームページ:https://vuejs.org/
スター51,272
ウォッチ754
フォーク8,824
作成日:2018年6月12日
言語:TypeScript
ライセンス:MIT License

トピックス

なし

スター履歴

vuejs/core Star History
データ取得日時: 2025/8/13 01:43

フレームワーク

Vue.js

概要

Vue.jsは、プログレッシブなJavaScriptフレームワークで、Webユーザーインターフェースを構築するために設計されています。軽量で学習しやすく、段階的な導入が可能です。

詳細

Vue.js(ヴュー・ジェーエス)は、2014年にEvan Youによって開発されたプログレッシブJavaScriptフレームワークです。「プログレッシブ」とは、既存のプロジェクトに段階的に導入できることを意味し、小規模なアプリケーションから大規模なSPAまで幅広く対応できます。Vue 3では、Composition APIの導入により、コンポーネントロジックの組織化と再利用性が大幅に向上しました。主な特徴として、リアクティブデータバインディング(Proxyベースの反応性システム)、コンポーネント指向アーキテクチャ、仮想DOM、シングルファイルコンポーネント(SFC)、Options APIとComposition APIの選択肢、Vue Router(公式ルーター)、Vuex/Pinia(状態管理)などがあります。Vue.jsは学習コストが低く、直感的なテンプレート構文を持ち、TypeScriptサポートも充実しています。エコシステムも豊富で、Nuxt.js、Vite、VuePress、VitePress等の優秀なツールが揃っており、GitLab、Adobe、Nintendo、BMW等の企業でも広く採用されています。

メリット・デメリット

メリット

  • 学習しやすさ: 直感的なテンプレート構文と段階的な導入が可能
  • 高い柔軟性: プログレッシブフレームワークとして部分的な利用から本格的なSPAまで対応
  • 優れたパフォーマンス: 軽量な仮想DOMとコンパイル時最適化
  • リアクティブシステム: Vue 3のProxy-ベース反応性による効率的なデータバインディング
  • 豊富なエコシステム: Vue Router、Pinia、Nuxt.js、Vite等の高品質ツール群
  • TypeScriptサポート: 優秀な型推論とVolarによる開発体験
  • コンポーネント指向: 再利用可能なコンポーネントアーキテクチャ
  • 優れたドキュメント: 詳細で分かりやすい公式ドキュメント

デメリット

  • React/Angularと比較したエコシステム: サードパーティライブラリの選択肢が相対的に少ない
  • 大企業での採用: 大規模エンタープライズでの採用事例が相対的に少ない
  • 人材の確保: React/Angularと比較してVue.js専門の開発者が少ない
  • バージョン変更の影響: Vue 2から3への移行で一部のブレーキングチェンジ
  • パフォーマンス調整: 大規模アプリケーションでの最適化が複雑になる場合
  • 国際化: React等と比較して海外でのコミュニティが小さい

主要リンク

書き方の例

Hello World

<!-- App.vue -->
<template>
  <div class="app">
    <h1>{{ greeting }}</h1>
    <p>カウント: {{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">リセット</button>
    
    <!-- 条件付きレンダリング -->
    <p v-if="count > 10" class="warning">
      カウントが高くなっています!
    </p>
    <p v-else-if="count < 0" class="error">
      カウントが負の値です!
    </p>
  </div>
</template>

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

// リアクティブな状態
const count = ref(0)
const name = ref('Vue.js')

// 算出プロパティ
const greeting = computed(() => {
  return `${name.value} へようこそ!`
})

// メソッド
function increment() {
  count.value++
}

function decrement() {
  count.value--
}

function reset() {
  count.value = 0
}
</script>

<style scoped>
.app {
  text-align: center;
  padding: 2rem;
  font-family: Arial, sans-serif;
}

h1 {
  color: #42b883;
  margin-bottom: 1rem;
}

button {
  margin: 0 0.5rem;
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #369870;
}

.warning {
  color: #f56565;
}

.error {
  color: #e53e3e;
}
</style>

コンポーネント作成とProps

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name" class="avatar" />
    <div class="user-info">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
      <span class="role" :class="roleClass">{{ user.role }}</span>
    </div>
    <div class="actions">
      <button @click="$emit('edit', user)" class="btn btn-primary">
        編集
      </button>
      <button @click="$emit('delete', user.id)" class="btn btn-danger">
        削除
      </button>
    </div>
  </div>
</template>

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

// Props定義
const props = defineProps({
  user: {
    type: Object,
    required: true,
    validator: (user) => {
      return user && user.id && user.name && user.email
    }
  }
})

// Emits定義
const emit = defineEmits(['edit', 'delete'])

// 算出プロパティ
const roleClass = computed(() => {
  const roleMap = {
    admin: 'role-admin',
    user: 'role-user',
    guest: 'role-guest'
  }
  return roleMap[props.user.role] || 'role-default'
})
</script>

<style scoped>
.user-card {
  display: flex;
  align-items: center;
  padding: 1rem;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  gap: 1rem;
  margin-bottom: 1rem;
}

.avatar {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  object-fit: cover;
}

.user-info {
  flex: 1;
}

.user-info h3 {
  margin: 0 0 0.5rem 0;
  color: #2d3748;
}

.user-info p {
  margin: 0;
  color: #718096;
}

.role {
  display: inline-block;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.875rem;
  font-weight: 500;
}

.role-admin {
  background-color: #fed7d7;
  color: #c53030;
}

.role-user {
  background-color: #bee3f8;
  color: #2b6cb0;
}

.role-guest {
  background-color: #f7fafc;
  color: #4a5568;
}

.actions {
  display: flex;
  gap: 0.5rem;
}

.btn {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.875rem;
}

.btn-primary {
  background-color: #4299e1;
  color: white;
}

.btn-danger {
  background-color: #f56565;
  color: white;
}
</style>

リアクティブ状態とウォッチャー

<!-- UserList.vue -->
<template>
  <div class="user-list">
    <div class="filters">
      <input
        v-model="searchQuery"
        type="text"
        placeholder="ユーザーを検索..."
        class="search-input"
      />
      <select v-model="selectedRole" class="role-filter">
        <option value="">すべての役割</option>
        <option value="admin">管理者</option>
        <option value="user">ユーザー</option>
        <option value="guest">ゲスト</option>
      </select>
    </div>

    <div class="stats">
      <p>全{{ users.length }}人中{{ filteredUsers.length }}人を表示</p>
    </div>

    <div v-if="loading" class="loading">
      読み込み中...
    </div>

    <div v-else class="users">
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        :user="user"
        @edit="editUser"
        @delete="deleteUser"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import UserCard from './UserCard.vue'

// リアクティブ状態
const users = ref([])
const searchQuery = ref('')
const selectedRole = ref('')
const loading = ref(false)

// 算出プロパティ
const filteredUsers = computed(() => {
  let filtered = users.value

  // 検索クエリでフィルタリング
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase()
    filtered = filtered.filter(user =>
      user.name.toLowerCase().includes(query) ||
      user.email.toLowerCase().includes(query)
    )
  }

  // 役割でフィルタリング
  if (selectedRole.value) {
    filtered = filtered.filter(user => user.role === selectedRole.value)
  }

  return filtered
})

// ウォッチャー
watch(searchQuery, (newQuery) => {
  console.log('検索クエリが変更されました:', newQuery)
  // デバウンス処理などをここで実装可能
})

watch(selectedRole, (newRole) => {
  console.log('選択された役割が変更されました:', newRole)
})

// 複数の値を監視
watch([searchQuery, selectedRole], ([newQuery, newRole]) => {
  console.log('フィルターが更新されました:', { newQuery, newRole })
})

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

// メソッド
async function fetchUsers() {
  loading.value = true
  try {
    // APIからユーザーデータを取得(モックデータ)
    await new Promise(resolve => setTimeout(resolve, 1000))
    users.value = [
      { id: 1, name: '田中太郎', email: '[email protected]', role: 'admin', avatar: 'https://i.pravatar.cc/150?img=1' },
      { id: 2, name: '佐藤花子', email: '[email protected]', role: 'user', avatar: 'https://i.pravatar.cc/150?img=2' },
      { id: 3, name: '鈴木一郎', email: '[email protected]', role: 'user', avatar: 'https://i.pravatar.cc/150?img=3' },
      { id: 4, name: '山田美咲', email: '[email protected]', role: 'guest', avatar: 'https://i.pravatar.cc/150?img=4' }
    ]
  } catch (error) {
    console.error('ユーザーデータの取得に失敗しました:', error)
  } finally {
    loading.value = false
  }
}

function editUser(user) {
  console.log('ユーザーを編集:', user)
  // 編集ロジックを実装
}

function deleteUser(userId) {
  if (confirm('このユーザーを削除しますか?')) {
    users.value = users.value.filter(user => user.id !== userId)
    console.log('ユーザーを削除しました:', userId)
  }
}
</script>

<style scoped>
.user-list {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.filters {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
}

.search-input,
.role-filter {
  padding: 0.5rem;
  border: 1px solid #d2d6dc;
  border-radius: 4px;
  font-size: 1rem;
}

.search-input {
  flex: 1;
}

.stats {
  margin-bottom: 1rem;
  color: #718096;
}

.loading {
  text-align: center;
  padding: 2rem;
  color: #4a5568;
}

.users {
  display: grid;
  gap: 1rem;
}
</style>

フォーム処理とバリデーション

<!-- ContactForm.vue -->
<template>
  <form @submit.prevent="submitForm" class="contact-form">
    <h2>お問い合わせフォーム</h2>

    <div class="form-group">
      <label for="name">お名前 *</label>
      <input
        id="name"
        v-model="form.name"
        type="text"
        required
        :class="{ error: errors.name }"
        @blur="validateField('name')"
      />
      <span v-if="errors.name" class="error-message">{{ errors.name }}</span>
    </div>

    <div class="form-group">
      <label for="email">メールアドレス *</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        required
        :class="{ error: errors.email }"
        @blur="validateField('email')"
      />
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>

    <div class="form-group">
      <label for="category">カテゴリ</label>
      <select id="category" v-model="form.category">
        <option value="">選択してください</option>
        <option value="general">一般的なお問い合わせ</option>
        <option value="support">サポート</option>
        <option value="business">ビジネス</option>
        <option value="bug">バグ報告</option>
      </select>
    </div>

    <div class="form-group">
      <label for="message">メッセージ *</label>
      <textarea
        id="message"
        v-model="form.message"
        rows="5"
        required
        :class="{ error: errors.message }"
        @blur="validateField('message')"
      ></textarea>
      <span v-if="errors.message" class="error-message">{{ errors.message }}</span>
    </div>

    <div class="form-group">
      <label class="checkbox-label">
        <input
          v-model="form.agreeToTerms"
          type="checkbox"
          required
        />
        利用規約に同意します *
      </label>
      <span v-if="errors.agreeToTerms" class="error-message">{{ errors.agreeToTerms }}</span>
    </div>

    <button type="submit" :disabled="!isFormValid || isSubmitting" class="submit-btn">
      {{ isSubmitting ? '送信中...' : '送信' }}
    </button>

    <div v-if="submitStatus === 'success'" class="success-message">
      お問い合わせを受け付けました。ありがとうございます!
    </div>

    <div v-if="submitStatus === 'error'" class="error-message">
      送信に失敗しました。しばらく後でお試しください。
    </div>
  </form>
</template>

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

// フォームデータ
const form = reactive({
  name: '',
  email: '',
  category: '',
  message: '',
  agreeToTerms: false
})

// エラー状態
const errors = reactive({
  name: '',
  email: '',
  message: '',
  agreeToTerms: ''
})

// UI状態
const isSubmitting = ref(false)
const submitStatus = ref('')

// バリデーション関数
const validators = {
  name: (value) => {
    if (!value) return 'お名前は必須です'
    if (value.length < 2) return 'お名前は2文字以上で入力してください'
    return ''
  },
  email: (value) => {
    if (!value) return 'メールアドレスは必須です'
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(value)) return '有効なメールアドレスを入力してください'
    return ''
  },
  message: (value) => {
    if (!value) return 'メッセージは必須です'
    if (value.length < 10) return 'メッセージは10文字以上で入力してください'
    return ''
  },
  agreeToTerms: (value) => {
    if (!value) return '利用規約への同意が必要です'
    return ''
  }
}

// フィールドバリデーション
function validateField(field) {
  if (validators[field]) {
    errors[field] = validators[field](form[field])
  }
}

// フォーム全体のバリデーション
function validateForm() {
  Object.keys(validators).forEach(field => {
    validateField(field)
  })
}

// フォームの有効性チェック
const isFormValid = computed(() => {
  return Object.values(errors).every(error => !error) &&
         form.name && form.email && form.message && form.agreeToTerms
})

// フォーム送信
async function submitForm() {
  validateForm()
  
  if (!isFormValid.value) {
    return
  }

  isSubmitting.value = true
  submitStatus.value = ''

  try {
    // APIエンドポイントにデータを送信(モック)
    await new Promise(resolve => setTimeout(resolve, 2000))
    
    // 成功時の処理
    submitStatus.value = 'success'
    
    // フォームをリセット
    Object.keys(form).forEach(key => {
      form[key] = typeof form[key] === 'boolean' ? false : ''
    })
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
    
  } catch (error) {
    console.error('送信エラー:', error)
    submitStatus.value = 'error'
  } finally {
    isSubmitting.value = false
  }
}
</script>

<style scoped>
.contact-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #f7fafc;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
  color: #2d3748;
}

input,
select,
textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d2d6dc;
  border-radius: 4px;
  font-size: 1rem;
}

input.error,
textarea.error {
  border-color: #f56565;
}

.checkbox-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.checkbox-label input {
  width: auto;
}

.error-message {
  color: #f56565;
  font-size: 0.875rem;
  margin-top: 0.25rem;
  display: block;
}

.submit-btn {
  background-color: #4299e1;
  color: white;
  padding: 0.75rem 2rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  width: 100%;
}

.submit-btn:disabled {
  background-color: #a0aec0;
  cursor: not-allowed;
}

.success-message {
  background-color: #c6f6d5;
  color: #22543d;
  padding: 1rem;
  border-radius: 4px;
  margin-top: 1rem;
}
</style>

ライフサイクルとComposables

<!-- Dashboard.vue -->
<template>
  <div class="dashboard">
    <h1>ダッシュボード</h1>
    
    <div class="stats-grid">
      <div class="stat-card">
        <h3>総ユーザー数</h3>
        <p class="stat-number">{{ stats.totalUsers }}</p>
      </div>
      <div class="stat-card">
        <h3>アクティブユーザー</h3>
        <p class="stat-number">{{ stats.activeUsers }}</p>
      </div>
      <div class="stat-card">
        <h3>今日の売上</h3>
        <p class="stat-number">¥{{ stats.todayRevenue.toLocaleString() }}</p>
      </div>
    </div>

    <div class="current-time">
      <p>現在時刻: {{ currentTime }}</p>
      <p>オンライン時間: {{ onlineTime }}</p>
    </div>

    <div class="window-size">
      <p>ウィンドウサイズ: {{ windowSize.width }} × {{ windowSize.height }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useStats, useCurrentTime, useWindowSize } from '@/composables'

// Composablesの使用
const { stats, fetchStats } = useStats()
const { currentTime, onlineTime, startTimer, stopTimer } = useCurrentTime()
const { windowSize } = useWindowSize()

// コンポーネント固有の状態
const refreshInterval = ref(null)

// ライフサイクルフック
onMounted(async () => {
  console.log('ダッシュボードがマウントされました')
  
  // 初期データの取得
  await fetchStats()
  
  // タイマー開始
  startTimer()
  
  // 定期的なデータ更新(30秒間隔)
  refreshInterval.value = setInterval(() => {
    fetchStats()
  }, 30000)
})

onUnmounted(() => {
  console.log('ダッシュボードがアンマウントされました')
  
  // タイマー停止
  stopTimer()
  
  // インターバルのクリア
  if (refreshInterval.value) {
    clearInterval(refreshInterval.value)
  }
})
</script>

<style scoped>
.dashboard {
  padding: 2rem;
  max-width: 1200px;
  margin: 0 auto;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 1rem;
  margin-bottom: 2rem;
}

.stat-card {
  background-color: white;
  padding: 1.5rem;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.stat-card h3 {
  margin: 0 0 0.5rem 0;
  color: #4a5568;
  font-size: 0.875rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.stat-number {
  margin: 0;
  font-size: 2rem;
  font-weight: bold;
  color: #2d3748;
}

.current-time,
.window-size {
  background-color: #f7fafc;
  padding: 1rem;
  border-radius: 4px;
  margin-bottom: 1rem;
}
</style>
// composables/useStats.js
import { ref } from 'vue'

export function useStats() {
  const stats = ref({
    totalUsers: 0,
    activeUsers: 0,
    todayRevenue: 0
  })

  const isLoading = ref(false)
  const error = ref(null)

  async function fetchStats() {
    isLoading.value = true
    error.value = null
    
    try {
      // APIからデータを取得(モック)
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      stats.value = {
        totalUsers: Math.floor(Math.random() * 10000) + 1000,
        activeUsers: Math.floor(Math.random() * 1000) + 100,
        todayRevenue: Math.floor(Math.random() * 1000000) + 100000
      }
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  return {
    stats,
    isLoading,
    error,
    fetchStats
  }
}

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

export function useCurrentTime() {
  const currentTime = ref('')
  const onlineTime = ref('')
  const startTime = ref(null)
  let timeInterval = null

  function updateTime() {
    const now = new Date()
    currentTime.value = now.toLocaleString('ja-JP')
    
    if (startTime.value) {
      const diff = now - startTime.value
      const hours = Math.floor(diff / 3600000)
      const minutes = Math.floor((diff % 3600000) / 60000)
      const seconds = Math.floor((diff % 60000) / 1000)
      onlineTime.value = `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
    }
  }

  function startTimer() {
    startTime.value = new Date()
    updateTime()
    timeInterval = setInterval(updateTime, 1000)
  }

  function stopTimer() {
    if (timeInterval) {
      clearInterval(timeInterval)
      timeInterval = null
    }
  }

  onUnmounted(() => {
    stopTimer()
  })

  return {
    currentTime,
    onlineTime,
    startTimer,
    stopTimer
  }
}

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

export function useWindowSize() {
  const windowSize = ref({
    width: 0,
    height: 0
  })

  function updateSize() {
    windowSize.value.width = window.innerWidth
    windowSize.value.height = window.innerHeight
  }

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

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

  return {
    windowSize
  }
}

Vue Router統合

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Dashboard from '@/views/Dashboard.vue'
import Users from '@/views/Users.vue'
import UserDetail from '@/views/UserDetail.vue'
import Contact from '@/views/Contact.vue'
import NotFound from '@/views/NotFound.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: Dashboard,
    meta: { requiresAuth: true }
  },
  {
    path: '/users',
    name: 'Users',
    component: Users
  },
  {
    path: '/users/:id',
    name: 'UserDetail',
    component: UserDetail,
    props: true
  },
  {
    path: '/contact',
    name: 'Contact',
    component: Contact
  },
  {
    path: '/about',
    name: 'About',
    // 遅延読み込み
    component: () => import('@/views/About.vue')
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: NotFound
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// ナビゲーションガード
router.beforeEach((to, from, next) => {
  // 認証が必要なページのチェック
  if (to.meta.requiresAuth) {
    const isAuthenticated = localStorage.getItem('auth-token')
    if (!isAuthenticated) {
      next('/login')
      return
    }
  }
  next()
})

export default router

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')