04.Vue自定义组件制作详细指南

发布于:2025-07-03 ⋅ 阅读:(27) ⋅ 点赞:(0)

1. Vue组件基础概念

1.1 什么是Vue组件?

Vue组件是可复用的Vue实例,具有独立的功能和界面。组件化是Vue.js的核心思想之一,它允许我们将复杂的应用拆分成独立、可复用的小组件。

1.2 组件的核心特点

1.2.1 可复用性
  • 一次编写,多处使用
  • 减少代码重复
  • 提高开发效率
1.2.2 封装性
  • 内部逻辑独立
  • 数据和方法封装
  • 样式作用域隔离
1.2.3 组合性
  • 组件可以嵌套使用
  • 父子组件通信
  • 构建复杂应用
1.2.4 维护性
  • 职责单一
  • 易于测试
  • 便于维护和更新

1.3 组件的组成部分

Vue组件通常由三个部分组成:

<template>
  <!-- HTML模板 -->
  <div>组件的HTML结构</div>
</template>

<script>
// JavaScript逻辑
export default {
  name: 'MyComponent',
  // 组件选项
}
</script>

<style scoped>
/* CSS样式 */
div {
  color: blue;
}
</style>

各部分说明:

  • template: HTML模板,定义组件的结构
  • script: JavaScript逻辑,定义组件的行为
  • style: CSS样式,定义组件的外观

2. 创建第一个自定义组件

2.1 简单的Hello组件

让我们从最简单的组件开始:

<!-- HelloComponent.vue -->
<template>
  <div class="hello">
    <h1>Hello, Vue组件!</h1>
    <p>这是我的第一个自定义组件</p>
  </div>
</template>

<script>
export default {
  name: 'HelloComponent'
}
</script>

<style scoped>
.hello {
  text-align: center;
  color: #42b983;
  padding: 20px;
  border: 2px solid #42b983;
  border-radius: 10px;
  margin: 20px;
}

h1 {
  font-size: 2em;
  margin-bottom: 10px;
}

p {
  font-size: 1.2em;
  color: #666;
}
</style>

2.2 在父组件中使用

<!-- App.vue -->
<template>
  <div id="app">
    <h1>我的Vue应用</h1>
    <!-- 使用自定义组件 -->
    <HelloComponent />
    <HelloComponent />
    <HelloComponent />
  </div>
</template>

<script>
// 导入组件
import HelloComponent from './components/HelloComponent.vue'

export default {
  name: 'App',
  components: {
    // 注册组件
    HelloComponent
  }
}
</script>

<style>
#app {
  font-family: Arial, sans-serif;
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}
</style>

2.3 组件注册详解

2.3.1 局部注册(推荐)
<script>
import MyComponent from './components/MyComponent.vue'

export default {
  components: {
    MyComponent,  // ES6简写
    // 完整写法:MyComponent: MyComponent
  }
}
</script>
2.3.2 全局注册
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import MyComponent from './components/MyComponent.vue'

const app = createApp(App)

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

app.mount('#app')
2.3.3 注册方式对比
注册方式 优点 缺点 使用场景
局部注册 按需引入,减小打包体积 需要在每个使用的地方导入 大部分场景
全局注册 无需导入,直接使用 增加打包体积,难以追踪依赖 基础组件

3. 组件Props详解

3.1 什么是Props?

Props是父组件传递给子组件的数据。它是组件间通信的主要方式之一。

3.2 基础Props使用

3.2.1 定义Props
<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <img :src="avatar" :alt="name" class="avatar">
    <h3>{{ name }}</h3>
    <p>{{ email }}</p>
    <p>年龄: {{ age }}</p>
  </div>
</template>

<script>
export default {
  name: 'UserCard',
  props: {
    name: String,      // 字符串类型
    email: String,     // 字符串类型
    age: Number,       // 数字类型
    avatar: String     // 字符串类型
  }
}
</script>

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

.avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  margin-bottom: 10px;
}
</style>
3.2.2 使用Props
<!-- 父组件 -->
<template>
  <div>
    <h1>用户列表</h1>
    <UserCard 
      name="张三" 
      email="zhangsan@example.com" 
      :age="25"
      avatar="https://via.placeholder.com/80"
    />
    <UserCard 
      name="李四" 
      email="lisi@example.com" 
      :age="30"
      avatar="https://via.placeholder.com/80"
    />
  </div>
</template>

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

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

重要注意事项:

  • 字符串可以直接传递:name="张三"
  • 其他类型需要使用v-bind::age="25"

3.3 Props类型详解

3.3.1 基础类型
<script>
export default {
  props: {
    // 字符串
    title: String,
    
    // 数字
    count: Number,
    
    // 布尔值
    isActive: Boolean,
    
    // 数组
    items: Array,
    
    // 对象
    user: Object,
    
    // 函数
    callback: Function,
    
    // 日期
    date: Date
  }
}
</script>
3.3.2 多类型Props
<script>
export default {
  props: {
    // 多种类型
    value: [String, Number],
    
    // 任意类型
    data: null,
    
    // 自定义类型
    person: Person  // 假设Person是一个类
  }
}
</script>
3.3.3 详细Props配置
<script>
export default {
  props: {
    // 基础类型检查
    title: String,
    
    // 必需的字符串
    name: {
      type: String,
      required: true
    },
    
    // 带默认值的数字
    age: {
      type: Number,
      default: 18
    },
    
    // 带默认值的对象
    user: {
      type: Object,
      default() {
        return { name: '匿名用户', age: 0 }
      }
    },
    
    // 带验证的自定义prop
    score: {
      type: Number,
      validator(value) {
        return value >= 0 && value <= 100
      }
    },
    
    // 多类型 + 默认值
    size: {
      type: [String, Number],
      default: 'medium',
      validator(value) {
        return ['small', 'medium', 'large'].includes(value) || typeof value === 'number'
      }
    }
  }
}
</script>

3.4 Props最佳实践

3.4.1 命名规范
<script>
export default {
  props: {
    // ✅ 推荐:camelCase
    userName: String,
    isActive: Boolean,
    maxLength: Number,
    
    // ❌ 避免:kebab-case在JavaScript中
    // 'user-name': String,  // 不推荐
  }
}
</script>
<!-- 模板中使用kebab-case -->
<template>
  <MyComponent 
    :user-name="name" 
    :is-active="active"
    :max-length="50"
  />
</template>
3.4.2 Props验证示例
<!-- ProductCard.vue -->
<template>
  <div class="product-card">
    <img :src="product.image" :alt="product.name">
    <h3>{{ product.name }}</h3>
    <p class="price">¥{{ product.price }}</p>
    <p class="description">{{ product.description }}</p>
    <button :class="buttonClass" @click="addToCart">
      加入购物车
    </button>
  </div>
</template>

