Skip to content

Custom Components

Overview

Custom components in uni-app allow developers to create reusable, encapsulated UI elements that can be shared across different pages and projects. This guide covers how to create, use, and optimize custom components.

Creating Custom Components

Basic Component Structure

vue
<!-- components/MyButton/MyButton.vue -->
<template>
  <button 
    :class="buttonClass" 
    :disabled="disabled"
    @click="handleClick"
  >
    <slot>{{ text }}</slot>
  </button>
</template>

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

<style scoped>
.my-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s ease;
  font-weight: 500;
}

/* Size variants */
.my-button--small {
  padding: 6px 12px;
  font-size: 12px;
  min-height: 28px;
}

.my-button--medium {
  padding: 8px 16px;
  font-size: 14px;
  min-height: 32px;
}

.my-button--large {
  padding: 12px 24px;
  font-size: 16px;
  min-height: 40px;
}

/* Type variants */
.my-button--default {
  background-color: #ffffff;
  color: #333333;
  border: 1px solid #d9d9d9;
}

.my-button--default:hover {
  border-color: #40a9ff;
  color: #40a9ff;
}

.my-button--primary {
  background-color: #1890ff;
  color: #ffffff;
}

.my-button--primary:hover {
  background-color: #40a9ff;
}

.my-button--success {
  background-color: #52c41a;
  color: #ffffff;
}

.my-button--success:hover {
  background-color: #73d13d;
}

.my-button--warning {
  background-color: #faad14;
  color: #ffffff;
}

.my-button--warning:hover {
  background-color: #ffc53d;
}

.my-button--danger {
  background-color: #ff4d4f;
  color: #ffffff;
}

.my-button--danger:hover {
  background-color: #ff7875;
}

/* States */
.my-button--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.my-button--loading {
  opacity: 0.8;
  cursor: not-allowed;
}
</style>

Component Registration

Global Registration

javascript
// main.js
import Vue from 'vue'
import MyButton from '@/components/MyButton/MyButton.vue'

Vue.component('MyButton', MyButton)

Local Registration

vue
<template>
  <view>
    <MyButton type="primary" @click="handleButtonClick">
      Click Me
    </MyButton>
  </view>
</template>

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

export default {
  components: {
    MyButton
  },
  methods: {
    handleButtonClick() {
      console.log('Button clicked!')
    }
  }
}
</script>

Advanced Component Patterns

Component with Slots

vue
<!-- components/Card/Card.vue -->
<template>
  <view class="card" :class="cardClass">
    <view v-if="$slots.header" class="card-header">
      <slot name="header"></slot>
    </view>
    
    <view class="card-body">
      <slot></slot>
    </view>
    
    <view v-if="$slots.footer" class="card-footer">
      <slot name="footer"></slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'Card',
  props: {
    shadow: {
      type: String,
      default: 'medium',
      validator: (value) => {
        return ['none', 'light', 'medium', 'heavy'].includes(value)
      }
    },
    bordered: {
      type: Boolean,
      default: true
    }
  },
  
  computed: {
    cardClass() {
      return [
        `card--shadow-${this.shadow}`,
        {
          'card--bordered': this.bordered
        }
      ]
    }
  }
}
</script>

<style scoped>
.card {
  background-color: #ffffff;
  border-radius: 8px;
  overflow: hidden;
}

.card--bordered {
  border: 1px solid #e8e8e8;
}

