组件系统是Vue.js最强大的功能之一,它允许我们将UI拆分成独立、可复用的代码片段。本章将深入学习组件的定义、使用、通信和高级特性。

4.1 组件基础

组件定义和注册

<!-- HelloWorld.vue -->
<template>
  <div class="hello-world">
    <h1>{{ title }}</h1>
    <p>{{ message }}</p>
    <button @click="updateMessage">更新消息</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      title: 'Hello World 组件',
      message: '这是一个Vue组件示例'
    }
  },
  methods: {
    updateMessage() {
      this.message = '消息已更新!时间:' + new Date().toLocaleTimeString()
    }
  }
}
</script>

<style scoped>
.hello-world {
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 8px;
  margin: 10px;
}

.hello-world h1 {
  color: #42b983;
}

.hello-world button {
  background: #42b983;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.hello-world button:hover {
  background: #369870;
}
</style>
<!-- App.vue -->
<template>
  <div id="app">
    <h1>Vue组件系统示例</h1>
    
    <!-- 使用组件 -->
    <HelloWorld />
    <HelloWorld />
    
    <!-- 全局注册的组件 -->
    <GlobalComponent />
    
    <!-- 局部注册的组件 -->
    <LocalComponent />
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'
import LocalComponent from './components/LocalComponent.vue'

export default {
  name: 'App',
  components: {
    HelloWorld,
    LocalComponent
  }
}
</script>
// main.js - 全局组件注册
import { createApp } from 'vue'
import App from './App.vue'
import GlobalComponent from './components/GlobalComponent.vue'

const app = createApp(App)

// 全局注册组件
app.component('GlobalComponent', GlobalComponent)

app.mount('#app')

组件的data必须是函数

<!-- Counter.vue -->
<template>
  <div class="counter">
    <h3>计数器 {{ id }}</h3>
    <p>当前计数: {{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  name: 'Counter',
  props: ['id'],
  // data必须是函数,确保每个组件实例都有独立的数据副本
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    reset() {
      this.count = 0
    }
  }
}
</script>

<style scoped>
.counter {
  border: 1px solid #ddd;
  padding: 15px;
  margin: 10px;
  border-radius: 5px;
  background: #f9f9f9;
}

.counter button {
  margin: 0 5px;
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 3px;
  cursor: pointer;
}

.counter button:hover {
  background: #e0e0e0;
}
</style>
<!-- 使用多个Counter实例 -->
<template>
  <div>
    <h2>多个计数器实例</h2>
    <Counter id="A" />
    <Counter id="B" />
    <Counter id="C" />
  </div>
</template>

<script>
import Counter from './Counter.vue'

export default {
  components: {
    Counter
  }
}
</script>

4.2 Props(父向子通信)

基本Props使用

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <img :src="avatar" :alt="name" class="avatar">
    <div class="user-info">
      <h3>{{ name }}</h3>
      <p>{{ email }}</p>
      <p>年龄: {{ age }}</p>
      <p>状态: <span :class="statusClass">{{ status }}</span></p>
      <div class="tags">
        <span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'UserCard',
  props: {
    // 基本类型检查
    name: String,
    email: String,
    age: Number,
    
    // 多种可能的类型
    avatar: [String, Object],
    
    // 带有默认值的prop
    status: {
      type: String,
      default: 'offline'
    },
    
    // 数组类型
    tags: {
      type: Array,
      default: () => []
    },
    
    // 对象类型
    userInfo: {
      type: Object,
      default: () => ({})
    },
    
    // 自定义验证函数
    priority: {
      type: String,
      validator: function (value) {
        return ['low', 'medium', 'high'].indexOf(value) !== -1
      }
    },
    
    // 必需的prop
    userId: {
      type: [String, Number],
      required: true
    }
  },
  computed: {
    statusClass() {
      return {
        'status-online': this.status === 'online',
        'status-offline': this.status === 'offline',
        'status-busy': this.status === 'busy'
      }
    }
  }
}
</script>

<style scoped>
.user-card {
  display: flex;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  margin: 10px;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  margin-right: 20px;
  object-fit: cover;
}

.user-info h3 {
  margin: 0 0 10px 0;
  color: #333;
}

.user-info p {
  margin: 5px 0;
  color: #666;
}