<script>
export default {
  name: 'ProductCard',
  props: {
    product: {
      type: Object,
      required: true,
      validator(value) {
        // 验证必需的属性
        return value && 
               typeof value.name === 'string' &&
               typeof value.price === 'number' &&
               value.price > 0
      }
    },
    size: {
      type: String,
      default: 'medium',
      validator(value) {
        return ['small', 'medium', 'large'].includes(value)
      }
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  computed: {
    buttonClass() {
      return {
        'btn': true,
        'btn-small': this.size === 'small',
        'btn-medium': this.size === 'medium',
        'btn-large': this.size === 'large',
        'btn-disabled': this.disabled
      }
    }
  },
  methods: {
    addToCart() {
      if (!this.disabled) {
        this.$emit('add-to-cart', this.product)
      }
    }
  }
}
</script>

<style scoped>
.product-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  margin: 8px;
  transition: box-shadow 0.3s;
}

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

.price {
  font-size: 1.2em;
  font-weight: bold;
  color: #e74c3c;
}

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.btn-small { padding: 4px 8px; font-size: 0.8em; }
.btn-medium { padding: 8px 16px; font-size: 1em; }
.btn-large { padding: 12px 24px; font-size: 1.2em; }

.btn:not(.btn-disabled) {
  background-color: #3498db;
  color: white;
}

.btn:not(.btn-disabled):hover {
  background-color: #2980b9;
}

.btn-disabled {
  background-color: #bdc3c7;
  color: #7f8c8d;
  cursor: not-allowed;
}
</style>

4. 组件事件与通信

4.1 子组件向父组件传递数据

4.1.1 使用$emit触发事件
<!-- Counter.vue -->
<template>
  <div class="counter">
    <h3>计数器: {{ count }}</h3>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  name: 'Counter',
  props: {
    initialValue: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      count: this.initialValue
    }
  },
  methods: {
    increment() {
      this.count++
      // 触发自定义事件,传递新的值
      this.$emit('count-change', this.count)
    },
    decrement() {
      this.count--
      this.$emit('count-change', this.count)
    },
    reset() {
      this.count = this.initialValue
      this.$emit('count-change', this.count)
      // 触发重置事件
      this.$emit('reset')
    }
  }
}
</script>

<style scoped>
.counter {
  border: 2px solid #3498db;
  border-radius: 8px;
  padding: 20px;
  margin: 10px;
  text-align: center;
}

button {
  margin: 5px;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: #3498db;
  color: white;
  cursor: pointer;
}

button:hover {
  background-color: #2980b9;
}
</style>
4.1.2 在父组件中监听事件
<!-- 父组件 -->
<template>
  <div>
    <h1>计数器应用</h1>
    <p>总计数: {{ totalCount }}</p>
    
    <Counter 
      :initial-value="10"
      @count-change="handleCountChange"
      @reset="handleReset"
    />
    
    <Counter 
      :initial-value="5"
      @count-change="handleCountChange"
      @reset="handleReset"
    />
    
    <!-- 事件日志 -->
    <div class="log">
      <h3>事件日志:</h3>
      <ul>
        <li v-for="(log, index) in eventLogs" :key="index">
          {{ log }}
        </li>
      </ul>
    </div>
  </div>
</template>

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

export default {
  components: {
    Counter
  },
  data() {
    return {
      totalCount: 15, // 10 + 5
      eventLogs: []
    }
  },
  methods: {
    handleCountChange(newValue) {
      console.log('计数器值改变:', newValue)
      this.eventLogs.push(`计数器值改变为: ${newValue} - ${new Date().toLocaleTimeString()}`)
      // 这里可以进行其他处理,比如更新总计数
    },
    handleReset() {
      console.log('计数器被重置')
      this.eventLogs.push(`计数器被重置 - ${new Date().toLocaleTimeString()}`)
    }
  }
}
</script>

<style>
.log {
  margin-top: 20px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 5px;
}

.log ul {
  max-height: 200px;
  overflow-y: auto;
}

.log li {
  margin: 5px 0;
  padding: 5px;
  background-color: white;
  border-radius: 3px;
}
</style>

4.2 事件验证

4.2.1 声明emits选项
<script>
export default {
  name: 'MyComponent',
  emits: ['update', 'delete', 'create'],
  // 或者使用对象语法进行验证
  emits: {
    // 无验证
    click: null,
    
    // 带验证
    submit: (payload) => {
      return payload && payload.email && payload.password
    },
    
    // 复杂验证
    'update-user': (user) => {
      return user && 
             typeof user.id === 'number' && 
             typeof user.name === 'string'
    }
  },
  methods: {
    handleSubmit() {
      const payload = { email: 'test@example.com', password: '123456' }
      this.$emit('submit', payload)
    }
  }
}
</script>

4.3 表单组件实例

