Vue.js
プログレッシブJavaScriptフレームワーク。段階的に導入可能で学習コストが低く、双方向データバインディングとコンポーネントシステムを提供する。
GitHub概要
vuejs/core
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
トピックス
スター履歴
フレームワーク
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')