Chapter 07: State Management and Interactivity
Haiyue
26min
Learning Objectives
- Implement state management in Astro + Vue 3 environment
- Integrate Pinia or Vuex for complex state handling
- Implement component communication and event handling
- Optimize client-side interactivity performance
Knowledge Points
State Management Challenges in Astro
In Astro’s islands architecture, state management faces unique challenges:
- Island isolation: Different component islands don’t share state by default
- Hydration timing: Components hydrate on the client at different times
- Performance considerations: Avoid unnecessary JavaScript transmission
- SSR compatibility: Ensure server-side rendering consistency
State Management Architecture
🔄 正在渲染 Mermaid 图表...
Interactivity Principles
- Progressive enhancement: Basic functionality works without JavaScript
- Load on demand: Only activate interactive features when needed
- Graceful degradation: Fallback when JavaScript fails
- Performance first: Minimize client-side JavaScript
Pinia State Management
Installation and Configuration
# Install Pinia
npm install pinia
// src/stores/index.ts
import { createPinia } from 'pinia';
import { createPersistedState } from 'pinia-plugin-persistedstate';
export const pinia = createPinia();
// Add persistence plugin
pinia.use(createPersistedState({
key: id => `pinia-${id}`,
storage: typeof window !== 'undefined' ? localStorage : undefined,
}));
User State Management
// src/stores/user.ts
import { defineStore } from 'pinia';
interface User {
id: number;
name: string;
email: string;
avatar?: string;
role: 'user' | 'admin';
}
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
isLoading: false,
error: null,
}),
getters: {
isAuthenticated: (state) => !!state.user,
isAdmin: (state) => state.user?.role === 'admin',
displayName: (state) => state.user?.name || 'Guest',
},
actions: {
async login(email: string, password: string) {
this.isLoading = true;
this.error = null;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const userData = await response.json();
this.user = userData;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Unknown error';
} finally {
this.isLoading = false;
}
},
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (error) {
console.error('Logout request failed:', error);
} finally {
this.user = null;
this.error = null;
}
},
async fetchProfile() {
if (!this.isAuthenticated) return;
this.isLoading = true;
try {
const response = await fetch('/api/user/profile');
if (response.ok) {
this.user = await response.json();
}
} catch (error) {
console.error('Failed to fetch user profile:', error);
} finally {
this.isLoading = false;
}
},
},
// Persistence configuration
persist: {
paths: ['user'], // Only persist user field
},
});
Shopping Cart State Management
// src/stores/cart.ts
import { defineStore } from 'pinia';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image?: string;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: [],
isOpen: false,
}),
getters: {
totalItems: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
isEmpty: (state) => state.items.length === 0,
// Format price
formattedTotal: (state) => {
const total = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return `$${total.toFixed(2)}`;
},
},
actions: {
addItem(product: Omit<CartItem, 'quantity'>) {
const existingItem = this.items.find(item => item.id === product.id);
if (existingItem) {
existingItem.quantity++;
} else {
this.items.push({ ...product, quantity: 1 });
}
// Open cart after successful addition
this.isOpen = true;
},
removeItem(productId: number) {
const index = this.items.findIndex(item => item.id === productId);
if (index > -1) {
this.items.splice(index, 1);
}
},
updateQuantity(productId: number, quantity: number) {
const item = this.items.find(item => item.id === productId);
if (item) {
if (quantity <= 0) {
this.removeItem(productId);
} else {
item.quantity = quantity;
}
}
},
clearCart() {
this.items = [];
this.isOpen = false;
},
toggleCart() {
this.isOpen = !this.isOpen;
},
},
persist: true,
});
Vue Component Integration
User Authentication Component
<!-- src/components/vue/UserAuth.vue -->
<template>
<div class="user-auth">
<!-- Authenticated state -->
<div v-if="userStore.isAuthenticated" class="user-menu">
<button @click="toggleMenu" class="user-button">
<img
v-if="userStore.user?.avatar"
:src="userStore.user.avatar"
:alt="userStore.displayName"
class="user-avatar"
/>
<span v-else class="user-initial">
{{ userStore.displayName[0].toUpperCase() }}
</span>
<span class="user-name">{{ userStore.displayName }}</span>
<ChevronDownIcon class="w-4 h-4" />
</button>
<!-- Dropdown menu -->
<div v-show="isMenuOpen" class="user-dropdown">
<a href="/profile" class="dropdown-item">
<UserIcon class="w-4 h-4" />
Profile
</a>
<a href="/settings" class="dropdown-item">
<CogIcon class="w-4 h-4" />
Settings
</a>
<hr class="dropdown-divider" />
<button @click="handleLogout" class="dropdown-item text-red-600">
<LogoutIcon class="w-4 h-4" />
Logout
</button>
</div>
</div>
<!-- Unauthenticated state -->
<div v-else class="auth-buttons">
<button @click="showLoginForm = true" class="btn btn-outline">
Login
</button>
<a href="/register" class="btn btn-primary">
Register
</a>
</div>
<!-- Login form modal -->
<LoginModal
v-if="showLoginForm"
@close="showLoginForm = false"
@success="handleLoginSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useUserStore } from '../../stores/user';
import LoginModal from './LoginModal.vue';
import {
ChevronDownIcon,
UserIcon,
CogIcon,
LogoutIcon
} from '@heroicons/vue/24/outline';
const userStore = useUserStore();
const isMenuOpen = ref(false);
const showLoginForm = ref(false);
function toggleMenu() {
isMenuOpen.value = !isMenuOpen.value;
}
async function handleLogout() {
await userStore.logout();
isMenuOpen.value = false;
window.location.reload(); // Refresh page to update Astro state
}
function handleLoginSuccess() {
showLoginForm.value = false;
window.location.reload();
}
// Click outside to close menu
function handleClickOutside(event: MouseEvent) {
const target = event.target as Element;
if (!target.closest('.user-menu')) {
isMenuOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
// Fetch latest user info
userStore.fetchProfile();
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.user-auth {
@apply relative;
}
.user-button {
@apply flex items-center space-x-2 px-3 py-2 rounded-md;
@apply bg-gray-100 hover:bg-gray-200 transition-colors;
}
.user-avatar {
@apply w-8 h-8 rounded-full object-cover;
}
.user-initial {
@apply w-8 h-8 rounded-full bg-blue-500 text-white;
@apply flex items-center justify-center font-medium;
}
.user-dropdown {
@apply absolute right-0 mt-2 w-48 bg-white shadow-lg rounded-md py-1 z-50;
@apply border border-gray-200;
}
.dropdown-item {
@apply flex items-center space-x-2 px-4 py-2 text-sm;
@apply hover:bg-gray-100 transition-colors;
}
.dropdown-divider {
@apply border-gray-200 my-1;
}
.auth-buttons {
@apply flex items-center space-x-2;
}
.btn {
@apply px-4 py-2 rounded-md font-medium transition-colors;
}
.btn-outline {
@apply border border-gray-300 text-gray-700 hover:bg-gray-50;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
</style>
Shopping Cart Component
<!-- src/components/vue/ShoppingCart.vue -->
<template>
<div class="shopping-cart">
<!-- Cart icon -->
<button @click="cartStore.toggleCart()" class="cart-button">
<ShoppingCartIcon class="w-6 h-6" />
<span v-if="cartStore.totalItems > 0" class="cart-badge">
{{ cartStore.totalItems }}
</span>
</button>
<!-- Cart sidebar -->
<div
v-show="cartStore.isOpen"
class="cart-overlay"
@click="cartStore.toggleCart()"
>
<div
class="cart-sidebar"
@click.stop
>
<!-- Header -->
<div class="cart-header">
<h3 class="cart-title">Shopping Cart</h3>
<button @click="cartStore.toggleCart()" class="close-button">
<XMarkIcon class="w-6 h-6" />
</button>
</div>
<!-- Cart content -->
<div class="cart-content">
<!-- Empty cart -->
<div v-if="cartStore.isEmpty" class="empty-cart">
<ShoppingCartIcon class="w-16 h-16 text-gray-300" />
<p class="text-gray-500">Cart is empty</p>
<button
@click="cartStore.toggleCart()"
class="btn btn-primary"
>
Continue Shopping
</button>
</div>
<!-- Cart items -->
<div v-else class="cart-items">
<div
v-for="item in cartStore.items"
:key="item.id"
class="cart-item"
>
<!-- Product image -->
<img
v-if="item.image"
:src="item.image"
:alt="item.name"
class="item-image"
/>
<div v-else class="item-placeholder"></div>
<!-- Product details -->
<div class="item-details">
<h4 class="item-name">{{ item.name }}</h4>
<p class="item-price">${{ item.price.toFixed(2) }}</p>
<!-- Quantity controls -->
<div class="quantity-controls">
<button
@click="cartStore.updateQuantity(item.id, item.quantity - 1)"
class="quantity-btn"
>
<MinusIcon class="w-4 h-4" />
</button>
<span class="quantity">{{ item.quantity }}</span>
<button
@click="cartStore.updateQuantity(item.id, item.quantity + 1)"
class="quantity-btn"
>
<PlusIcon class="w-4 h-4" />
</button>
</div>
</div>
<!-- Remove button -->
<button
@click="cartStore.removeItem(item.id)"
class="remove-button"
>
<TrashIcon class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Cart footer -->
<div v-if="!cartStore.isEmpty" class="cart-footer">
<div class="cart-total">
<span class="total-label">Total:</span>
<span class="total-price">{{ cartStore.formattedTotal }}</span>
</div>
<div class="cart-actions">
<button
@click="clearCart"
class="btn btn-outline btn-small"
>
Clear Cart
</button>
<button
@click="goToCheckout"
class="btn btn-primary"
>
Checkout
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useCartStore } from '../../stores/cart';
import {
ShoppingCartIcon,
XMarkIcon,
MinusIcon,
PlusIcon,
TrashIcon
} from '@heroicons/vue/24/outline';
const cartStore = useCartStore();
function clearCart() {
if (confirm('Are you sure you want to clear the cart?')) {
cartStore.clearCart();
}
}
function goToCheckout() {
cartStore.toggleCart();
window.location.href = '/checkout';
}
</script>
<style scoped>
.cart-button {
@apply relative p-2 rounded-md hover:bg-gray-100 transition-colors;
}
.cart-badge {
@apply absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white;
@apply text-xs rounded-full flex items-center justify-center;
}
.cart-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-50;
}
.cart-sidebar {
@apply fixed right-0 top-0 h-full w-96 bg-white shadow-xl;
@apply flex flex-col;
}
.cart-header {
@apply flex items-center justify-between p-4 border-b;
}
.cart-title {
@apply text-lg font-semibold;
}
.close-button {
@apply p-1 hover:bg-gray-100 rounded-md transition-colors;
}
.cart-content {
@apply flex-1 overflow-y-auto p-4;
}
.empty-cart {
@apply flex flex-col items-center justify-center h-full space-y-4;
}
.cart-items {
@apply space-y-4;
}
.cart-item {
@apply flex items-center space-x-3 p-3 border rounded-md;
}
.item-image {
@apply w-16 h-16 object-cover rounded-md;
}
.item-placeholder {
@apply w-16 h-16 bg-gray-200 rounded-md;
}
.item-details {
@apply flex-1;
}
.item-name {
@apply font-medium text-sm;
}
.item-price {
@apply text-gray-600 text-sm;
}
.quantity-controls {
@apply flex items-center space-x-2 mt-2;
}
.quantity-btn {
@apply w-6 h-6 flex items-center justify-center;
@apply bg-gray-100 hover:bg-gray-200 rounded transition-colors;
}
.quantity {
@apply text-sm font-medium min-w-8 text-center;
}
.remove-button {
@apply p-1 text-red-500 hover:bg-red-50 rounded transition-colors;
}
.cart-footer {
@apply border-t p-4 space-y-4;
}
.cart-total {
@apply flex items-center justify-between text-lg font-semibold;
}
.cart-actions {
@apply flex space-x-2;
}
.btn {
@apply px-4 py-2 rounded-md font-medium transition-colors;
}
.btn-small {
@apply px-3 py-1 text-sm;
}
.btn-outline {
@apply border border-gray-300 text-gray-700 hover:bg-gray-50;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-700 flex-1;
}
</style>
Component Communication
Event Bus
// src/lib/event-bus.ts
import { reactive } from 'vue';
interface EventBus {
on(event: string, callback: Function): void;
emit(event: string, ...args: any[]): void;
off(event: string, callback: Function): void;
}
class EventBusImpl implements EventBus {
private events: { [key: string]: Function[] } = reactive({});
on(event: string, callback: Function): void {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
emit(event: string, ...args: any[]): void {
if (this.events[event]) {
this.events[event].forEach(callback => callback(...args));
}
}
off(event: string, callback: Function): void {
if (this.events[event]) {
const index = this.events[event].indexOf(callback);
if (index > -1) {
this.events[event].splice(index, 1);
}
}
}
}
export const eventBus = new EventBusImpl();
Notification System
<!-- src/components/vue/NotificationSystem.vue -->
<template>
<Teleport to="body">
<div class="notification-container">
<TransitionGroup name="notification" tag="div">
<div
v-for="notification in notifications"
:key="notification.id"
:class="notificationClass(notification.type)"
class="notification"
>
<div class="notification-content">
<component :is="getIcon(notification.type)" class="notification-icon" />
<div class="notification-text">
<h4 v-if="notification.title" class="notification-title">
{{ notification.title }}
</h4>
<p class="notification-message">
{{ notification.message }}
</p>
</div>
</div>
<button
@click="removeNotification(notification.id)"
class="notification-close"
>
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { eventBus } from '../../lib/event-bus';
import {
CheckCircleIcon,
ExclamationCircleIcon,
InformationCircleIcon,
XCircleIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title?: string;
message: string;
duration?: number;
}
const notifications = ref<Notification[]>([]);
function addNotification(notification: Omit<Notification, 'id'>) {
const id = Math.random().toString(36).substr(2, 9);
const newNotification = { ...notification, id };
notifications.value.push(newNotification);
// Auto-remove
const duration = notification.duration || 5000;
if (duration > 0) {
setTimeout(() => {
removeNotification(id);
}, duration);
}
}
function removeNotification(id: string) {
const index = notifications.value.findIndex(n => n.id === id);
if (index > -1) {
notifications.value.splice(index, 1);
}
}
function getIcon(type: string) {
const icons = {
success: CheckCircleIcon,
error: XCircleIcon,
warning: ExclamationCircleIcon,
info: InformationCircleIcon,
};
return icons[type] || InformationCircleIcon;
}
function notificationClass(type: string) {
const classes = {
success: 'notification-success',
error: 'notification-error',
warning: 'notification-warning',
info: 'notification-info',
};
return classes[type] || classes.info;
}
onMounted(() => {
eventBus.on('notify', addNotification);
});
</script>
<style scoped>
.notification-container {
@apply fixed top-4 right-4 z-50 space-y-2;
}
.notification {
@apply flex items-start p-4 rounded-md shadow-lg min-w-80 max-w-md;
@apply backdrop-blur-sm;
}
.notification-success {
@apply bg-green-50 border-l-4 border-green-400 text-green-800;
}
.notification-error {
@apply bg-red-50 border-l-4 border-red-400 text-red-800;
}
.notification-warning {
@apply bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800;
}
.notification-info {
@apply bg-blue-50 border-l-4 border-blue-400 text-blue-800;
}
.notification-content {
@apply flex-1 flex items-start space-x-3;
}
.notification-icon {
@apply w-5 h-5 mt-0.5 flex-shrink-0;
}
.notification-title {
@apply font-medium text-sm;
}
.notification-message {
@apply text-sm;
}
.notification-close {
@apply ml-4 flex-shrink-0 p-1 rounded-md hover:bg-black hover:bg-opacity-10;
}
/* Transition animations */
.notification-enter-active,
.notification-leave-active {
@apply transition-all duration-300;
}
.notification-enter-from {
@apply opacity-0 transform translate-x-full;
}
.notification-leave-to {
@apply opacity-0 transform translate-x-full;
}
</style>
Performance Optimization Strategies
Lazy Loading and Code Splitting
---
// src/components/LazyVueComponent.astro
export interface Props {
component: string;
props?: Record<string, any>;
loading?: string;
}
const { component, props = {}, loading = 'Loading...' } = Astro.props;
---
<div
data-vue-component={component}
data-vue-props={JSON.stringify(props)}
class="vue-component-placeholder"
>
{loading}
</div>
<script>
// Lazy load Vue components
class VueComponentLoader {
private observer: IntersectionObserver;
private loadedComponents = new Set<string>();
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadComponent(entry.target as HTMLElement);
}
});
},
{ rootMargin: '50px' }
);
this.init();
}
private init() {
const placeholders = document.querySelectorAll('[data-vue-component]');
placeholders.forEach((el) => {
this.observer.observe(el);
});
}
private async loadComponent(element: HTMLElement) {
const componentName = element.dataset.vueComponent;
const propsData = element.dataset.vueProps;
if (!componentName || this.loadedComponents.has(componentName)) {
return;
}
try {
// Dynamic import component
const { createApp } = await import('vue');
const { pinia } = await import('../stores');
// Import component dynamically by name
const componentModule = await import(`../vue/${componentName}.vue`);
const component = componentModule.default;
// Create Vue app instance
const app = createApp(component, propsData ? JSON.parse(propsData) : {});
app.use(pinia);
// Mount to element
app.mount(element);
this.loadedComponents.add(componentName);
this.observer.unobserve(element);
} catch (error) {
console.error(`Failed to load component ${componentName}:`, error);
element.innerHTML = `<div class="error">Failed to load component</div>`;
}
}
}
// Initialize lazy loader
if (typeof window !== 'undefined') {
new VueComponentLoader();
}
</script>
State Persistence Optimization
// src/lib/storage.ts
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
class SmartStorage implements StorageAdapter {
private storage: Storage;
private compression: boolean;
constructor(storage: Storage = localStorage, compression = false) {
this.storage = storage;
this.compression = compression;
}
getItem(key: string): string | null {
try {
const value = this.storage.getItem(key);
if (!value) return null;
if (this.compression) {
// Decompression logic (if needed)
return this.decompress(value);
}
return value;
} catch (error) {
console.error('Failed to get item from storage:', error);
return null;
}
}
setItem(key: string, value: string): void {
try {
const finalValue = this.compression ? this.compress(value) : value;
this.storage.setItem(key, finalValue);
} catch (error) {
console.error('Failed to set item in storage:', error);
// Handle storage quota exceeded
this.handleStorageQuotaExceeded();
}
}
removeItem(key: string): void {
try {
this.storage.removeItem(key);
} catch (error) {
console.error('Failed to remove item from storage:', error);
}
}
private compress(value: string): string {
// Simple compression implementation (use better compression algorithms in production)
return btoa(value);
}
private decompress(value: string): string {
return atob(value);
}
private handleStorageQuotaExceeded(): void {
// Strategy for cleaning old data
const keys = Object.keys(this.storage);
const oldKeys = keys.filter(key => key.startsWith('pinia-'));
// Remove oldest data
if (oldKeys.length > 0) {
this.storage.removeItem(oldKeys[0]);
}
}
}
export const smartStorage = new SmartStorage();
Key Takeaways
- Islands Architecture Adaptation: Properly implement state sharing in Astro’s islands mode
- Performance First: Only load and execute JavaScript when necessary
- Progressive Enhancement: Ensure basic functionality works without JavaScript
- State Persistence: Use local storage wisely to maintain user state
- Component Communication: Implement component collaboration through event bus and state management
Considerations
- Be aware of SSR and client-side state synchronization issues
- Avoid accessing browser APIs on the server side
- Use client directives wisely to control component hydration timing
- Consider data security and privacy protection for state persistence