<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="login-form">
    <h2>用户登录</h2>
    
    <div class="form-group">
      <label for="email">邮箱:</label>
      <input 
        id="email"
        v-model="formData.email" 
        type="email" 
        required
        :class="{ error: errors.email }"
        @blur="validateEmail"
      >
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <label for="password">密码:</label>
      <input 
        id="password"
        v-model="formData.password" 
        type="password" 
        required
        :class="{ error: errors.password }"
        @blur="validatePassword"
      >
      <span v-if="errors.password" class="error-message">{{ errors.password }}</span>
    </div>
    
    <div class="form-group">
      <label>
        <input 
          v-model="formData.rememberMe" 
          type="checkbox"
        > 记住我
      </label>
    </div>
    
    <button type="submit" :disabled="!isFormValid" class="submit-btn">
      {{ loading ? '登录中...' : '登录' }}
    </button>
    
    <p class="register-link">
      还没有账号? 
      <a href="#" @click.prevent="$emit('switch-to-register')">立即注册</a>
    </p>
  </form>
</template>

<script>
export default {
  name: 'LoginForm',
  emits: {
    'login-submit': (formData) => {
      return formData && formData.email && formData.password
    },
    'switch-to-register': null
  },
  props: {
    loading: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      formData: {
        email: '',
        password: '',
        rememberMe: false
      },
      errors: {
        email: '',
        password: ''
      }
    }
  },
  computed: {
    isFormValid() {
      return this.formData.email && 
             this.formData.password && 
             !this.errors.email && 
             !this.errors.password
    }
  },
  methods: {
    validateEmail() {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
      if (!this.formData.email) {
        this.errors.email = '邮箱不能为空'
      } else if (!emailRegex.test(this.formData.email)) {
        this.errors.email = '请输入有效的邮箱地址'
      } else {
        this.errors.email = ''
      }
    },
    validatePassword() {
      if (!this.formData.password) {
        this.errors.password = '密码不能为空'
      } else if (this.formData.password.length < 6) {
        this.errors.password = '密码至少需要6位'
      } else {
        this.errors.password = ''
      }
    },
    handleSubmit() {
      this.validateEmail()
      this.validatePassword()
      
      if (this.isFormValid) {
        this.$emit('login-submit', { ...this.formData })
      }
    }
  }
}
</script>

<style scoped>
.login-form {
  max-width: 400px;
  margin: 0 auto;
  padding: 30px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background-color: #fff;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.form-group {
  margin-bottom: 20px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #333;
}

input[type="email"],
input[type="password"] {
  width: 100%;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.3s;
}

input[type="email"]:focus,
input[type="password"]:focus {
  outline: none;
  border-color: #3498db;
}

input.error {
  border-color: #e74c3c;
}

.error-message {
  color: #e74c3c;
  font-size: 14px;
  margin-top: 5px;
  display: block;
}

.submit-btn {
  width: 100%;
  padding: 12px;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.submit-btn:hover:not(:disabled) {
  background-color: #2980b9;
}

.submit-btn:disabled {
  background-color: #bdc3c7;
  cursor: not-allowed;
}

.register-link {
  text-align: center;
  margin-top: 15px;
}

.register-link a {
  color: #3498db;
  text-decoration: none;
}

.register-link a:hover {
  text-decoration: underline;
}
</style>

5. 插槽(Slots)详解

5.1 基础插槽

5.1.1 默认插槽
<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <h3>{{ title }}</h3>
    </div>
    <div class="card-body">
      <!-- 插槽内容会在这里显示 -->
      <slot></slot>
    </div>
    <div class="card-footer" v-if="showFooter">
      <small>{{ footerText }}</small>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Card',
  props: {
    title: {
      type: String,
      required: true
    },
    showFooter: {
      type: Boolean,
      default: true
    },
    footerText: {
      type: String,
      default: 'Card footer'
    }
  }
}
</script>

<style scoped>
.card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  margin: 10px;
}