.card--shadow-light {
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.card--shadow-medium {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.card--shadow-heavy {
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}

.card-header {
  padding: 16px 20px;
  border-bottom: 1px solid #f0f0f0;
  font-weight: 600;
}

.card-body {
  padding: 20px;
}

.card-footer {
  padding: 12px 20px;
  border-top: 1px solid #f0f0f0;
  background-color: #fafafa;
}
</style>

Usage with Slots

vue
<template>
  <view>
    <Card shadow="medium">
      <template #header>
        <text class="card-title">User Profile</text>
      </template>
      
      <view class="user-info">
        <image :src="user.avatar" class="avatar"></image>
        <view class="user-details">
          <text class="username">{{ user.name }}</text>
          <text class="user-email">{{ user.email }}</text>
        </view>
      </view>
      
      <template #footer>
        <MyButton type="primary" size="small">Edit Profile</MyButton>
        <MyButton type="default" size="small">View Details</MyButton>
      </template>
    </Card>
  </view>
</template>

Form Components

Custom Input Component

vue
<!-- components/FormInput/FormInput.vue -->
<template>
  <view class="form-input" :class="inputClass">
    <text v-if="label" class="form-input__label">{{ label }}</text>
    
    <view class="form-input__wrapper">
      <view v-if="$slots.prefix" class="form-input__prefix">
        <slot name="prefix"></slot>
      </view>
      
      <input
        :type="type"
        :value="currentValue"
        :placeholder="placeholder"
        :disabled="disabled"
        :maxlength="maxlength"
        class="form-input__control"
        @input="handleInput"
        @focus="handleFocus"
        @blur="handleBlur"
      />
      
      <view v-if="$slots.suffix" class="form-input__suffix">
        <slot name="suffix"></slot>
      </view>
      
      <view v-if="clearable && currentValue" class="form-input__clear" @click="handleClear">
        <text class="clear-icon">×</text>
      </view>
    </view>
    
    <text v-if="errorMessage" class="form-input__error">{{ errorMessage }}</text>
    <text v-else-if="helpText" class="form-input__help">{{ helpText }}</text>
  </view>
</template>

<script>
export default {
  name: 'FormInput',
  props: {
    value: {
      type: [String, Number],
      default: ''
    },
    type: {
      type: String,
      default: 'text'
    },
    label: {
      type: String,
      default: ''
    },
    placeholder: {
      type: String,
      default: ''
    },
    disabled: {
      type: Boolean,
      default: false
    },
    clearable: {
      type: Boolean,
      default: false
    },
    maxlength: {
      type: Number,
      default: -1
    },
    rules: {
      type: Array,
      default: () => []
    },
    helpText: {
      type: String,
      default: ''
    }
  },
  
  data() {
    return {
      currentValue: this.value,
      focused: false,
      errorMessage: ''
    }
  },
  
  computed: {
    inputClass() {
      return {
        'form-input--focused': this.focused,
        'form-input--disabled': this.disabled,
        'form-input--error': !!this.errorMessage
      }
    }
  },
  
  watch: {
    value(newValue) {
      this.currentValue = newValue
      this.validateInput()
    }
  },
  
  methods: {
    handleInput(event) {
      this.currentValue = event.detail.value
      this.$emit('input', this.currentValue)
      this.validateInput()
    },
    
    handleFocus(event) {
      this.focused = true
      this.$emit('focus', event)
    },
    
    handleBlur(event) {
      this.focused = false
      this.$emit('blur', event)
      this.validateInput()
    },
    
    handleClear() {
      this.currentValue = ''
      this.$emit('input', '')
      this.$emit('clear')
    },
    
    validateInput() {
      this.errorMessage = ''
      
      for (const rule of this.rules) {
        const result = rule(this.currentValue)
        if (result !== true) {
          this.errorMessage = result
          break
        }
      }
      
      this.$emit('validate', {
        valid: !this.errorMessage,
        message: this.errorMessage
      })
    }
  }
}
</script>

<style scoped>
.form-input {
  margin-bottom: 16px;
}

.form-input__label {
  display: block;
  margin-bottom: 4px;
  font-size: 14px;
  font-weight: 500;
  color: #333333;
}

.form-input__wrapper {
  position: relative;
  display: flex;
  align-items: center;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  background-color: #ffffff;
  transition: border-color 0.3s ease;
}

.form-input--focused .form-input__wrapper {
  border-color: #1890ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}

.form-input--error .form-input__wrapper {
  border-color: #ff4d4f;
}

.form-input--disabled .form-input__wrapper {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.form-input__control {
  flex: 1;
  padding: 8px 12px;
  border: none;
  outline: none;
  font-size: 14px;
  background-color: transparent;
}

.form-input__prefix,
.form-input__suffix {
  padding: 0 8px;
  color: #999999;
}

.form-input__clear {
  padding: 0 8px;
  cursor: pointer;
  color: #999999;
}

.clear-icon {
  font-size: 18px;
  font-weight: bold;
}

.form-input__error {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #ff4d4f;
}

.form-input__help {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #999999;
}
</style>

List Components

Virtual List Component

vue
<!-- components/VirtualList/VirtualList.vue -->
<template>
  <scroll-view 
    class="virtual-list"
    :style="{ height: containerHeight + 'px' }"
    scroll-y
    @scroll="handleScroll"
  >
    <view 
      class="virtual-list__spacer" 
      :style="{ height: offsetY + 'px' }"
    ></view>
    
    <view 
      v-for="item in visibleItems" 
      :key="getItemKey(item)"
      class="virtual-list__item"
      :style="{ height: itemHeight + 'px' }"
    >
      <slot :item="item" :index="item.index"></slot>
    </view>
    
    <view 
      class="virtual-list__spacer" 
      :style="{ height: remainingHeight + 'px' }"
    ></view>
  </scroll-view>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    items: {
      type: Array,
      required: true
    },
    itemHeight: {
      type: Number,
      required: true
    },
    containerHeight: {
      type: Number,
      required: true
    },
    keyField: {
      type: String,
      default: 'id'
    },
    buffer: {
      type: Number,
      default: 5
    }
  },
  
  data() {
    return {
      scrollTop: 0
    }
  },
  
  computed: {
    visibleCount() {
      return Math.ceil(this.containerHeight / this.itemHeight)
    },
    
    startIndex() {
      const index = Math.floor(this.scrollTop / this.itemHeight)
      return Math.max(0, index - this.buffer)
    },
    
    endIndex() {
      const index = this.startIndex + this.visibleCount + this.buffer * 2
      return Math.min(this.items.length - 1, index)
    },
    
    visibleItems() {
      return this.items.slice(this.startIndex, this.endIndex + 1).map((item, index) => ({
        ...item,
        index: this.startIndex + index
      }))
    },
    
    offsetY() {
      return this.startIndex * this.itemHeight
    },
    
    remainingHeight() {
      const totalHeight = this.items.length * this.itemHeight
      const visibleHeight = (this.endIndex + 1) * this.itemHeight
      return Math.max(0, totalHeight - visibleHeight)
    }
  },
  
  methods: {
    handleScroll(event) {
      this.scrollTop = event.detail.scrollTop
    },
    
    getItemKey(item) {
      return item[this.keyField] || item.index
    },
    
    scrollToIndex(index) {
      const scrollTop = index * this.itemHeight
      // Implementation depends on platform
      // For uni-app, you might need to use scroll-into-view
    }
  }
}
</script>

<style scoped>
.virtual-list {
  overflow: hidden;
}

.virtual-list__item {
  overflow: hidden;
}
</style>

Component Communication

Event Bus Component

vue
<!-- components/EventBus/EventBus.vue -->
<script>
// Event bus for component communication
class EventBus {
  constructor() {
    this.events = {}
  }
  
  // Subscribe to event
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }
    this.events[event].push(callback)
  }
  
  // Unsubscribe from event
  off(event, callback) {
    if (!this.events[event]) return
    
    const index = this.events[event].indexOf(callback)
    if (index > -1) {
      this.events[event].splice(index, 1)
    }
  }
  
  // Emit event
  emit(event, data) {
    if (!this.events[event]) return
    
    this.events[event].forEach(callback => {
      callback(data)
    })
  }
  
  // Subscribe once
  once(event, callback) {
    const onceCallback = (data) => {
      callback(data)
      this.off(event, onceCallback)
    }
    this.on(event, onceCallback)
  }
}

