Skip to content

自定义组件开发 - 封装你自己的组件

🧩 组件化是现代前端开发的核心思想,今天我们来学习如何创建自己的组件

1. 创建基础组件

简单的按钮组件

vue
<!-- components/MyButton.vue -->
<template>
  <button 
    class="my-button"
    :class="[`my-button--\${type}`, `my-button--\${size}`, { 'my-button--disabled': disabled }]"
    :disabled="disabled"
    @click="handleClick"
  >
    <text v-if="loading" class="loading-icon">⏳</text>
    <slot v-else></slot>
  </button>
</template>

<script>
export default {
  name: 'MyButton',
  props: {
    type: {
      type: String,
      default: 'default',
      validator: (value) => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
    },
    size: {
      type: String,
      default: 'medium',
      validator: (value) => ['small', 'medium', 'large'].includes(value)
    },
    disabled: {
      type: Boolean,
      default: false
    },
    loading: {
      type: Boolean,
      default: false
    }
  },
  methods: {
    handleClick(e) {
      if (this.disabled || this.loading) {
        return
      }
      this.$emit('click', e)
    }
  }
}
</script>

<style lang="scss">
.my-button {
  border: none;
  border-radius: 6rpx;
  font-size: 28rpx;
  text-align: center;
  transition: all 0.3s ease;
  
  // 尺寸
  &--small {
    padding: 16rpx 24rpx;
    font-size: 24rpx;
  }
  
  &--medium {
    padding: 20rpx 32rpx;
    font-size: 28rpx;
  }
  
  &--large {
    padding: 24rpx 40rpx;
    font-size: 32rpx;
  }
  
  // 类型
  &--default {
    background: #f5f5f5;
    color: #333;
    
    &:active {
      background: #e0e0e0;
    }
  }
  
  &--primary {
    background: #007aff;
    color: white;
    
    &:active {
      background: #0056cc;
    }
  }
  
  &--success {
    background: #4cd964;
    color: white;
    
    &:active {
      background: #2ac845;
    }
  }
  
  &--warning {
    background: #ff9500;
    color: white;
    
    &:active {
      background: #cc7700;
    }
  }
  
  &--danger {
    background: #ff3b30;
    color: white;
    
    &:active {
      background: #cc1f17;
    }
  }
  
  // 禁用状态
  &--disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}

.loading-icon {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

使用自定义按钮

vue
<template>
  <view class="button-demo">
    <MyButton @click="handleClick">默认按钮</MyButton>
    <MyButton type="primary" @click="handleClick">主要按钮</MyButton>
    <MyButton type="success" size="large" @click="handleClick">成功按钮</MyButton>
    <MyButton type="warning" :loading="isLoading" @click="handleAsyncAction">
      \{\{ isLoading ? '加载中...' : '异步操作' \}\}
    </MyButton>
    <MyButton type="danger" disabled>禁用按钮</MyButton>
  </view>
</template>

<script>
import MyButton from '@/components/MyButton.vue'

export default {
  components: {
    MyButton
  },
  data() {
    return {
      isLoading: false
    }
  },
  methods: {
    handleClick() {
      uni.showToast({
        title: '按钮被点击',
        icon: 'success'
      })
    },
    async handleAsyncAction() {
      this.isLoading = true
      
      try {
        // 模拟异步操作
        await new Promise(resolve => setTimeout(resolve, 2000))
        
        uni.showToast({
          title: '操作成功',
          icon: 'success'
        })
      } catch (error) {
        uni.showToast({
          title: '操作失败',
          icon: 'error'
        })
      } finally {
        this.isLoading = false
      }
    }
  }
}
</script>

2. 复杂组件:卡片组件

vue
<!-- components/Card.vue -->
<template>
  <view class="card" :class="{ 'card--shadow': shadow }">
    <!-- 头部 -->
    <view v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <text class="card__title">\{\{ title \}\}</text>
        <text v-if="subtitle" class="card__subtitle">\{\{ subtitle \}\}</text>
      </slot>
    </view>
    
    <!-- 内容 -->
    <view class="card__body" :style="{ padding: bodyPadding }">
      <slot></slot>
    </view>
    
    <!-- 底部 -->
    <view v-if="$slots.footer" class="card__footer">
      <slot name="footer"></slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'Card',
  props: {
    title: {
      type: String,
      default: ''
    },
    subtitle: {
      type: String,
      default: ''
    },
    shadow: {
      type: Boolean,
      default: true
    },
    bodyPadding: {
      type: String,
      default: '30rpx'
    }
  }
}
</script>

<style lang="scss">
.card {
  background: white;
  border-radius: 16rpx;
  overflow: hidden;
  margin-bottom: 20rpx;
  
  &--shadow {
    box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
  }
  
  &__header {
    padding: 30rpx;
    border-bottom: 1rpx solid #f0f0f0;
  }
  
  &__title {
    font-size: 32rpx;
    font-weight: bold;
    color: #333;
    display: block;
  }
  
  &__subtitle {
    font-size: 26rpx;
    color: #666;
    margin-top: 8rpx;
    display: block;
  }
  
  &__body {
    padding: 30rpx;
  }
  
  &__footer {
    padding: 20rpx 30rpx;
    background: #f8f9fa;
    border-top: 1rpx solid #f0f0f0;
  }
}
</style>