.status-online { color: #4CAF50; }
.status-offline { color: #9E9E9E; }
.status-busy { color: #FF9800; }

.tags {
  margin-top: 10px;
}

.tag {
  display: inline-block;
  background: #e1f5fe;
  color: #0277bd;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  margin-right: 5px;
}
</style>
<!-- 父组件使用UserCard -->
<template>
  <div>
    <h2>用户列表</h2>
    
    <!-- 静态props -->
    <UserCard
      user-id="1"
      name="张三"
      email="zhangsan@example.com"
      :age="25"
      avatar="https://via.placeholder.com/80"
      status="online"
      :tags="['开发者', 'Vue.js', '前端']"
      priority="high"
    />
    
    <!-- 动态props -->
    <UserCard
      v-for="user in users"
      :key="user.id"
      :user-id="user.id"
      :name="user.name"
      :email="user.email"
      :age="user.age"
      :avatar="user.avatar"
      :status="user.status"
      :tags="user.tags"
      :priority="user.priority"
    />
    
    <!-- 使用v-bind传递整个对象 -->
    <UserCard
      v-for="user in users"
      :key="'obj-' + user.id"
      v-bind="user"
    />
  </div>
</template>

<script>
import UserCard from './components/UserCard.vue'

export default {
  components: {
    UserCard
  },
  data() {
    return {
      users: [
        {
          userId: '2',
          name: '李四',
          email: 'lisi@example.com',
          age: 30,
          avatar: 'https://via.placeholder.com/80',
          status: 'busy',
          tags: ['设计师', 'UI/UX'],
          priority: 'medium'
        },
        {
          userId: '3',
          name: '王五',
          email: 'wangwu@example.com',
          age: 28,
          avatar: 'https://via.placeholder.com/80',
          status: 'offline',
          tags: ['后端', 'Node.js', 'Python'],
          priority: 'low'
        }
      ]
    }
  }
}
</script>

Prop验证和类型检查

<!-- ProductCard.vue -->
<template>
  <div class="product-card">
    <img :src="product.image" :alt="product.name" class="product-image">
    <div class="product-info">
      <h3>{{ product.name }}</h3>
      <p class="description">{{ product.description }}</p>
      <div class="price">
        <span class="current-price">${{ formatPrice(product.price) }}</span>
        <span v-if="product.originalPrice" class="original-price">
          ${{ formatPrice(product.originalPrice) }}
        </span>
      </div>
      <div class="rating">
        <span v-for="star in 5" :key="star" class="star" :class="{ filled: star <= product.rating }">
          ★
        </span>
        <span class="rating-text">({{ product.rating }}/5)</span>
      </div>
      <button @click="addToCart" :disabled="!product.inStock" class="add-to-cart">
        {{ product.inStock ? '加入购物车' : '缺货' }}
      </button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ProductCard',
  props: {
    product: {
      type: Object,
      required: true,
      validator(value) {
        // 验证product对象必须包含必要的属性
        const requiredProps = ['id', 'name', 'price', 'image']
        return requiredProps.every(prop => prop in value)
      }
    },
    
    // 货币格式
    currency: {
      type: String,
      default: 'USD',
      validator(value) {
        return ['USD', 'EUR', 'CNY', 'JPY'].includes(value)
      }
    },
    
    // 显示模式
    displayMode: {
      type: String,
      default: 'card',
      validator(value) {
        return ['card', 'list', 'grid'].includes(value)
      }
    },
    
    // 是否显示评分
    showRating: {
      type: Boolean,
      default: true
    },
    
    // 折扣信息
    discount: {
      type: [Number, Object],
      default: null,
      validator(value) {
        if (typeof value === 'number') {
          return value >= 0 && value <= 100
        }
        if (typeof value === 'object' && value !== null) {
          return 'percentage' in value || 'amount' in value
        }
        return true
      }
    }
  },
  emits: ['add-to-cart', 'product-click'],
  methods: {
    formatPrice(price) {
      return parseFloat(price).toFixed(2)
    },
    
    addToCart() {
      this.$emit('add-to-cart', {
        productId: this.product.id,
        name: this.product.name,
        price: this.product.price,
        quantity: 1
      })
    }
  }
}
</script>

<style scoped>
.product-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  background: white;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  transition: transform 0.2s, box-shadow 0.2s;
  max-width: 300px;
  margin: 10px;
}

.product-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}

.product-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.product-info {
  padding: 15px;
}

.product-info h3 {
  margin: 0 0 10px 0;
  color: #333;
  font-size: 18px;
}

.description {
  color: #666;
  font-size: 14px;
  margin-bottom: 10px;
  line-height: 1.4;
}

.price {
  margin-bottom: 10px;
}

.current-price {
  font-size: 20px;
  font-weight: bold;
  color: #e74c3c;
}

.original-price {
  font-size: 16px;
  color: #999;
  text-decoration: line-through;
  margin-left: 10px;
}

.rating {
  margin-bottom: 15px;
}

