Chapter 03: Vue 3 Integration and Configuration

Haiyue
23min
Learning Objectives
  • Configure Astro project to support Vue 3
  • Understand Vue component lifecycle in Astro
  • Master client-side hydration mechanism
  • Learn to use Vue 3 Composition API in Astro

Vue 3 Integration Configuration

Installing Vue 3 Integration

# Add Vue integration using Astro CLI
npx astro add vue

# Or install manually
npm install @astrojs/vue vue

Configuring astro.config.mjs

// astro.config.mjs
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';

export default defineConfig({
  integrations: [
    vue({
      // Vue-specific configuration
      appEntrypoint: '/src/pages/_app', // Optional app entry point
      jsx: true, // Enable JSX support
    })
  ],
  vite: {
    // Vite configuration for Vue
    vue: {
      template: {
        compilerOptions: {
          // Custom element handling
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    }
  }
});

TypeScript Support

// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue"
  },
  "include": [
    "src/**/*",
    "src/**/*.vue"
  ]
}
// src/env.d.ts
/// <reference types="astro/client" />
/// <reference types="@astrojs/vue/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue';
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

Vue Component Lifecycle in Astro

Server-Side Rendering (SSR) Phase

🔄 正在渲染 Mermaid 图表...

Client-Side Hydration Phase

def vue_hydration_lifecycle():
    """
    Vue component hydration lifecycle in Astro
    """
    lifecycle_stages = {
        "1_server_render": {
            "stage": "Server-Side Rendering",
            "description": "Component renders to static HTML at build time",
            "vue_hooks": ["setup()", "computed", "watch"],
            "astro_role": "Execute Vue component and serialize state"
        },
        "2_html_delivery": {
            "stage": "HTML Delivery",
            "description": "Static HTML sent to client",
            "vue_hooks": [],
            "astro_role": "Provide static content and hydration directives"
        },
        "3_hydration_trigger": {
            "stage": "Hydration Trigger",
            "description": "Decide when to start hydration based on client directive",
            "vue_hooks": [],
            "astro_role": "Listen for trigger conditions (load/idle/visible)"
        },
        "4_client_activation": {
            "stage": "Client Activation",
            "description": "Vue component becomes interactive on client",
            "vue_hooks": ["onMounted", "onUpdated"],
            "astro_role": "Restore component state and bind events"
        }
    }

    print("Vue Component Lifecycle in Astro:")
    for stage_key, stage_info in lifecycle_stages.items():
        print(f"\n{stage_info['stage']}:")
        print(f"  Description: {stage_info['description']}")
        print(f"  Vue Hooks: {stage_info['vue_hooks']}")
        print(f"  Astro Role: {stage_info['astro_role']}")

    return lifecycle_stages

# Usage example
lifecycle_info = vue_hydration_lifecycle()

Using Vue 3 Composition API in Astro

Basic Reactive Component

