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.