.star {
  color: #ddd;
  font-size: 16px;
}

.star.filled {
  color: #ffc107;
}

.rating-text {
  margin-left: 5px;
  color: #666;
  font-size: 14px;
}

.add-to-cart {
  width: 100%;
  padding: 10px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background 0.2s;
}

.add-to-cart:hover:not(:disabled) {
  background: #369870;
}

.add-to-cart:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

4.3 自定义事件(子向父通信)

基本事件发射

<!-- ChildComponent.vue -->
<template>
  <div class="child-component">
    <h3>子组件</h3>
    <p>内部计数: {{ internalCount }}</p>
    
    <!-- 发射简单事件 -->
    <button @click="sendMessage">发送消息给父组件</button>
    
    <!-- 发射带数据的事件 -->
    <button @click="sendData">发送数据</button>
    
    <!-- 发射多个参数的事件 -->
    <button @click="sendMultipleData">发送多个参数</button>
    
    <!-- 计数器事件 -->
    <button @click="increment">增加计数</button>
    <button @click="decrement">减少计数</button>
    
    <!-- 表单事件 -->
    <div class="form-section">
      <input v-model="inputValue" @input="handleInput" placeholder="输入内容">
      <button @click="submitForm">提交表单</button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ChildComponent',
  // 声明组件会发射的事件
  emits: {
    // 简单事件声明
    'message-sent': null,
    
    // 带验证的事件声明
    'data-changed': (payload) => {
      return payload && typeof payload === 'object'
    },
    
    // 计数器事件
    'count-updated': (count) => {
      return typeof count === 'number'
    },
    
    // 表单事件
    'form-submitted': (formData) => {
      return formData && formData.value !== undefined
    },
    
    // 输入事件
    'input-changed': String
  },
  
  data() {
    return {
      internalCount: 0,
      inputValue: '',
      messages: []
    }
  },
  
  methods: {
    sendMessage() {
      // 发射简单事件
      this.$emit('message-sent')
      this.messages.push('消息已发送: ' + new Date().toLocaleTimeString())
    },
    
    sendData() {
      // 发射带数据的事件
      const data = {
        timestamp: Date.now(),
        message: '来自子组件的数据',
        count: this.internalCount
      }
      this.$emit('data-changed', data)
    },
    
    sendMultipleData() {
      // 发射多个参数的事件
      this.$emit('multiple-data', 'param1', 'param2', { key: 'value' })
    },
    
    increment() {
      this.internalCount++
      this.$emit('count-updated', this.internalCount)
    },
    
    decrement() {
      this.internalCount--
      this.$emit('count-updated', this.internalCount)
    },
    
    handleInput() {
      this.$emit('input-changed', this.inputValue)
    },
    
    submitForm() {
      const formData = {
        value: this.inputValue,
        timestamp: new Date().toISOString(),
        source: 'child-component'
      }
      this.$emit('form-submitted', formData)
      this.inputValue = '' // 清空输入
    }
  }
}
</script>

<style scoped>
.child-component {
  border: 2px solid #42b983;
  padding: 20px;
  margin: 10px;
  border-radius: 8px;
  background: #f0f9ff;
}

.child-component h3 {
  color: #42b983;
  margin-top: 0;
}