3. 组件通信

父子组件通信

vue
<!-- components/Counter.vue -->
<template>
  <view class="counter">
    <button @click="decrement" :disabled="value <= min">-</button>
    <text class="counter__value">\{\{ value \}\}</text>
    <button @click="increment" :disabled="value >= max">+</button>
  </view>
</template>

<script>
export default {
  name: 'Counter',
  props: {
    value: {
      type: Number,
      default: 0
    },
    min: {
      type: Number,
      default: 0
    },
    max: {
      type: Number,
      default: 100
    },
    step: {
      type: Number,
      default: 1
    }
  },
  methods: {
    increment() {
      if (this.value < this.max) {
        this.$emit('input', this.value + this.step)
        this.$emit('change', this.value + this.step)
      }
    },
    decrement() {
      if (this.value > this.min) {
        this.$emit('input', this.value - this.step)
        this.$emit('change', this.value - this.step)
      }
    }
  }
}
</script>

<style lang="scss">
.counter {
  display: flex;
  align-items: center;
  gap: 20rpx;
  
  button {
    width: 60rpx;
    height: 60rpx;
    border-radius: 50%;
    border: 1rpx solid #ddd;
    background: white;
    font-size: 24rpx;
    
    &:disabled {
      opacity: 0.5;
    }
  }
  
  &__value {
    min-width: 60rpx;
    text-align: center;
    font-size: 32rpx;
    font-weight: bold;
  }
}
</style>

使用计数器组件

vue
<template>
  <view class="counter-demo">
    <Card title="商品数量">
      <view class="product-item">
        <image src="/static/product.jpg" class="product-image" />
        <view class="product-info">
          <text class="product-name">iPhone 15 Pro</text>
          <text class="product-price">¥8999</text>
        </view>
        <Counter 
          v-model="quantity" 
          :min="1" 
          :max="10"
          @change="onQuantityChange"
        />
      </view>
      
      <template #footer>
        <view class="total">
          <text>总价:¥\{\{ totalPrice \}\}</text>
        </view>
      </template>
    </Card>
  </view>
</template>

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

export default {
  components: {
    Card,
    Counter
  },
  data() {
    return {
      quantity: 1,
      price: 8999
    }
  },
  computed: {
    totalPrice() {
      return this.quantity * this.price
    }
  },
  methods: {
    onQuantityChange(newQuantity) {
      console.log('数量变化:', newQuantity)
      uni.showToast({
        title: `数量:\${newQuantity}`,
        icon: 'none'
      })
    }
  }
}
</script>

<style>
.counter-demo {
  padding: 20rpx;
}
.product-item {
  display: flex;
  align-items: center;
  gap: 20rpx;
}
.product-image {
  width: 120rpx;
  height: 120rpx;
  border-radius: 8rpx;
}
.product-info {
  flex: 1;
}
.product-name {
  font-size: 28rpx;
  font-weight: bold;
  display: block;
  margin-bottom: 8rpx;
}
.product-price {
  color: #ff6b6b;
  font-size: 32rpx;
  font-weight: bold;
}
.total {
  text-align: right;
}
.total text {
  font-size: 32rpx;
  font-weight: bold;
  color: #ff6b6b;
}
</style>

4. 高级组件:模态框

vue
<!-- components/Modal.vue -->
<template>
  <view v-if="visible" class="modal" @click="handleMaskClick">
    <view class="modal__content" :style="contentStyle" @click.stop>
      <!-- 头部 -->
      <view v-if="showHeader" class="modal__header">
        <slot name="header">
          <text class="modal__title">\{\{ title \}\}</text>
        </slot>
        <view v-if="showClose" class="modal__close" @click="handleClose">
          <text>✕</text>
        </view>
      </view>
      
      <!-- 内容 -->
      <view class="modal__body">
        <slot></slot>
      </view>
      
      <!-- 底部 -->
      <view v-if="$slots.footer || showFooter" class="modal__footer">
        <slot name="footer">
          <button v-if="showCancel" @click="handleCancel" class="modal__btn modal__btn--cancel">
            \{\{ cancelText \}\}
          </button>
          <button v-if="showConfirm" @click="handleConfirm" class="modal__btn modal__btn--confirm">
            \{\{ confirmText \}\}
          </button>
        </slot>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'Modal',
  props: {
    visible: {
      type: Boolean,
      default: false
    },
    title: {
      type: String,
      default: '提示'
    },
    width: {
      type: String,
      default: '600rpx'
    },
    maskClosable: {
      type: Boolean,
      default: true
    },
    showHeader: {
      type: Boolean,
      default: true
    },
    showClose: {
      type: Boolean,
      default: true
    },
    showFooter: {
      type: Boolean,
      default: true
    },
    showCancel: {
      type: Boolean,
      default: true
    },
    showConfirm: {
      type: Boolean,
      default: true
    },
    cancelText: {
      type: String,
      default: '取消'
    },
    confirmText: {
      type: String,
      default: '确定'
    }
  },
  computed: {
    contentStyle() {
      return {
        width: this.width
      }
    }
  },
  methods: {
    handleMaskClick() {
      if (this.maskClosable) {
        this.handleClose()
      }
    },
    handleClose() {
      this.$emit('close')
    },
    handleCancel() {
      this.$emit('cancel')
      this.handleClose()
    },
    handleConfirm() {
      this.$emit('confirm')
    }
  }
}
</script>