// Create global event bus instance
const eventBus = new EventBus()

export default eventBus
</script>

Provider/Inject Pattern

vue
<!-- components/ThemeProvider/ThemeProvider.vue -->
<template>
  <view class="theme-provider" :class="themeClass">
    <slot></slot>
  </view>
</template>

<script>
export default {
  name: 'ThemeProvider',
  props: {
    theme: {
      type: String,
      default: 'light',
      validator: (value) => ['light', 'dark'].includes(value)
    }
  },
  
  provide() {
    return {
      theme: this.theme,
      toggleTheme: this.toggleTheme
    }
  },
  
  computed: {
    themeClass() {
      return `theme--${this.theme}`
    }
  },
  
  methods: {
    toggleTheme() {
      this.$emit('theme-change', this.theme === 'light' ? 'dark' : 'light')
    }
  }
}
</script>

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

.theme--dark {
  background-color: #1a1a1a;
  color: #ffffff;
}
</style>

Child Component Using Inject

vue
<!-- components/ThemedButton/ThemedButton.vue -->
<template>
  <button :class="buttonClass" @click="handleClick">
    <slot></slot>
  </button>
</template>

<script>
export default {
  name: 'ThemedButton',
  inject: ['theme', 'toggleTheme'],
  
  computed: {
    buttonClass() {
      return [
        'themed-button',
        `themed-button--${this.theme}`
      ]
    }
  },
  
  methods: {
    handleClick() {
      this.$emit('click')
    }
  }
}
</script>