.child-component button {
  margin: 5px;
  padding: 8px 12px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.child-component button:hover {
  background: #369870;
}

.form-section {
  margin-top: 15px;
  padding-top: 15px;
  border-top: 1px solid #ddd;
}

.form-section input {
  padding: 8px;
  margin-right: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 200px;
}
</style>
<!-- ParentComponent.vue -->
<template>
  <div class="parent-component">
    <h2>父组件</h2>
    
    <!-- 监听子组件事件 -->
    <ChildComponent
      @message-sent="handleMessageSent"
      @data-changed="handleDataChanged"
      @count-updated="handleCountUpdated"
      @form-submitted="handleFormSubmitted"
      @input-changed="handleInputChanged"
      @multiple-data="handleMultipleData"
    />
    
    <!-- 显示接收到的数据 -->
    <div class="received-data">
      <h3>接收到的数据:</h3>
      <div v-if="lastMessage" class="message">
        <strong>最后消息:</strong> {{ lastMessage }}
      </div>
      <div v-if="lastData" class="data">
        <strong>最后数据:</strong> {{ JSON.stringify(lastData, null, 2) }}
      </div>
      <div class="count">
        <strong>子组件计数:</strong> {{ childCount }}
      </div>
      <div v-if="lastInput" class="input">
        <strong>输入内容:</strong> {{ lastInput }}
      </div>
      <div v-if="submittedForms.length > 0" class="forms">
        <strong>提交的表单:</strong>
        <ul>
          <li v-for="(form, index) in submittedForms" :key="index">
            {{ form.value }} ({{ form.timestamp }})
          </li>
        </ul>
      </div>
    </div>
    
    <!-- 事件日志 -->
    <div class="event-log">
      <h3>事件日志:</h3>
      <div class="log-container">
        <div v-for="(log, index) in eventLogs" :key="index" class="log-item">
          <span class="timestamp">{{ log.timestamp }}</span>
          <span class="event-type">{{ log.type }}</span>
          <span class="event-data">{{ log.data }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
  name: 'ParentComponent',
  components: {
    ChildComponent
  },
  
  data() {
    return {
      lastMessage: '',
      lastData: null,
      childCount: 0,
      lastInput: '',
      submittedForms: [],
      eventLogs: []
    }
  },
  
  methods: {
    handleMessageSent() {
      this.lastMessage = '子组件发送了消息'
      this.addEventLog('message-sent', '简单消息事件')
    },
    
    handleDataChanged(data) {
      this.lastData = data
      this.addEventLog('data-changed', JSON.stringify(data))
    },
    
    handleCountUpdated(count) {
      this.childCount = count
      this.addEventLog('count-updated', `计数更新为: ${count}`)
    },
    
    handleFormSubmitted(formData) {
      this.submittedForms.push(formData)
      this.addEventLog('form-submitted', `表单提交: ${formData.value}`)
    },
    
    handleInputChanged(value) {
      this.lastInput = value
      this.addEventLog('input-changed', `输入变化: ${value}`)
    },
    
    handleMultipleData(param1, param2, param3) {
      this.addEventLog('multiple-data', `多参数: ${param1}, ${param2}, ${JSON.stringify(param3)}`)
    },
    
    addEventLog(type, data) {
      this.eventLogs.unshift({
        timestamp: new Date().toLocaleTimeString(),
        type,
        data
      })
      
      // 限制日志数量
      if (this.eventLogs.length > 10) {
        this.eventLogs.pop()
      }
    }
  }
}
</script>

<style scoped>
.parent-component {
  padding: 20px;
  background: #f9f9f9;
  border-radius: 8px;
}

.received-data {
  background: white;
  padding: 15px;
  margin: 20px 0;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.received-data > div {
  margin-bottom: 10px;
}

.event-log {
  background: white;
  padding: 15px;
  margin: 20px 0;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.log-container {
  max-height: 200px;
  overflow-y: auto;
  border: 1px solid #eee;
  border-radius: 4px;
}

.log-item {
  padding: 8px;
  border-bottom: 1px solid #eee;
  display: flex;
  gap: 10px;
}

.log-item:last-child {
  border-bottom: none;
}

.timestamp {
  color: #666;
  font-size: 12px;
  min-width: 80px;
}

.event-type {
  color: #42b983;
  font-weight: bold;
  min-width: 120px;
}

.event-data {
  color: #333;
  flex: 1;
}
</style>

事件验证和自定义事件名

<!-- CustomEventComponent.vue -->
<template>
  <div class="custom-event-component">
    <h3>自定义事件组件</h3>
    
    <!-- 用户操作事件 -->
    <div class="user-actions">
      <button @click="handleLogin">登录</button>
      <button @click="handleLogout">登出</button>
      <button @click="handleProfileUpdate">更新资料</button>
    </div>
    
    <!-- 数据操作事件 -->
    <div class="data-actions">
      <button @click="createItem">创建项目</button>
      <button @click="updateItem">更新项目</button>
      <button @click="deleteItem">删除项目</button>
    </div>
    
    <!-- 状态变化事件 -->
    <div class="status-actions">
      <select @change="changeStatus" v-model="currentStatus">
        <option value="active">激活</option>
        <option value="inactive">非激活</option>
        <option value="pending">待处理</option>
        <option value="suspended">暂停</option>
      </select>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CustomEventComponent',
  
  // 详细的事件声明
  emits: {
    // 用户认证事件
    'user:login': (userData) => {
      return userData && userData.username && userData.timestamp
    },
    
    'user:logout': (logoutData) => {
      return logoutData && logoutData.timestamp
    },
    
    'user:profile-updated': (profileData) => {
      return profileData && typeof profileData === 'object'
    },
    
    // 数据操作事件
    'data:item-created': (item) => {
      return item && item.id && item.type
    },
    
    'data:item-updated': (updateInfo) => {
      return updateInfo && updateInfo.id && updateInfo.changes
    },
    
    'data:item-deleted': (deleteInfo) => {
      return deleteInfo && deleteInfo.id
    },
    
    // 状态变化事件
    'status:changed': (statusInfo) => {
      return statusInfo && statusInfo.from && statusInfo.to
    }
  },
  
  data() {
    return {
      currentStatus: 'active',
      previousStatus: 'active'
    }
  },
  
  methods: {
    handleLogin() {
      const userData = {
        username: 'user123',
        timestamp: new Date().toISOString(),
        loginMethod: 'password'
      }
      this.$emit('user:login', userData)
    },
    
    handleLogout() {
      const logoutData = {
        timestamp: new Date().toISOString(),
        reason: 'user_initiated'
      }
      this.$emit('user:logout', logoutData)
    },
    
    handleProfileUpdate() {
      const profileData = {
        userId: 'user123',
        changes: {
          email: 'newemail@example.com',
          phone: '+1234567890'
        },
        timestamp: new Date().toISOString()
      }
      this.$emit('user:profile-updated', profileData)
    },
    
    createItem() {
      const item = {
        id: 'item_' + Date.now(),
        type: 'document',
        name: '新文档',
        createdAt: new Date().toISOString()
      }
      this.$emit('data:item-created', item)
    },
    
    updateItem() {
      const updateInfo = {
        id: 'item_123',
        changes: {
          name: '更新的文档名称',
          lastModified: new Date().toISOString()
        },
        updatedBy: 'user123'
      }
      this.$emit('data:item-updated', updateInfo)
    },
    
    deleteItem() {
      const deleteInfo = {
        id: 'item_123',
        deletedAt: new Date().toISOString(),
        deletedBy: 'user123'
      }
      this.$emit('data:item-deleted', deleteInfo)
    },
    
    changeStatus() {
      const statusInfo = {
        from: this.previousStatus,
        to: this.currentStatus,
        timestamp: new Date().toISOString(),
        changedBy: 'user123'
      }
      
      this.$emit('status:changed', statusInfo)
      this.previousStatus = this.currentStatus
    }
  }
}
</script>

<style scoped>
.custom-event-component {
  border: 1px solid #ddd;
  padding: 20px;
  margin: 10px;
  border-radius: 8px;
  background: white;
}

.user-actions,
.data-actions,
.status-actions {
  margin: 15px 0;
  padding: 10px;
  border: 1px solid #eee;
  border-radius: 4px;
}

.user-actions button,
.data-actions button {
  margin: 5px;
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
  background: white;
}

.user-actions button:hover,
.data-actions button:hover {
  background: #f0f0f0;
}

.status-actions select {
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 150px;
}
</style>

4.4 插槽(Slots)

基本插槽使用

<!-- BaseCard.vue -->
<template>
  <div class="base-card" :class="cardClass">
    <!-- 卡片头部插槽 -->
    <header v-if="$slots.header" class="card-header">
      <slot name="header"></slot>
    </header>
    
    <!-- 默认插槽(卡片内容) -->
    <main class="card-content">
      <slot>
        <!-- 默认内容 -->
        <p>这是默认内容,当没有提供插槽内容时显示</p>
      </slot>
    </main>
    
    <!-- 卡片底部插槽 -->
    <footer v-if="$slots.footer" class="card-footer">
      <slot name="footer"></slot>
    </footer>
    
    <!-- 侧边栏插槽 -->
    <aside v-if="$slots.sidebar" class="card-sidebar">
      <slot name="sidebar"></slot>
    </aside>
  </div>
</template>

<script>
export default {
  name: 'BaseCard',
  props: {
    type: {
      type: String,
      default: 'default',
      validator: value => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
    },
    shadow: {
      type: Boolean,
      default: true
    }
  },
  computed: {
    cardClass() {
      return {
        [`card-${this.type}`]: true,
        'card-shadow': this.shadow,
        'has-sidebar': !!this.$slots.sidebar
      }
    }
  }
}
</script>

<style scoped>
.base-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  background: white;
  overflow: hidden;
  margin: 10px;
  display: flex;
  flex-direction: column;
}

.base-card.has-sidebar {
  flex-direction: row;
}

.card-shadow {
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.card-header {
  background: #f8f9fa;
  padding: 15px 20px;
  border-bottom: 1px solid #dee2e6;
  font-weight: bold;
}

.card-content {
  padding: 20px;
  flex: 1;
}

.card-footer {
  background: #f8f9fa;
  padding: 15px 20px;
  border-top: 1px solid #dee2e6;
}

.card-sidebar {
  background: #f1f3f4;
  padding: 20px;
  border-left: 1px solid #dee2e6;
  min-width: 200px;
}

/* 卡片类型样式 */
.card-primary {
  border-color: #007bff;
}

.card-primary .card-header {
  background: #007bff;
  color: white;
}

.card-success {
  border-color: #28a745;
}

.card-success .card-header {
  background: #28a745;
  color: white;
}

.card-warning {
  border-color: #ffc107;
}

.card-warning .card-header {
  background: #ffc107;
  color: #212529;
}

.card-danger {
  border-color: #dc3545;
}

.card-danger .card-header {
  background: #dc3545;
  color: white;
}
</style>
<!-- 使用BaseCard组件 -->
<template>
  <div class="card-examples">
    <h2>插槽示例</h2>
    
    <!-- 基本使用 -->
    <BaseCard>
      <p>这是卡片的主要内容</p>
      <p>可以包含任何HTML内容</p>
    </BaseCard>
    
    <!-- 带头部和底部的卡片 -->
    <BaseCard type="primary">
      <template #header>
        <h3>用户信息</h3>
      </template>
      
      <div class="user-profile">
        <img src="https://via.placeholder.com/60" alt="用户头像" class="avatar">
        <div class="user-details">
          <h4>张三</h4>
          <p>前端开发工程师</p>
          <p>邮箱: zhangsan@example.com</p>
        </div>
      </div>
      
      <template #footer>
        <button class="btn btn-primary">编辑资料</button>
        <button class="btn btn-secondary">发送消息</button>
      </template>
    </BaseCard>
    
    <!-- 带侧边栏的卡片 -->
    <BaseCard type="success">
      <template #header>
        <h3>项目详情</h3>
      </template>
      
      <div class="project-content">
        <h4>Vue.js 学习项目</h4>
        <p>这是一个用于学习Vue.js的示例项目,包含了组件系统、路由、状态管理等核心概念。</p>
        <div class="project-stats">
          <span class="stat">进度: 75%</span>
          <span class="stat">团队成员: 5人</span>
          <span class="stat">截止日期: 2024-03-15</span>
        </div>
      </div>
      
      <template #sidebar>
        <h5>项目工具</h5>
        <ul class="tool-list">
          <li>Vue 3</li>
          <li>Vite</li>
          <li>Vue Router</li>
          <li>Pinia</li>
          <li>Element Plus</li>
        </ul>
        
        <h5>团队成员</h5>
        <ul class="member-list">
          <li>张三 (PM)</li>
          <li>李四 (前端)</li>
          <li>王五 (后端)</li>
          <li>赵六 (设计)</li>
          <li>钱七 (测试)</li>
        </ul>
      </template>
      
      <template #footer>
        <button class="btn btn-success">查看详情</button>
        <button class="btn btn-outline">加入项目</button>
      </template>
    </BaseCard>
    
    <!-- 警告卡片 -->
    <BaseCard type="warning">
      <template #header>
        <h3>⚠️ 重要提醒</h3>
      </template>
      
      <div class="warning-content">
        <p>您的账户将在3天后到期,请及时续费以避免服务中断。</p>
        <ul>
          <li>当前套餐: 专业版</li>
          <li>到期时间: 2024-02-15</li>
          <li>剩余天数: 3天</li>
        </ul>
      </div>
      
      <template #footer>
        <button class="btn btn-warning">立即续费</button>
        <button class="btn btn-outline">稍后提醒</button>
      </template>
    </BaseCard>
  </div>
</template>

<script>
import BaseCard from './components/BaseCard.vue'

export default {
  components: {
    BaseCard
  }
}
</script>

<style scoped>
.card-examples {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.user-profile {
  display: flex;
  align-items: center;
  gap: 15px;
}

.avatar {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  object-fit: cover;
}

.user-details h4 {
  margin: 0 0 5px 0;
  color: #333;
}

.user-details p {
  margin: 2px 0;
  color: #666;
  font-size: 14px;
}

.project-stats {
  margin-top: 15px;
}

.stat {
  display: inline-block;
  background: #e9ecef;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  margin-right: 10px;
}

.tool-list,
.member-list {
  list-style: none;
  padding: 0;
  margin: 10px 0;
}

.tool-list li,
.member-list li {
  padding: 5px 0;
  border-bottom: 1px solid #eee;
  font-size: 14px;
}

.tool-list li:last-child,
.member-list li:last-child {
  border-bottom: none;
}

.warning-content ul {
  margin: 15px 0;
  padding-left: 20px;
}

.warning-content li {
  margin: 5px 0;
}

/* 按钮样式 */
.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  margin-right: 10px;
  transition: all 0.2s;
}

.btn-primary {
  background: #007bff;
  color: white;
}

.btn-primary:hover {
  background: #0056b3;
}

.btn-secondary {
  background: #6c757d;
  color: white;
}

.btn-secondary:hover {
  background: #545b62;
}

.btn-success {
  background: #28a745;
  color: white;
}

.btn-success:hover {
  background: #1e7e34;
}

.btn-warning {
  background: #ffc107;
  color: #212529;
}

.btn-warning:hover {
  background: #e0a800;
}

.btn-outline {
  background: transparent;
  border: 1px solid #ccc;
  color: #333;
}

.btn-outline:hover {
  background: #f8f9fa;
}
</style>

作用域插槽

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <!-- 表格头部 -->
    <div class="table-header">
      <slot name="header" :total="filteredData.length" :selected="selectedItems">
        <h3>数据表格 ({{ filteredData.length }} 条记录)</h3>
      </slot>
    </div>
    
    <!-- 搜索和过滤 -->
    <div class="table-controls">
      <slot name="controls" :search="search" :updateSearch="updateSearch">
        <input 
          v-model="search" 
          placeholder="搜索..." 
          class="search-input"
        >
      </slot>
    </div>
    
    <!-- 表格内容 -->
    <div class="table-content">
      <table>
        <thead>
          <tr>
            <th v-if="selectable">
              <input 
                type="checkbox" 
                :checked="isAllSelected" 
                @change="toggleSelectAll"
              >
            </th>
            <th v-for="column in columns" :key="column.key">
              {{ column.title }}
            </th>
            <th v-if="$slots.actions">操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in paginatedData" :key="getItemKey(item, index)">
            <td v-if="selectable">
              <input 
                type="checkbox" 
                :checked="isSelected(item)" 
                @change="toggleSelect(item)"
              >
            </td>
            <td v-for="column in columns" :key="column.key">
              <!-- 作用域插槽:允许自定义单元格内容 -->
              <slot 
                :name="`cell-${column.key}`" 
                :item="item" 
                :value="getNestedValue(item, column.key)"
                :index="index"
                :column="column"
              >
                <!-- 默认单元格内容 -->
                {{ formatCellValue(item, column) }}
              </slot>
            </td>
            <td v-if="$slots.actions">
              <!-- 操作列作用域插槽 -->
              <slot 
                name="actions" 
                :item="item" 
                :index="index"
                :edit="() => editItem(item)"
                :delete="() => deleteItem(item)"
              >
                <button @click="editItem(item)">编辑</button>
                <button @click="deleteItem(item)">删除</button>
              </slot>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    
    <!-- 分页 -->
    <div class="table-pagination">
      <slot 
        name="pagination" 
        :currentPage="currentPage"
        :totalPages="totalPages"
        :pageSize="pageSize"
        :total="filteredData.length"
        :goToPage="goToPage"
        :changePageSize="changePageSize"
      >
        <div class="pagination-info">
          显示 {{ startIndex + 1 }}-{{ endIndex }} 条,共 {{ filteredData.length }} 条
        </div>
        <div class="pagination-controls">
          <button 
            @click="goToPage(currentPage - 1)" 
            :disabled="currentPage === 1"
          >
            上一页
          </button>
          <span>第 {{ currentPage }} 页,共 {{ totalPages }} 页</span>
          <button 
            @click="goToPage(currentPage + 1)" 
            :disabled="currentPage === totalPages"
          >
            下一页
          </button>
        </div>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'DataTable',
  props: {
    data: {
      type: Array,
      required: true
    },
    columns: {
      type: Array,
      required: true
    },
    selectable: {
      type: Boolean,
      default: false
    },
    pageSize: {
      type: Number,
      default: 10
    },
    searchFields: {
      type: Array,
      default: () => []
    }
  },
  
  emits: ['selection-change', 'item-edit', 'item-delete'],
  
  data() {
    return {
      search: '',
      currentPage: 1,
      selectedItems: []
    }
  },
  
  computed: {
    filteredData() {
      if (!this.search) return this.data
      
      const searchLower = this.search.toLowerCase()
      return this.data.filter(item => {
        if (this.searchFields.length > 0) {
          return this.searchFields.some(field => {
            const value = this.getNestedValue(item, field)
            return String(value).toLowerCase().includes(searchLower)
          })
        } else {
          return Object.values(item).some(value => 
            String(value).toLowerCase().includes(searchLower)
          )
        }
      })
    },
    
    totalPages() {
      return Math.ceil(this.filteredData.length / this.pageSize)
    },
    
    startIndex() {
      return (this.currentPage - 1) * this.pageSize
    },
    
    endIndex() {
      return Math.min(this.startIndex + this.pageSize, this.filteredData.length)
    },
    
    paginatedData() {
      return this.filteredData.slice(this.startIndex, this.endIndex)
    },
    
    isAllSelected() {
      return this.paginatedData.length > 0 && 
             this.paginatedData.every(item => this.isSelected(item))
    }
  },
  
  methods: {
    getNestedValue(obj, path) {
      return path.split('.').reduce((current, key) => current?.[key], obj)
    },
    
    formatCellValue(item, column) {
      const value = this.getNestedValue(item, column.key)
      
      if (column.formatter && typeof column.formatter === 'function') {
        return column.formatter(value, item)
      }
      
      return value
    },
    
    getItemKey(item, index) {
      return item.id || item.key || index
    },
    
    isSelected(item) {
      return this.selectedItems.some(selected => 
        this.getItemKey(selected) === this.getItemKey(item)
      )
    },
    
    toggleSelect(item) {
      const index = this.selectedItems.findIndex(selected => 
        this.getItemKey(selected) === this.getItemKey(item)
      )
      
      if (index > -1) {
        this.selectedItems.splice(index, 1)
      } else {
        this.selectedItems.push(item)
      }
      
      this.$emit('selection-change', this.selectedItems)
    },
    
    toggleSelectAll() {
      if (this.isAllSelected) {
        // 取消选择当前页的所有项
        this.paginatedData.forEach(item => {
          const index = this.selectedItems.findIndex(selected => 
            this.getItemKey(selected) === this.getItemKey(item)
          )
          if (index > -1) {
            this.selectedItems.splice(index, 1)
          }
        })
      } else {
        // 选择当前页的所有项
        this.paginatedData.forEach(item => {
          if (!this.isSelected(item)) {
            this.selectedItems.push(item)
          }
        })
      }
      
      this.$emit('selection-change', this.selectedItems)
    },
    
    goToPage(page) {
      if (page >= 1 && page <= this.totalPages) {
        this.currentPage = page
      }
    },
    
    changePageSize(newSize) {
      this.pageSize = newSize
      this.currentPage = 1
    },
    
    updateSearch(newSearch) {
      this.search = newSearch
      this.currentPage = 1
    },
    
    editItem(item) {
      this.$emit('item-edit', item)
    },
    
    deleteItem(item) {
      this.$emit('item-delete', item)
    }
  }
}
</script>

<style scoped>
.data-table {
  border: 1px solid #ddd;
  border-radius: 8px;
  background: white;
  overflow: hidden;
}

.table-header {
  padding: 15px 20px;
  background: #f8f9fa;
  border-bottom: 1px solid #dee2e6;
}

.table-controls {
  padding: 15px 20px;
  background: #f8f9fa;
  border-bottom: 1px solid #dee2e6;
}

.search-input {
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 300px;
}

.table-content {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #dee2e6;
}

th {
  background: #f8f9fa;
  font-weight: 600;
  color: #495057;
}

tr:hover {
  background: #f8f9fa;
}

.table-pagination {
  padding: 15px 20px;
  background: #f8f9fa;
  border-top: 1px solid #dee2e6;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.pagination-controls {
  display: flex;
  align-items: center;
  gap: 10px;
}

.pagination-controls button {
  padding: 6px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
}

.pagination-controls button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.pagination-controls button:hover:not(:disabled) {
  background: #f8f9fa;
}
</style>

本章小结

本章我们深入学习了Vue.js的组件系统:

  1. 组件基础:组件定义、注册和data函数的重要性
  2. Props通信:父向子传递数据,包括类型检查和验证
  3. 自定义事件:子向父通信,事件发射和监听
  4. 插槽系统:内容分发,包括具名插槽和作用域插槽

下一章预告

下一章我们将学习Vue.js的高级组件特性,包括: - 动态组件和异步组件 - 组件生命周期 - 混入和组合式API - 高阶组件模式

练习题

基础练习

  1. 组件通信练习

    • 创建一个父子组件通信的示例
    • 实现数据的双向传递
    • 使用自定义事件处理用户操作
  2. 插槽练习

    • 创建一个模态框组件
    • 使用具名插槽定义头部、内容和底部
    • 实现作用域插槽传递数据

进阶练习

  1. 复杂组件练习

    • 创建一个可复用的表单组件
    • 支持不同类型的表单字段
    • 实现表单验证和提交功能
  2. 组件库练习

    • 设计一套基础UI组件
    • 包括按钮、输入框、卡片等
    • 确保组件的可复用性和一致性

提示:组件系统是Vue.js的核心,掌握好组件通信和插槽使用对构建复杂应用至关重要。