.card-header {
  background-color: #f8f9fa;
  padding: 15px;
  border-bottom: 1px solid #e0e0e0;
}

.card-header h3 {
  margin: 0;
  color: #333;
}

.card-body {
  padding: 20px;
}

.card-footer {
  background-color: #f8f9fa;
  padding: 10px 15px;
  border-top: 1px solid #e0e0e0;
  color: #666;
}
</style>
5.1.2 使用默认插槽
<!-- 父组件 -->
<template>
  <div>
    <Card title="用户信息">
      <p>姓名: 张三</p>
      <p>年龄: 25岁</p>
      <p>职业: 前端开发工程师</p>
    </Card>
    
    <Card title="产品列表" :show-footer="false">
      <ul>
        <li>苹果 - ¥5.00</li>
        <li>香蕉 - ¥3.00</li>
        <li>橙子 - ¥4.00</li>
      </ul>
    </Card>
    
    <Card title="图片展示" footer-text="点击查看大图">
      <img src="https://via.placeholder.com/300x200" alt="示例图片" style="width: 100%; border-radius: 4px;">
    </Card>
  </div>
</template>

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

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

5.2 具名插槽

5.2.1 定义具名插槽
<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="layout-header">
      <slot name="header">
        <!-- 默认头部内容 -->
        <h1>默认标题</h1>
      </slot>
    </header>
    
    <nav class="layout-sidebar">
      <slot name="sidebar">
        <!-- 默认侧边栏内容 -->
        <ul>
          <li><a href="#">首页</a></li>
          <li><a href="#">关于</a></li>
        </ul>
      </slot>
    </nav>
    
    <main class="layout-main">
      <slot>
        <!-- 默认主要内容 -->
        <p>默认主要内容</p>
      </slot>
    </main>
    
    <footer class="layout-footer">
      <slot name="footer">
        <!-- 默认底部内容 -->
        <p>&copy; 2023 我的网站</p>
      </slot>
    </footer>
  </div>
</template>

<script>
export default {
  name: 'Layout'
}
</script>

<style scoped>
.layout {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 200px 1fr;
  grid-template-rows: auto 1fr auto;
  min-height: 100vh;
}

.layout-header {
  grid-area: header;
  background-color: #2c3e50;
  color: white;
  padding: 1rem;
}

.layout-sidebar {
  grid-area: sidebar;
  background-color: #34495e;
  color: white;
  padding: 1rem;
}

.layout-main {
  grid-area: main;
  padding: 2rem;
  background-color: #ecf0f1;
}

.layout-footer {
  grid-area: footer;
  background-color: #95a5a6;
  color: white;
  padding: 1rem;
  text-align: center;
}

.layout-sidebar ul {
  list-style: none;
  padding: 0;
}

.layout-sidebar li {
  margin: 10px 0;
}

.layout-sidebar a {
  color: white;
  text-decoration: none;
}

.layout-sidebar a:hover {
  text-decoration: underline;
}
</style>
5.2.2 使用具名插槽
<!-- 父组件 -->
<template>
  <Layout>
    <!-- 使用 v-slot 指定插槽名称 -->
    <template v-slot:header>
      <div style="display: flex; justify-content: space-between; align-items: center;">
        <h1>我的博客</h1>
        <nav>
          <a href="#" style="color: white; margin: 0 10px;">首页</a>
          <a href="#" style="color: white; margin: 0 10px;">文章</a>
          <a href="#" style="color: white; margin: 0 10px;">关于</a>
        </nav>
      </div>
    </template>
    
    <!-- 简写语法 #sidebar -->
    <template #sidebar>
      <div>
        <h3>分类</h3>
        <ul>
          <li><a href="#">前端开发</a></li>
          <li><a href="#">后端开发</a></li>
          <li><a href="#">数据库</a></li>
          <li><a href="#">运维部署</a></li>
        </ul>
        
        <h3>最新文章</h3>
        <ul>
          <li><a href="#">Vue 3 组件开发指南</a></li>
          <li><a href="#">JavaScript 异步编程</a></li>
          <li><a href="#">CSS Grid 布局详解</a></li>
        </ul>
      </div>
    </template>
    
    <!-- 默认插槽 -->
    <div>
      <article>
        <h2>Vue 组件化开发最佳实践</h2>
        <p class="meta">发布时间: 2023-12-01 | 作者: 张三</p>
        <p>组件化是 Vue.js 的核心概念之一,通过合理的组件设计可以大大提高代码的可维护性和复用性...</p>
        <p>在本文中,我们将深入探讨 Vue 组件的各个方面,包括 Props、Events、Slots 等重要概念...</p>
      </article>
    </div>
    
    <template #footer>
      <div>
        <p>&copy; 2023 我的博客 | 
          <a href="#" style="color: white;">隐私政策</a> | 
          <a href="#" style="color: white;">联系我们</a>
        </p>
      </div>
    </template>
  </Layout>