<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <h3>Counter</h3>
    <div class="display">
      <span class="count">{{ count }}</span>
      <span class="status" :class="{ 'high': count >= 10 }">
        {{ statusText }}
      </span>
    </div>
    <div class="controls">
      <button @click="decrement" :disabled="count <= 0">-</button>
      <button @click="reset">Reset</button>
      <button @click="increment">+</button>
    </div>
    <div class="info">
      <p>Double Clicks: {{ doubleClicks }}</p>
      <p>Total Operations: {{ totalOperations }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';

// Reactive state
const count = ref(0);
const doubleClicks = ref(0);
const totalOperations = ref(0);

// Computed property
const statusText = computed(() => {
  if (count.value === 0) return 'Start';
  if (count.value < 5) return 'Low';
  if (count.value < 10) return 'Medium';
  return 'High';
});

// Methods
const increment = () => {
  count.value++;
  totalOperations.value++;
};

const decrement = () => {
  if (count.value > 0) {
    count.value--;
    totalOperations.value++;
  }
};

const reset = () => {
  count.value = 0;
  totalOperations.value++;
};

// Watcher
watch(count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`);

  // Detect rapid double clicks
  if (Math.abs(newVal - oldVal) === 2) {
    doubleClicks.value++;
  }
});

// Lifecycle
onMounted(() => {
  console.log('Counter component mounted');
});

// Data and methods exposed to template (TypeScript inference)
</script>

<style scoped>
.counter {
  padding: 1.5rem;
  border: 2px solid #e5e7eb;
  border-radius: 12px;
  background: #f9fafb;
  max-width: 300px;
  text-align: center;
}

.display {
  margin: 1rem 0;
}

.count {
  font-size: 2rem;
  font-weight: bold;
  color: #1f2937;
}

.status {
  display: block;
  font-size: 0.875rem;
  color: #6b7280;
  margin-top: 0.5rem;
}

.status.high {
  color: #ef4444;
  font-weight: bold;
}

.controls {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  margin: 1rem 0;
}

.controls button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 6px;
  background: #3b82f6;
  color: white;
  cursor: pointer;
  transition: background-color 0.2s;
}

.controls button:hover:not(:disabled) {
  background: #2563eb;
}

.controls button:disabled {
  background: #9ca3af;
  cursor: not-allowed;
}

.info {
  font-size: 0.875rem;
  color: #6b7280;
  margin-top: 1rem;
}

.info p {
  margin: 0.25rem 0;
}
</style>

Complex State Management Component

<!-- src/components/TaskManager.vue -->
<template>
  <div class="task-manager">
    <h3>Task Manager</h3>

    <!-- Add new task -->
    <form @submit.prevent="addTask" class="add-form">
      <input
        v-model="newTaskTitle"
        type="text"
        placeholder="Enter new task..."
        required
        class="task-input"
      />
      <select v-model="newTaskPriority" class="priority-select">
        <option value="low">Low Priority</option>
        <option value="medium">Medium Priority</option>
        <option value="high">High Priority</option>
      </select>
      <button type="submit" class="add-btn">Add</button>
    </form>

    <!-- Filters -->
    <div class="filters">
      <button
        v-for="filter in filters"
        :key="filter.key"
        @click="currentFilter = filter.key"
        :class="['filter-btn', { active: currentFilter === filter.key }]"
      >
        {{ filter.label }} ({{ filter.count }})
      </button>
    </div>

    <!-- Task list -->
    <div class="task-list">
      <div
        v-for="task in filteredTasks"
        :key="task.id"
        :class="['task-item', `priority-${task.priority}`, { completed: task.completed }]"
      >
        <input
          type="checkbox"
          v-model="task.completed"
          @change="toggleTask(task.id)"
          class="task-checkbox"
        />
        <span class="task-title">{{ task.title }}</span>
        <span class="task-priority">{{ task.priority }}</span>
        <button @click="removeTask(task.id)" class="remove-btn">Delete</button>
      </div>
    </div>

    <!-- Statistics -->
    <div class="stats">
      <p>Total Tasks: {{ tasks.length }}</p>
      <p>Completed: {{ completedTasksCount }}</p>
      <p>Progress: {{ progressPercentage }}%</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';

// Type definitions
interface Task {
  id: number;
  title: string;
  priority: 'low' | 'medium' | 'high';
  completed: boolean;
  createdAt: Date;
}

type FilterKey = 'all' | 'active' | 'completed';

// Reactive state
const tasks = ref<Task[]>([]);
const newTaskTitle = ref('');
const newTaskPriority = ref<Task['priority']>('medium');
const currentFilter = ref<FilterKey>('all');

// Computed properties
const completedTasksCount = computed(() =>
  tasks.value.filter(task => task.completed).length
);

const progressPercentage = computed(() =>
  tasks.value.length === 0 ? 0 : Math.round((completedTasksCount.value / tasks.value.length) * 100)
);

const filteredTasks = computed(() => {
  switch (currentFilter.value) {
    case 'active':
      return tasks.value.filter(task => !task.completed);
    case 'completed':
      return tasks.value.filter(task => task.completed);
    default:
      return tasks.value;
  }
});

const filters = computed(() => [
  { key: 'all' as FilterKey, label: 'All', count: tasks.value.length },
  { key: 'active' as FilterKey, label: 'Active', count: tasks.value.length - completedTasksCount.value },
  { key: 'completed' as FilterKey, label: 'Completed', count: completedTasksCount.value }
]);

// Methods
const addTask = () => {
  if (newTaskTitle.value.trim()) {
    const newTask: Task = {
      id: Date.now(),
      title: newTaskTitle.value.trim(),
      priority: newTaskPriority.value,
      completed: false,
      createdAt: new Date()
    };

    tasks.value.unshift(newTask);
    newTaskTitle.value = '';
    saveToLocalStorage();
  }
};

const toggleTask = (taskId: number) => {
  const task = tasks.value.find(t => t.id === taskId);
  if (task) {
    task.completed = !task.completed;
    saveToLocalStorage();
  }
};

const removeTask = (taskId: number) => {
  tasks.value = tasks.value.filter(t => t.id !== taskId);
  saveToLocalStorage();
};

const saveToLocalStorage = () => {
  localStorage.setItem('astro-vue-tasks', JSON.stringify(tasks.value));
};

const loadFromLocalStorage = () => {
  const saved = localStorage.getItem('astro-vue-tasks');
  if (saved) {
    try {
      const parsed = JSON.parse(saved);
      tasks.value = parsed.map((task: any) => ({
        ...task,
        createdAt: new Date(task.createdAt)
      }));
    } catch (error) {
      console.error('Failed to load tasks:', error);
    }
  }
};

// Watcher
watch(tasks, (newTasks) => {
  console.log(`Current task count: ${newTasks.length}`);
}, { deep: true });

// Lifecycle
onMounted(() => {
  loadFromLocalStorage();
  console.log('Task manager mounted');
});
</script>

<style scoped>
.task-manager {
  max-width: 600px;
  margin: 0 auto;
  padding: 1.5rem;
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

.add-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

.task-input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  font-size: 1rem;
}

.priority-select {
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  background: white;
}

.add-btn {
  padding: 0.75rem 1.5rem;
  background: #10b981;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
}

.add-btn:hover {
  background: #059669;
}

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

.filter-btn {
  padding: 0.5rem 1rem;
  border: 1px solid #d1d5db;
  background: white;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s;
}

.filter-btn.active {
  background: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.task-list {
  min-height: 200px;
  margin-bottom: 1.5rem;
}

.task-item {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  margin-bottom: 0.5rem;
  transition: all 0.2s;
}

.task-item.completed {
  opacity: 0.6;
  background: #f3f4f6;
}

.task-item.priority-high {
  border-left: 4px solid #ef4444;
}

.task-item.priority-medium {
  border-left: 4px solid #f59e0b;
}

.task-item.priority-low {
  border-left: 4px solid #10b981;
}

.task-checkbox {
  width: 1.25rem;
  height: 1.25rem;
}

.task-title {
  flex: 1;
  font-weight: 500;
}

.task-item.completed .task-title {
  text-decoration: line-through;
}

.task-priority {
  font-size: 0.875rem;
  color: #6b7280;
  text-transform: capitalize;
}

.remove-btn {
  padding: 0.25rem 0.75rem;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.875rem;
}

.remove-btn:hover {
  background: #dc2626;
}

.stats {
  display: flex;
  justify-content: space-around;
  padding: 1rem;
  background: #f9fafb;
  border-radius: 8px;
  font-size: 0.875rem;
  color: #6b7280;
}
</style>

Using Vue Components in Astro Pages

Basic Usage

---
// src/pages/vue-demo.astro
import Layout from '../layouts/Layout.astro';
import Counter from '../components/Counter.vue';
import TaskManager from '../components/TaskManager.vue';
---

<Layout title="Vue 3 Component Demo">
  <main class="container">
    <h1>Astro + Vue 3 Demo</h1>

    <!-- Static rendering (no JavaScript) -->
    <section class="demo-section">
      <h2>Static Counter</h2>
      <p>This component renders at build time with no client interaction:</p>
      <Counter />
    </section>

    <!-- Hydrate on page load -->
    <section class="demo-section">
      <h2>Interactive Counter</h2>
      <p>This component becomes interactive on page load:</p>
      <Counter client:load />
    </section>

    <!-- Hydrate on idle -->
    <section class="demo-section">
      <h2>Task Manager</h2>
      <p>This component becomes interactive when browser is idle:</p>
      <TaskManager client:idle />
    </section>

    <!-- Hydrate on visible -->
    <section class="demo-section">
      <h2>Lazy-Loaded Counter</h2>
      <p>This component becomes interactive when it enters viewport:</p>
      <Counter client:visible />
    </section>
  </main>
</Layout>

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

  .demo-section {
    margin-bottom: 3rem;
    padding: 2rem;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    background: #fafafa;
  }

  .demo-section h2 {
    color: #1f2937;
    margin-bottom: 0.5rem;
  }

  .demo-section p {
    color: #6b7280;
    margin-bottom: 1.5rem;
  }
</style>

Advanced Integration: Props Passing and Event Handling

---
// src/pages/advanced-vue.astro
import Layout from '../layouts/Layout.astro';
import UserProfile from '../components/UserProfile.vue';
import NotificationCenter from '../components/NotificationCenter.vue';

// Mock user data
const userData = {
  id: 1,
  name: "John Doe",
  email: "john.doe@example.com",
  avatar: "/avatars/johndoe.jpg",
  preferences: {
    theme: "light",
    notifications: true,
    language: "en-US"
  }
};

const notifications = [
  {
    id: 1,
    title: "Welcome to Astro + Vue 3",
    message: "You have successfully integrated Vue 3 components",
    type: "success",
    timestamp: new Date()
  },
  {
    id: 2,
    title: "System Update",
    message: "New features are now available",
    type: "info",
    timestamp: new Date(Date.now() - 86400000)
  }
];
---

<Layout title="Advanced Vue Integration">
  <div class="app-layout">
    <header class="app-header">
      <h1>User Dashboard</h1>
    </header>

    <div class="app-content">
      <aside class="sidebar">
        <!-- User profile component -->
        <UserProfile
          client:load
          user={userData}
          showSettings={true}
        />
      </aside>

      <main class="main-content">
        <!-- Notification center component -->
        <NotificationCenter
          client:idle
          notifications={notifications}
          autoRefresh={true}
        />
      </main>
    </div>
  </div>
</Layout>

<style>
  .app-layout {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
  }

  .app-header {
    background: #1f2937;
    color: white;
    padding: 1rem 2rem;
  }

  .app-content {
    flex: 1;
    display: grid;
    grid-template-columns: 300px 1fr;
    gap: 2rem;
    padding: 2rem;
  }

  .sidebar {
    background: #f9fafb;
    border-radius: 12px;
    padding: 1.5rem;
  }

  .main-content {
    background: white;
    border-radius: 12px;
    padding: 1.5rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }

  @media (max-width: 768px) {
    .app-content {
      grid-template-columns: 1fr;
    }
  }
</style>

Vue Component Communication

Using provide/inject

<!-- src/components/ThemeProvider.vue -->
<template>
  <div :class="`theme-${currentTheme}`">
    <div class="theme-controls">
      <button
        v-for="theme in themes"
        :key="theme"
        @click="setTheme(theme)"
        :class="['theme-btn', { active: currentTheme === theme }]"
      >
        {{ theme }}
      </button>
    </div>
    <slot />
  </div>
</template>

<script setup lang="ts">
import { ref, provide, onMounted } from 'vue';

type Theme = 'light' | 'dark' | 'auto';

const currentTheme = ref<Theme>('light');
const themes: Theme[] = ['light', 'dark', 'auto'];

const setTheme = (theme: Theme) => {
  currentTheme.value = theme;
  localStorage.setItem('theme-preference', theme);
};

const loadTheme = () => {
  const saved = localStorage.getItem('theme-preference') as Theme;
  if (saved && themes.includes(saved)) {
    currentTheme.value = saved;
  }
};

// Provide theme context
provide('theme', {
  currentTheme,
  setTheme,
  themes
});

onMounted(() => {
  loadTheme();
});
</script>

<style scoped>
.theme-light {
  background: #ffffff;
  color: #1f2937;
}

.theme-dark {
  background: #1f2937;
  color: #f9fafb;
}

.theme-controls {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
  padding: 1rem;
  border-bottom: 1px solid #e5e7eb;
}

.theme-btn {
  padding: 0.5rem 1rem;
  border: 1px solid #d1d5db;
  background: transparent;
  border-radius: 6px;
  cursor: pointer;
  text-transform: capitalize;
}

.theme-btn.active {
  background: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.theme-dark .theme-controls {
  border-bottom-color: #374151;
}

.theme-dark .theme-btn {
  border-color: #6b7280;
  color: #f9fafb;
}
</style>
<!-- src/components/ThemedCard.vue -->
<template>
  <div class="themed-card">
    <h3>Theme-Aware Card</h3>
    <p>Current theme: {{ themeContext?.currentTheme }}</p>
    <p>This card adapts its style based on the global theme</p>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';

// Inject theme context
const themeContext = inject('theme');
</script>

<style scoped>
.themed-card {
  padding: 1.5rem;
  border-radius: 8px;
  border: 1px solid #e5e7eb;
  margin: 1rem 0;
}
</style>

Best Practices and Performance Optimization

1. Proper Use of Client Directives

def vue_hydration_best_practices():
    """
    Best practices guide for Vue component hydration
    """
    guidelines = {
        "client:load": {
            "use_cases": ["Critical interactive components", "Features users need immediately"],
            "examples": ["Navigation menu", "Search box", "User login status"],
            "considerations": "Increases initial JavaScript bundle size"
        },
        "client:idle": {
            "use_cases": ["Secondary features", "Non-critical interactions"],
            "examples": ["Comment system", "Share buttons", "Recommended content"],
            "considerations": "Hydrates when browser is idle, better user experience"
        },
        "client:visible": {
            "use_cases": ["Content below the fold", "Collapsed panel content"],
            "examples": ["Chart components", "Lazy-loaded lists", "Footer interactions"],
            "considerations": "Can significantly reduce initial JavaScript execution"
        },
        "client:media": {
            "use_cases": ["Responsive components", "Device-specific features"],
            "examples": ["Mobile menu", "Desktop toolbar"],
            "considerations": "Loads based on media query conditions"
        },
        "no_directive": {
            "use_cases": ["Pure display content", "Static data"],
            "examples": ["Article content", "Product showcase", "About page"],
            "considerations": "Zero JavaScript, optimal performance"
        }
    }

    print("Vue Component Hydration Best Practices:")
    for directive, info in guidelines.items():
        print(f"\n{directive}:")
        for key, value in info.items():
            if isinstance(value, list):
                print(f"  {key}: {', '.join(value)}")
            else:
                print(f"  {key}: {value}")

    return guidelines

# Usage example
best_practices = vue_hydration_best_practices()

2. Component Splitting Strategy

<!-- Large component splitting example -->
<!-- src/components/ProductPage.vue -->
<template>
  <div class="product-page">
    <!-- Static content - no JavaScript needed -->
    <ProductHeader :product="product" />
    <ProductGallery :images="product.images" />
    <ProductDescription :description="product.description" />

    <!-- Interactive features - load on demand -->
    <ProductActions
      client:load
      :product="product"
      @add-to-cart="handleAddToCart"
    />
    <ReviewSystem
      client:visible
      :product-id="product.id"
    />
    <RecommendedProducts
      client:idle
      :category="product.category"
    />
  </div>
</template>

<script setup lang="ts">
// Component logic...
</script>

Summary

In this chapter, we explored Vue 3 integration in Astro in depth:

  1. Integration Configuration: Installing and configuring the Vue 3 integration plugin
  2. Lifecycle Understanding: Complete lifecycle of Vue components in SSR and client-side
  3. Composition API: Using Vue 3’s modern API in the Astro environment
  4. Hydration Strategies: Choosing appropriate client directives to optimize performance
  5. Component Communication: Using Vue features like provide/inject
  6. Best Practices: Performance optimization and component splitting strategies
Core Advantages

The Vue 3 + Astro combination provides us with:

  • Familiar Vue development experience
  • Excellent SSR performance
  • Flexible hydration control
  • Powerful TypeScript support
Next Chapter Preview

In the next chapter, we will learn how to integrate Tailwind CSS to build a modern styling system, further enhancing development efficiency and user experience.