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.

Develop once, deploy everywhere - Making cross-platform development easier