</template>

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

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

<style>
.meta {
  color: #666;
  font-size: 0.9em;
  margin-bottom: 15px;
}

article {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
</style>

5.3 作用域插槽

作用域插槽允许子组件向父组件传递数据,父组件可以决定如何渲染这些数据。

5.3.1 基础作用域插槽
<!-- TodoList.vue -->
<template>
  <div class="todo-list">
    <h3>{{ title }}</h3>
    <ul>
      <li v-for="item in items" :key="item.id" class="todo-item">
        <!-- 作用域插槽,传递数据给父组件 -->
        <slot :item="item" :index="index" :toggleComplete="toggleComplete">
          <!-- 默认内容 -->
          <span :class="{ completed: item.completed }">{{ item.text }}</span>
          <button @click="toggleComplete(item.id)">
            {{ item.completed ? '取消完成' : '标记完成' }}
          </button>
        </slot>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'TodoList',
  props: {
    title: String,
    items: Array
  },
  methods: {
    toggleComplete(id) {
      this.$emit('toggle-complete', id)
    }
  }
}
</script>
5.3.2 使用作用域插槽
<!-- 父组件 -->
<template>
  <div>
    <TodoList 
      title="我的任务列表" 
      :items="todos"
      @toggle-complete="handleToggle"
    >
      <!-- 使用作用域插槽自定义渲染 -->
      <template #default="{ item, toggleComplete }">
        <div class="custom-todo">
          <input 
            type="checkbox" 
            :checked="item.completed"
            @change="toggleComplete(item.id)"
          >
          <span 
            :class="{ 
              'completed': item.completed,
              'urgent': item.priority === 'high' 
            }"
          >
            {{ item.text }}
          </span>
          <span class="priority">{{ item.priority }}</span>
          <button @click="deleteItem(item.id)" class="delete-btn">删除</button>
        </div>
      </template>
    </TodoList>
  </div>
</template>

6. 动态组件

6.1 基础动态组件

<template>
  <div class="tab-container">
    <div class="tab-buttons">
      <button 
        v-for="tab in tabs" 
        :key="tab.name"
        @click="currentTab = tab.name"
        :class="{ active: currentTab === tab.name }"
      >
        {{ tab.label }}
      </button>
    </div>
    
    <!-- 动态组件 -->
    <component :is="currentTab" :data="tabData[currentTab]"></component>
  </div>
</template>

<script>
import UserProfile from './UserProfile.vue'
import UserSettings from './UserSettings.vue'
import UserPosts from './UserPosts.vue'

export default {
  components: {
    UserProfile,
    UserSettings,
    UserPosts
  },
  data() {
    return {
      currentTab: 'UserProfile',
      tabs: [
        { name: 'UserProfile', label: '个人资料' },
        { name: 'UserSettings', label: '设置' },
        { name: 'UserPosts', label: '我的文章' }
      ],
      tabData: {
        UserProfile: { name: '张三', age: 25 },
        UserSettings: { theme: 'dark', language: 'zh-CN' },
        UserPosts: [{ title: '文章1' }, { title: '文章2' }]
      }
    }
  }
}
</script>