<style scoped>
.themed-button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.themed-button--light {
  background-color: #1890ff;
  color: #ffffff;
}

.themed-button--dark {
  background-color: #177ddc;
  color: #ffffff;
}
</style>

Component Testing

Unit Testing Custom Components

javascript
// tests/components/MyButton.spec.js
import { mount } from '@vue/test-utils'
import MyButton from '@/components/MyButton/MyButton.vue'

describe('MyButton', () => {
  it('renders correctly with default props', () => {
    const wrapper = mount(MyButton)
    expect(wrapper.find('.my-button').exists()).toBe(true)
    expect(wrapper.text()).toBe('Button')
  })
  
  it('applies correct type class', () => {
    const wrapper = mount(MyButton, {
      propsData: {
        type: 'primary'
      }
    })
    expect(wrapper.find('.my-button--primary').exists()).toBe(true)
  })
  
  it('emits click event when clicked', async () => {
    const wrapper = mount(MyButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeTruthy()
  })
  
  it('does not emit click when disabled', async () => {
    const wrapper = mount(MyButton, {
      propsData: {
        disabled: true
      }
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeFalsy()
  })
  
  it('renders slot content', () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: 'Custom Text'
      }
    })
    expect(wrapper.text()).toBe('Custom Text')
  })
})

Best Practices

1. Component Design Principles

  • Single Responsibility: Each component should have one clear purpose
  • Reusability: Design components to be reusable across different contexts
  • Composability: Components should work well together
  • Consistency: Maintain consistent API design across components

2. Props and Events

javascript
// Good: Clear prop definitions with validation
props: {
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  disabled: {
    type: Boolean,
    default: false
  }
}

// Good: Descriptive event names
this.$emit('item-selected', item)
this.$emit('validation-failed', errors)

3. Performance Optimization

vue
<template>
  <!-- Use v-show for frequently toggled elements -->
  <view v-show="visible" class="expensive-component">
    <!-- Content -->
  </view>
  
  <!-- Use v-if for conditionally rendered elements -->
  <view v-if="shouldRender" class="conditional-component">
    <!-- Content -->
  </view>
  
  <!-- Use key for list items to help Vue track changes -->
  <view v-for="item in items" :key="item.id">
    {{ item.name }}
  </view>
</template>

<script>
export default {
  // Use functional components for simple, stateless components
  functional: true,
  
  // Optimize with computed properties
  computed: {
    expensiveValue() {
      // Cached until dependencies change
      return this.items.filter(item => item.active).length
    }
  }
}
</script>

4. Documentation

javascript
/**
 * Custom Button Component
 * 
 * @component
 * @example
 * <MyButton type="primary" size="large" @click="handleClick">
 *   Click Me
 * </MyButton>
 */
export default {
  name: 'MyButton',
  
  props: {
    /**
     * Button type
     * @type {String}
     * @default 'default'
     * @values 'default', 'primary', 'success', 'warning', 'danger'
     */
    type: {
      type: String,
      default: 'default'
    }
  },
  
  /**
   * Emitted when button is clicked
   * @event click
   * @type {Event}
   */
  methods: {
    handleClick(event) {
      this.$emit('click', event)
    }
  }
}

Summary

Custom components are essential for building maintainable and scalable uni-app applications. By following best practices for component design, implementing proper communication patterns, and ensuring thorough testing, developers can create robust component libraries that enhance development efficiency and code quality across projects.

一次开发,多端部署 - 让跨平台开发更简单