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
  1. Islands Architecture Adaptation: Properly implement state sharing in Astro’s islands mode
  2. Performance First: Only load and execute JavaScript when necessary
  3. Progressive Enhancement: Ensure basic functionality works without JavaScript
  4. State Persistence: Use local storage wisely to maintain user state
  5. 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