7. 生命周期钩子

7.1 组件生命周期详解

<template>
  <div class="lifecycle-demo">
    <h3>生命周期演示组件</h3>
    <p>计数器: {{ count }}</p>
    <button @click="count++">增加</button>
    <p>消息: {{ message }}</p>
    <input v-model="message" placeholder="输入消息">
  </div>
</template>

<script>
export default {
  name: 'LifecycleDemo',
  props: {
    initialCount: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      count: this.initialCount,
      message: '',
      timer: null
    }
  },
  
  // 组件实例创建之前
  beforeCreate() {
    console.log('beforeCreate: 组件实例刚创建')
    console.log('此时 data 和 methods 还不可用')
  },
  
  // 组件实例创建完成
  created() {
    console.log('created: 组件实例创建完成')
    console.log('data:', this.count)
    console.log('可以访问 data 和 methods,但DOM还未挂载')
    
    // 适合进行数据初始化
    this.fetchData()
  },
  
  // DOM挂载之前
  beforeMount() {
    console.log('beforeMount: DOM挂载之前')
    console.log('模板编译完成,但还未渲染到页面')
  },
  
  // DOM挂载完成
  mounted() {
    console.log('mounted: DOM挂载完成')
    console.log('可以访问DOM元素')
    console.log('适合进行DOM操作或启动定时器')
    
    // 启动定时器
    this.timer = setInterval(() => {
      console.log(`组件运行中,当前计数: ${this.count}`)
    }, 5000)
  },
  
  // 数据更新前
  beforeUpdate() {
    console.log('beforeUpdate: 数据更新前')
    console.log('响应式数据发生变化,DOM还未重新渲染')
  },
  
  // 数据更新后
  updated() {
    console.log('updated: 数据更新后')
    console.log('DOM已重新渲染')
  },
  
  // 组件卸载前
  beforeUnmount() {
    console.log('beforeUnmount: 组件卸载前')
    console.log('组件仍完全可用')
    
    // 清理工作
    if (this.timer) {
      clearInterval(this.timer)
      this.timer = null
    }
  },
  
  // 组件卸载后
  unmounted() {
    console.log('unmounted: 组件卸载后')
    console.log('组件实例已销毁')
  },
  
  methods: {
    fetchData() {
      console.log('模拟获取数据...')
      setTimeout(() => {
        this.message = '数据加载完成'
      }, 1000)
    }
  }
}
</script>

8. 混入(Mixins)

8.1 基础混入

// mixins/loggerMixin.js
export default {
  data() {
    return {
      logs: []
    }
  },
  methods: {
    log(message) {
      const timestamp = new Date().toLocaleTimeString()
      this.logs.push(`[${timestamp}] ${message}`)
      console.log(`[${this.$options.name}] ${message}`)
    },
    clearLogs() {
      this.logs = []
    }
  },
  mounted() {
    this.log('组件已挂载')
  }
}

8.2 使用混入

<template>
  <div class="mixed-component">
    <h3>混入示例组件</h3>
    <button @click="handleClick">点击我</button>
    <button @click="clearLogs">清空日志</button>
    
    <div class="logs">
      <h4>操作日志:</h4>
      <ul>
        <li v-for="(log, index) in logs" :key="index">{{ log }}</li>
      </ul>
    </div>
  </div>
</template>

<script>
import loggerMixin from '@/mixins/loggerMixin.js'

export default {
  name: 'MixedComponent',
  mixins: [loggerMixin],
  data() {
    return {
      clickCount: 0
    }
  },
  methods: {
    handleClick() {
      this.clickCount++
      this.log(`按钮被点击,总计: ${this.clickCount} 次`)
    }
  }
}
</script>

9. 最佳实践

9.1 组件设计原则