<style lang="scss">
.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  
  &__content {
    background: white;
    border-radius: 16rpx;
    max-height: 80vh;
    overflow: hidden;
    animation: modalSlideIn 0.3s ease-out;
  }
  
  &__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 30rpx;
    border-bottom: 1rpx solid #f0f0f0;
  }
  
  &__title {
    font-size: 32rpx;
    font-weight: bold;
    color: #333;
  }
  
  &__close {
    width: 40rpx;
    height: 40rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: #f5f5f5;
    color: #666;
    font-size: 24rpx;
  }
  
  &__body {
    padding: 30rpx;
    max-height: 60vh;
    overflow-y: auto;
  }
  
  &__footer {
    display: flex;
    gap: 20rpx;
    padding: 20rpx 30rpx;
    border-top: 1rpx solid #f0f0f0;
  }
  
  &__btn {
    flex: 1;
    height: 80rpx;
    border: none;
    border-radius: 8rpx;
    font-size: 28rpx;
    
    &--cancel {
      background: #f5f5f5;
      color: #666;
    }
    
    &--confirm {
      background: #007aff;
      color: white;
    }
  }
}

@keyframes modalSlideIn {
  from {
    opacity: 0;
    transform: scale(0.8) translateY(-50rpx);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}
</style>

使用模态框组件

vue
<template>
  <view class="modal-demo">
    <button @click="showBasicModal">基础模态框</button>
    <button @click="showCustomModal">自定义模态框</button>
    <button @click="showConfirmModal">确认对话框</button>
    
    <!-- 基础模态框 -->
    <Modal 
      :visible="basicVisible"
      title="基础模态框"
      @close="basicVisible = false"
      @confirm="handleBasicConfirm"
    >
      <text>这是一个基础的模态框内容。</text>
    </Modal>
    
    <!-- 自定义模态框 -->
    <Modal 
      :visible="customVisible"
      :show-footer="false"
      width="700rpx"
      @close="customVisible = false"
    >
      <template #header>
        <text style="color: #007aff; font-size: 36rpx;">自定义标题</text>
      </template>
      
      <view class="custom-content">
        <image src="/static/success.png" class="success-icon" />
        <text class="success-text">操作成功!</text>
        <button @click="customVisible = false" class="custom-btn">知道了</button>
      </view>
    </Modal>
    
    <!-- 确认对话框 -->
    <Modal 
      :visible="confirmVisible"
      title="确认删除"
      @close="confirmVisible = false"
      @confirm="handleDelete"
      @cancel="confirmVisible = false"
    >
      <text>确定要删除这个项目吗?删除后无法恢复。</text>
    </Modal>
  </view>
</template>

<script>
import Modal from '@/components/Modal.vue'

export default {
  components: {
    Modal
  },
  data() {
    return {
      basicVisible: false,
      customVisible: false,
      confirmVisible: false
    }
  },
  methods: {
    showBasicModal() {
      this.basicVisible = true
    },
    showCustomModal() {
      this.customVisible = true
    },
    showConfirmModal() {
      this.confirmVisible = true
    },
    handleBasicConfirm() {
      uni.showToast({
        title: '确认操作',
        icon: 'success'
      })
      this.basicVisible = false
    },
    handleDelete() {
      uni.showToast({
        title: '删除成功',
        icon: 'success'
      })
      this.confirmVisible = false
    }
  }
}
</script>

<style>
.modal-demo {
  padding: 20rpx;
}
.modal-demo button {
  margin-bottom: 20rpx;
  width: 100%;
}
.custom-content {
  text-align: center;
  padding: 40rpx 0;
}
.success-icon {
  width: 120rpx;
  height: 120rpx;
  margin-bottom: 30rpx;
}
.success-text {
  font-size: 32rpx;
  color: #333;
  display: block;
  margin-bottom: 40rpx;
}
.custom-btn {
  background: #007aff;
  color: white;
  border: none;
  border-radius: 8rpx;
  padding: 20rpx 60rpx;
}
</style>

小结

今天我们学习了:

  • ✅ 创建基础自定义组件
  • ✅ 组件的props和事件通信
  • ✅ 插槽的使用和自定义内容
  • ✅ 复杂组件的设计和实现
  • ✅ 组件的生命周期和最佳实践

组件开发要点

  • 单一职责原则,每个组件只做一件事
  • 合理设计props和事件接口
  • 使用插槽提供灵活性
  • 注意组件的可复用性和可维护性

下一篇预告

下一篇我们将学习《状态管理 - Vuex在UniApp中的使用》,学习如何管理复杂应用的状态。


组件化是现代前端开发的精髓,好的组件设计能让开发事半功倍!