Web Development
Vue.js 3 Composition API
Bui Van H
Frontend Developer
5 tháng 1, 202505-01
14p
1,089 lượt xem1k
#Vue.js#Composition API#TypeScript#Frontend
Vue.js 3 Composition API
Khám phá Vue.js 3 Composition API và reactive system mạnh mẽ.
Setup và Basic Composition
VUE
<template>
<div class="user-dashboard">
<header class="dashboard-header">
<h1>Welcome, {{ user.name }}!</h1>
<button @click="toggleTheme" class="theme-toggle">
{{ isDarkMode ? '🌞' : '🌙' }}
</button>
</header>
<div class="stats-grid">
<div v-for="stat in stats" :key="stat.id" class="stat-card">
<h3>{{ stat.label }}</h3>
<p class="stat-value">{{ formatNumber(stat.value) }}</p>
<span class="stat-change" :class="stat.change >= 0 ? 'positive' : 'negative'">
{{ stat.change >= 0 ? '+' : '' }}{{ stat.change }}%
</span>
</div>
</div>
<div class="actions">
<input
v-model="searchQuery"
@input="debouncedSearch"
placeholder="Search users..."
class="search-input"
/>
<button @click="fetchUsers" :disabled="loading" class="fetch-btn">
{{ loading ? 'Loading...' : 'Refresh Data' }}
</button>
</div>
<div class="user-list" v-if="filteredUsers.length">
<TransitionGroup name="list" tag="div">
<div
v-for="user in paginatedUsers"
:key="user.id"
class="user-card"
@click="selectUser(user)"
>
<img :src="user.avatar" :alt="user.name" class="user-avatar" />
<div class="user-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
<span class="user-role">{{ user.role }}</span>
</div>
<div class="user-actions">
<button @click.stop="editUser(user)" class="edit-btn">Edit</button>
<button @click.stop="deleteUser(user.id)" class="delete-btn">Delete</button>
</div>
</div>
</TransitionGroup>
</div>
<div class="pagination" v-if="totalPages > 1">
<button
v-for="page in totalPages"
:key="page"
@click="currentPage = page"
:class="{ active: currentPage === page }"
class="page-btn"
>
{{ page }}
</button>
</div>
<!-- Modal -->
<Teleport to="body">
<UserModal
v-if="showModal"
:user="selectedUser"
@close="showModal = false"
@save="handleUserSave"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue';
import { debounce } from 'lodash-es';
import UserModal from './UserModal.vue';
import { useUserStore } from '@/stores/user';
import { useTheme } from '@/composables/useTheme';
import { useApi } from '@/composables/useApi';
interface User {
id: number;
name: string;
email: string;
avatar: string;
role: string;
isActive: boolean;
}
interface Stat {
id: string;
label: string;
value: number;
change: number;
}
// Stores
const userStore = useUserStore();
// Composables
const { isDarkMode, toggleTheme } = useTheme();
const { get, post, put, delete: deleteRequest, loading } = useApi();
// Reactive state
const users = ref<User[]>([]);
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
const selectedUser = ref<User | null>(null);
const showModal = ref(false);
// Reactive object for stats
const stats = reactive<Stat[]>([
{ id: 'total', label: 'Total Users', value: 0, change: 12.5 },
{ id: 'active', label: 'Active Users', value: 0, change: 8.2 },
{ id: 'new', label: 'New This Month', value: 0, change: -2.1 },
{ id: 'revenue', label: 'Revenue', value: 0, change: 15.3 }
]);
const user = computed(() => userStore.currentUser);
// Computed properties
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value;
const query = searchQuery.value.toLowerCase();
return users.value.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.role.toLowerCase().includes(query)
);
});
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredUsers.value.slice(start, end);
});
const totalPages = computed(() =>
Math.ceil(filteredUsers.value.length / pageSize.value)
);
// Methods
const fetchUsers = async () => {
try {
const response = await get('/api/users');
users.value = response.data;
updateStats();
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
const updateStats = () => {
stats[0].value = users.value.length;
stats[1].value = users.value.filter(u => u.isActive).length;
stats[2].value = users.value.filter(u => {
const userDate = new Date(u.createdAt);
const now = new Date();
return userDate.getMonth() === now.getMonth() &&
userDate.getFullYear() === now.getFullYear();
}).length;
stats[3].value = users.value.reduce((sum, u) => sum + (u.revenue || 0), 0);
};
const selectUser = (user: User) => {
selectedUser.value = user;
showModal.value = true;
};
const editUser = async (user: User) => {
selectedUser.value = { ...user };
showModal.value = true;
};
const deleteUser = async (userId: number) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await deleteRequest(`/api/users/${userId}`);
users.value = users.value.filter(u => u.id !== userId);
updateStats();
} catch (error) {
console.error('Failed to delete user:', error);
}
};
const handleUserSave = async (userData: User) => {
try {
if (userData.id) {
// Update existing user
const response = await put(`/api/users/${userData.id}`, userData);
const index = users.value.findIndex(u => u.id === userData.id);
if (index !== -1) {
users.value[index] = response.data;
}
} else {
// Create new user
const response = await post('/api/users', userData);
users.value.push(response.data);
}
updateStats();
showModal.value = false;
} catch (error) {
console.error('Failed to save user:', error);
}
};
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
// Debounced search
const debouncedSearch = debounce(() => {
currentPage.value = 1; // Reset to first page when searching
}, 300);
// Watchers
watch(searchQuery, () => {
debouncedSearch();
});
watch(
() => filteredUsers.value.length,
(newLength) => {
if (currentPage.value > Math.ceil(newLength / pageSize.value)) {
currentPage.value = 1;
}
}
);
// Lifecycle
onMounted(async () => {
await fetchUsers();
// Focus search input after component mounts
await nextTick();
const searchInput = document.querySelector('.search-input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
});
</script>
<style scoped>
.user-dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.theme-toggle {
background: none;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 18px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border-color);
}
.stat-value {
font-size: 28px;
font-weight: bold;
margin: 10px 0;
}
.stat-change.positive { color: #10b981; }
.stat-change.negative { color: #ef4444; }
.actions {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
}
.user-list {
display: grid;
gap: 15px;
}
.user-card {
display: flex;
align-items: center;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
}
.user-card:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.user-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 15px;
}
.user-info {
flex: 1;
}
.user-actions {
display: flex;
gap: 10px;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 30px;
}
.page-btn {
padding: 8px 12px;
border: 1px solid var(--border-color);
background: var(--card-bg);
border-radius: 6px;
cursor: pointer;
}
.page-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
/* Transitions */
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.list-move {
transition: transform 0.3s ease;
}
</style>
Custom Composables
TypeScript
// composables/useApi.ts
import { ref, readonly } from 'vue';
import axios from 'axios';
interface ApiResponse<T = any> {
data: T;
message?: string;
status: number;
}
export function useApi() {
const loading = ref(false);
const error = ref<string | null>(null);
const apiCall = async <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
data?: any
): Promise<ApiResponse<T>> => {
loading.value = true;
error.value = null;
try {
const response = await axios({
method,
url,
data,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
return {
data: response.data,
status: response.status,
message: response.data.message
};
} catch (err: any) {
error.value = err.response?.data?.message || err.message;
throw err;
} finally {
loading.value = false;
}
};
const get = <T>(url: string) => apiCall<T>('GET', url);
const post = <T>(url: string, data: any) => apiCall<T>('POST', url, data);
const put = <T>(url: string, data: any) => apiCall<T>('PUT', url, data);
const deleteRequest = <T>(url: string) => apiCall<T>('DELETE', url);
return {
loading: readonly(loading),
error: readonly(error),
get,
post,
put,
delete: deleteRequest
};
}
TypeScript
// composables/useTheme.ts
import { ref, watch } from 'vue';
export function useTheme() {
const isDarkMode = ref(false);
// Initialize theme from localStorage
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
isDarkMode.value = savedTheme === 'dark';
} else {
// Check system preference
isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const applyTheme = (dark: boolean) => {
document.documentElement.classList.toggle('dark', dark);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
};
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
};
// Watch for changes and apply theme
watch(
isDarkMode,
(newValue) => {
applyTheme(newValue);
localStorage.setItem('theme', newValue ? 'dark' : 'light');
},
{ immediate: true }
);
return {
isDarkMode,
toggleTheme
};
}
Pinia Store
TypeScript
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types/user';
export const useUserStore = defineStore('user', () => {
// State
const currentUser = ref<User | null>(null);
const users = ref<User[]>([]);
const isAuthenticated = ref(false);
// Getters
const userCount = computed(() => users.value.length);
const activeUsers = computed(() =>
users.value.filter(user => user.isActive)
);
const isAdmin = computed(() =>
currentUser.value?.role === 'admin'
);
// Actions
const setCurrentUser = (user: User | null) => {
currentUser.value = user;
isAuthenticated.value = !!user;
};
const addUser = (user: User) => {
users.value.push(user);
};
const updateUser = (updatedUser: User) => {
const index = users.value.findIndex(u => u.id === updatedUser.id);
if (index !== -1) {
users.value[index] = updatedUser;
}
};
const removeUser = (userId: number) => {
users.value = users.value.filter(u => u.id !== userId);
};
const login = async (credentials: { email: string; password: string }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (response.ok) {
const data = await response.json();
setCurrentUser(data.user);
localStorage.setItem('token', data.token);
return data;
} else {
throw new Error('Login failed');
}
} catch (error) {
console.error('Login error:', error);
throw error;
}
};
const logout = () => {
setCurrentUser(null);
localStorage.removeItem('token');
};
return {
// State
currentUser,
users,
isAuthenticated,
// Getters
userCount,
activeUsers,
isAdmin,
// Actions
setCurrentUser,
addUser,
updateUser,
removeUser,
login,
logout
};
});
Vue 3 tip: Sử dụng Composition API cho logic reusable và TypeScript support tốt hơn!
Kết luận
Vue.js 3 Composition API mang lại flexibility và type safety tuyệt vời cho modern web development.
Bui Van H
Frontend Developer chuyên Vue.js và modern JavaScript.