9.1.1 单一职责原则
<!-- ✅ 好的例子:职责单一 -->
<template>
  <div class="user-avatar">
    <img :src="src" :alt="alt" :class="sizeClass">
  </div>
</template>

<script>
export default {
  name: 'UserAvatar',
  props: {
    src: String,
    alt: String,
    size: {
      type: String,
      default: 'medium',
      validator: value => ['small', 'medium', 'large'].includes(value)
    }
  },
  computed: {
    sizeClass() {
      return `avatar-${this.size}`
    }
  }
}
</script>
9.1.2 命名规范
<!-- ✅ 推荐命名 -->
<template>
  <div>
    <!-- 组件名:多词、PascalCase -->
    <UserProfile />
    <ProductCard />
    <NavigationMenu />
    
    <!-- 属性名:kebab-case -->
    <UserCard 
      :user-name="name"
      :is-active="active"
      :max-items="10"
    />
  </div>
</template>
9.1.3 Props设计
<script>
export default {
  props: {
    // ✅ 提供类型和默认值
    title: {
      type: String,
      required: true
    },
    
    // ✅ 提供验证器
    status: {
      type: String,
      default: 'pending',
      validator: value => ['pending', 'approved', 'rejected'].includes(value)
    },
    
    // ✅ 对象类型使用函数返回默认值
    config: {
      type: Object,
      default: () => ({
        theme: 'light',
        language: 'zh-CN'
      })
    }
  }
}
</script>

9.2 性能优化

9.2.1 异步组件
// 路由级别的代码分割
const UserProfile = () => import('./components/UserProfile.vue')

// 条件加载
const HeavyComponent = () => {
  if (process.env.NODE_ENV === 'development') {
    return import('./components/HeavyComponent.vue')
  }
  return Promise.resolve(null)
}
9.2.2 KeepAlive缓存
<template>
  <div>
    <!-- 缓存动态组件 -->
    <KeepAlive>
      <component :is="currentComponent"></component>
    </KeepAlive>
    
    <!-- 条件缓存 -->
    <KeepAlive :include="['UserProfile', 'UserSettings']">
      <router-view></router-view>
    </KeepAlive>
  </div>
</template>

10. 常见问题与解决方案

10.1 Props变化监听

<script>
export default {
  props: ['value'],
  data() {
    return {
      localValue: this.value
    }
  },
  watch: {
    // 监听Props变化
    value(newVal) {
      this.localValue = newVal
    }
  },
  methods: {
    updateValue(val) {
      this.localValue = val
      this.$emit('input', val)
    }
  }
}
</script>

10.2 组件间通信

// 事件总线(小型应用)
import { createApp } from 'vue'
const eventBus = createApp({}).config.globalProperties

// 发送事件
eventBus.$emit('user-login', userData)

// 监听事件
eventBus.$on('user-login', (userData) => {
  console.log('用户登录:', userData)
})

10.3 样式隔离

<style scoped>
/* scoped样式只影响当前组件 */
.button {
  background: blue;
}
</style>

<style module>
/* CSS Modules */
.button {
  background: red;
}
</style>

<template>
  <div>
    <button class="button">Scoped按钮</button>
    <button :class="$style.button">Module按钮</button>
  </div>
</template>

11. 总结

通过本指南,您已经掌握了Vue自定义组件开发的核心知识:

  1. 组件基础:理解组件概念和结构
  2. Props传递:父子组件数据通信
  3. 事件系统:子父组件通信机制
  4. 插槽系统:内容分发和定制
  5. 动态组件:根据条件渲染不同组件
  6. 生命周期:组件各阶段的钩子函数
  7. 混入机制:代码复用和功能扩展
  8. 最佳实践:组件设计和性能优化

组件化开发是Vue.js的核心特性,熟练掌握这些概念将大大提高您的开发效率和代码质量。建议多进行实际练习,逐步构建复杂的组件系统。


网站公告

今日签到

点亮在社区的每一天
去签到