自定义组件
自定义组件是 uni-app 中非常重要的功能,它允许开发者将页面内的功能模块抽象成独立的组件,以提高代码复用性和可维护性。本文将介绍如何创建和使用自定义组件。
创建自定义组件
在 uni-app 中,自定义组件的创建方式与页面类似,但组件的文件需要放在 components
目录下。
组件结构
一个典型的自定义组件由以下文件组成:
.vue
文件:组件的主体文件,包含模板、脚本和样式- 可选的静态资源文件:如图片、字体等
组件示例
以下是一个简单的自定义按钮组件示例:
vue
<!-- components/custom-button/custom-button.vue -->
<template>
<view
class="custom-button"
:class="[`custom-button--${type}`, disabled ? 'custom-button--disabled' : '']"
:hover-class="disabled ? '' : 'custom-button--hover'"
@click="onClick"
>
<text class="custom-button__text">{{ text }}</text>
</view>
</template>
<script>
export default {
name: 'CustomButton',
props: {
// 按钮类型
type: {
type: String,
default: 'default',
validator: value => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
},
// 按钮文字
text: {
type: String,
default: '按钮'
},
// 是否禁用
disabled: {
type: Boolean,
default: false
}
},
methods: {
onClick() {
if (!this.disabled) {
this.$emit('click');
}
}
}
}
</script>
<style>
.custom-button {
padding: 10px 20px;
border-radius: 4px;
text-align: center;
margin: 5px;
}
.custom-button--default {
background-color: #f8f8f8;
color: #333;
border: 1px solid #ddd;
}
.custom-button--primary {
background-color: #007aff;
color: #fff;
}
.custom-button--success {
background-color: #4cd964;
color: #fff;
}
.custom-button--warning {
background-color: #f0ad4e;
color: #fff;
}
.custom-button--danger {
background-color: #dd524d;
color: #fff;
}
.custom-button--disabled {
opacity: 0.5;
}
.custom-button--hover {
opacity: 0.8;
}
.custom-button__text {
font-size: 16px;
}
</style>
使用自定义组件
全局注册
如果希望在所有页面中使用某个组件,可以在 main.js
中进行全局注册:
javascript
// main.js
import Vue from 'vue'
import App from './App'
import CustomButton from './components/custom-button/custom-button.vue'
Vue.component('custom-button', CustomButton)
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
局部注册
更常见的做法是在需要使用组件的页面或组件中进行局部注册:
vue
<!-- pages/index/index.vue -->
<template>
<view class="content">
<custom-button
text="默认按钮"
@click="handleClick"
></custom-button>
<custom-button
type="primary"
text="主要按钮"
@click="handleClick"
></custom-button>
<custom-button
type="success"
text="成功按钮"
@click="handleClick"
></custom-button>
<custom-button
type="warning"
text="警告按钮"
@click="handleClick"
></custom-button>
<custom-button
type="danger"
text="危险按钮"
@click="handleClick"
></custom-button>
<custom-button
type="primary"
text="禁用按钮"
:disabled="true"
@click="handleClick"
></custom-button>
</view>
</template>
<script>
import CustomButton from '@/components/custom-button/custom-button.vue'
export default {
components: {
CustomButton
},
methods: {
handleClick() {
uni.showToast({
title: '按钮被点击',
icon: 'none'
})
}
}
}
</script>
组件通信
Props 向下传递数据
父组件可以通过 props 向子组件传递数据:
vue
<!-- 父组件 -->
<template>
<view>
<custom-card
title="卡片标题"
:content="cardContent"
:show-footer="true"
></custom-card>
</view>
</template>
<script>
import CustomCard from '@/components/custom-card/custom-card.vue'
export default {
components: {
CustomCard
},
data() {
return {
cardContent: '这是卡片内容'
}
}
}
</script>
vue
<!-- components/custom-card/custom-card.vue -->
<template>
<view class="custom-card">
<view class="custom-card__header">
<text class="custom-card__title">{{ title }}</text>
</view>
<view class="custom-card__body">
<text class="custom-card__content">{{ content }}</text>
</view>
<view v-if="showFooter" class="custom-card__footer">
<slot name="footer">
<text class="custom-card__footer-text">默认页脚内容</text>
</slot>
</view>
</view>
</template>
<script>
export default {
name: 'CustomCard',
props: {
title: {
type: String,
default: '标题'
},
content: {
type: String,
default: '内容'
},
showFooter: {
type: Boolean,
default: false
}
}
}
</script>
<style>
.custom-card {
margin: 15px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.custom-card__header {
padding: 15px;
border-bottom: 1px solid #eee;
}
.custom-card__title {
font-size: 18px;
font-weight: bold;
}
.custom-card__body {
padding: 15px;
}
.custom-card__content {
font-size: 14px;
color: #333;
}
.custom-card__footer {
padding: 15px;
border-top: 1px solid #eee;
background-color: #f8f8f8;
}
.custom-card__footer-text {
font-size: 12px;
color: #666;
}
</style>
事件向上传递数据
子组件可以通过事件向父组件传递数据:
vue
<!-- 子组件 -->
<template>
<view class="counter">
<button @click="decrease">-</button>
<text class="counter__value">{{ value }}</text>
<button @click="increase">+</button>
</view>
</template>
<script>
export default {
name: 'Counter',
props: {
initialValue: {
type: Number,
default: 0
}
},
data() {
return {
value: this.initialValue
}
},
methods: {
increase() {
this.value++
this.$emit('change', this.value)
},
decrease() {
if (this.value > 0) {
this.value--
this.$emit('change', this.value)
}
}
}
}
</script>
vue
<!-- 父组件 -->
<template>
<view>
<counter :initial-value="count" @change="handleCountChange"></counter>
<text>当前计数:{{ count }}</text>
</view>
</template>
<script>
import Counter from '@/components/counter/counter.vue'
export default {
components: {
Counter
},
data() {
return {
count: 5
}
},
methods: {
handleCountChange(value) {
this.count = value
console.log('计数已更新:', value)
}
}
}
</script>
使用插槽
插槽(Slot)允许父组件向子组件插入内容:
默认插槽
vue
<!-- 子组件 -->
<template>
<view class="panel">
<view class="panel__header">
<text class="panel__title">{{ title }}</text>
</view>
<view class="panel__body">
<slot>
<!-- 默认内容,当没有提供插槽内容时显示 -->
<text>暂无内容</text>
</slot>
</view>
</view>
</template>
<script>
export default {
name: 'Panel',
props: {
title: {
type: String,
default: '面板'
}
}
}
</script>
vue
<!-- 父组件 -->
<template>
<view>
<panel title="用户信息">
<view class="user-info">
<image class="avatar" :src="userInfo.avatar"></image>
<text class="username">{{ userInfo.name }}</text>
</view>
</panel>
</view>
</template>
<script>
import Panel from '@/components/panel/panel.vue'
export default {
components: {
Panel
},
data() {
return {
userInfo: {
name: '张三',
avatar: '/static/images/avatar.png'
}
}
}
}
</script>
具名插槽
vue
<!-- 子组件 -->
<template>
<view class="dialog">
<view class="dialog__header">
<slot name="header">
<text class="dialog__title">{{ title }}</text>
</slot>
</view>
<view class="dialog__body">
<slot>
<text>{{ content }}</text>
</slot>
</view>
<view class="dialog__footer">
<slot name="footer">
<button @click="$emit('cancel')">取消</button>
<button type="primary" @click="$emit('confirm')">确定</button>
</slot>
</view>
</view>
</template>
<script>
export default {
name: 'Dialog',
props: {
title: {
type: String,
default: '提示'
},
content: {
type: String,
default: ''
}
}
}
</script>
vue
<!-- 父组件 -->
<template>
<view>
<button @click="showDialog = true">显示对话框</button>
<dialog
v-if="showDialog"
title="删除确认"
content="确定要删除这条记录吗?"
@cancel="handleCancel"
@confirm="handleConfirm"
>
<template v-slot:header>
<view class="custom-header">
<text class="custom-title">自定义标题</text>
<text class="close-icon" @click="showDialog = false">×</text>
</view>
</template>
<template v-slot:footer>
<view class="custom-footer">
<button @click="handleCancel">取消操作</button>
<button type="warn" @click="handleConfirm">确认删除</button>
</view>
</template>
</dialog>
</view>
</template>
<script>
import Dialog from '@/components/dialog/dialog.vue'
export default {
components: {
Dialog
},
data() {
return {
showDialog: false
}
},
methods: {
handleCancel() {
this.showDialog = false
uni.showToast({
title: '已取消',
icon: 'none'
})
},
handleConfirm() {
this.showDialog = false
uni.showToast({
title: '已确认删除',
icon: 'none'
})
}
}
}
</script>
组件生命周期
自定义组件拥有与页面类似的生命周期,但也有一些特有的钩子函数:
vue
<script>
export default {
name: 'MyComponent',
// 组件初始化前
beforeCreate() {
console.log('beforeCreate')
},
// 组件初始化后
created() {
console.log('created')
},
// 组件挂载到页面前
beforeMount() {
console.log('beforeMount')
},
// 组件挂载到页面后
mounted() {
console.log('mounted')
},
// 组件更新前
beforeUpdate() {
console.log('beforeUpdate')
},
// 组件更新后
updated() {
console.log('updated')
},
// 组件卸载前
beforeDestroy() {
console.log('beforeDestroy')
},
// 组件卸载后
destroyed() {
console.log('destroyed')
}
}
</script>
组件样式
样式隔离
在 uni-app 中,组件的样式默认是不会影响到外部的,但外部样式会影响到组件内部。
使用 scoped
如果希望组件样式完全隔离,可以使用 scoped
特性:
vue
<style scoped>
.my-component {
color: red;
}
</style>
使用 CSS 变量实现主题定制
vue
<!-- 子组件 -->
<template>
<view class="theme-card" :style="cardStyle">
<text class="theme-card__title">{{ title }}</text>
<text class="theme-card__content">{{ content }}</text>
</view>
</template>
<script>
export default {
name: 'ThemeCard',
props: {
title: String,
content: String,
theme: {
type: Object,
default: () => ({})
}
},
computed: {
cardStyle() {
return {
'--card-bg-color': this.theme.backgroundColor || '#ffffff',
'--card-text-color': this.theme.textColor || '#333333',
'--card-border-color': this.theme.borderColor || '#eeeeee'
}
}
}
}
</script>
<style>
.theme-card {
background-color: var(--card-bg-color);
color: var(--card-text-color);
border: 1px solid var(--card-border-color);
border-radius: 8px;
padding: 15px;
margin: 10px;
}
.theme-card__title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.theme-card__content {
font-size: 14px;
}
</style>
vue
<!-- 父组件 -->
<template>
<view>
<theme-card
title="浅色主题"
content="这是浅色主题的卡片"
:theme="lightTheme"
></theme-card>
<theme-card
title="深色主题"
content="这是深色主题的卡片"
:theme="darkTheme"
></theme-card>
<theme-card
title="自定义主题"
content="这是自定义主题的卡片"
:theme="customTheme"
></theme-card>
</view>
</template>
<script>
import ThemeCard from '@/components/theme-card/theme-card.vue'
export default {
components: {
ThemeCard
},
data() {
return {
lightTheme: {
backgroundColor: '#ffffff',
textColor: '#333333',
borderColor: '#eeeeee'
},
darkTheme: {
backgroundColor: '#333333',
textColor: '#ffffff',
borderColor: '#555555'
},
customTheme: {
backgroundColor: '#f0f8ff',
textColor: '#0066cc',
borderColor: '#99ccff'
}
}
}
}
</script>
组件通信进阶
使用 provide/inject
对于跨多级组件的数据传递,可以使用 provide/inject:
vue
<!-- 祖先组件 -->
<script>
export default {
provide() {
return {
theme: this.theme,
updateTheme: this.updateTheme
}
},
data() {
return {
theme: 'light'
}
},
methods: {
updateTheme(newTheme) {
this.theme = newTheme
}
}
}
</script>
vue
<!-- 后代组件 (可能隔了多层) -->
<template>
<view :class="['component', `component--${theme}`]">
<text>当前主题: {{ theme }}</text>
<button @click="changeTheme">切换主题</button>
</view>
</template>
<script>
export default {
inject: ['theme', 'updateTheme'],
methods: {
changeTheme() {
const newTheme = this.theme === 'light' ? 'dark' : 'light'
this.updateTheme(newTheme)
}
}
}
</script>
使用 Vuex 进行状态管理
对于复杂应用,可以使用 Vuex 进行状态管理:
javascript
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
count: 0,
userInfo: null
},
mutations: {
INCREMENT(state) {
state.count++
},
DECREMENT(state) {
state.count--
},
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo
}
},
actions: {
increment({ commit }) {
commit('INCREMENT')
},
decrement({ commit }) {
commit('DECREMENT')
},
login({ commit }, userInfo) {
// 模拟登录请求
return new Promise((resolve) => {
setTimeout(() => {
commit('SET_USER_INFO', userInfo)
resolve(true)
}, 1000)
})
}
},
getters: {
isLoggedIn: state => !!state.userInfo
}
})
vue
<!-- 组件中使用 Vuex -->
<template>
<view>
<text>计数: {{ count }}</text>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<view v-if="isLoggedIn">
<text>欢迎, {{ userInfo.name }}</text>
<button @click="logout">退出登录</button>
</view>
<view v-else>
<button @click="login">登录</button>
</view>
</view>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState(['count', 'userInfo']),
...mapGetters(['isLoggedIn'])
},
methods: {
...mapActions(['increment', 'decrement']),
login() {
const userInfo = {
id: 1,
name: '张三',
avatar: '/static/images/avatar.png'
}
this.$store.dispatch('login', userInfo)
},
logout() {
this.$store.commit('SET_USER_INFO', null)
}
}
}
</script>
组件复用与封装
混入 (Mixins)
混入是一种分发组件功能的方式,可以将共享功能提取到混入对象中:
javascript
// mixins/form-validation.js
export default {
data() {
return {
errors: {},
isSubmitting: false
}
},
methods: {
validate(rules) {
this.errors = {}
let isValid = true
Object.keys(rules).forEach(field => {
const value = this[field]
const fieldRules = rules[field]
if (fieldRules.required && !value) {
this.errors[field] = '此字段不能为空'
isValid = false
return
}
if (fieldRules.minLength && value.length < fieldRules.minLength) {
this.errors[field] = `长度不能少于 ${fieldRules.minLength} 个字符`
isValid = false
return
}
if (fieldRules.pattern && !fieldRules.pattern.test(value)) {
this.errors[field] = fieldRules.message || '格式不正确'
isValid = false
return
}
})
return isValid
},
resetForm(fields) {
fields.forEach(field => {
this[field] = ''
})
this.errors = {}
}
}
}
vue
<!-- 使用混入的组件 -->
<template>
<view class="form">
<view class="form-item">
<text class="label">用户名</text>
<input v-model="username" placeholder="请输入用户名" />
<text v-if="errors.username" class="error">{{ errors.username }}</text>
</view>
<view class="form-item">
<text class="label">密码</text>
<input v-model="password" type="password" placeholder="请输入密码" />
<text v-if="errors.password" class="error">{{ errors.password }}</text>
</view>
<button
type="primary"
:loading="isSubmitting"
@click="submitForm"
>登录</button>
</view>
</template>
<script>
import formValidation from '@/mixins/form-validation.js'
export default {
mixins: [formValidation],
data() {
return {
username: '',
password: ''
}
},
methods: {
submitForm() {
const rules = {
username: {
required: true,
minLength: 3
},
password: {
required: true,
minLength: 6,
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
message: '密码必须包含大小写字母和数字'
}
}
if (this.validate(rules)) {
this.isSubmitting = true
// 模拟提交
setTimeout(() => {
this.isSubmitting = false
uni.showToast({
title: '登录成功',
icon: 'success'
})
this.resetForm(['username', 'password'])
}, 2000)
}
}
}
}
</script>
<style>
.form {
padding: 15px;
}
.form-item {
margin-bottom: 15px;
}
.label {
display: block;
margin-bottom: 5px;
font-size: 14px;
}
.error {
color: #ff0000;
font-size: 12px;
margin-top: 5px;
}
</style>
高阶组件 (HOC)
高阶组件是一个函数,接收一个组件并返回一个新组件:
javascript
// hoc/with-loading.js
import LoadingComponent from '@/components/loading/loading.vue'
export default function withLoading(WrappedComponent) {
return {
props: {
loading: {
type: Boolean,
default: false
},
...WrappedComponent.props
},
render(h) {
return this.loading
? h(LoadingComponent)
: h(WrappedComponent, {
props: this.$props,
on: this.$listeners,
scopedSlots: this.$scopedSlots
})
}
}
}
vue
<!-- 使用高阶组件 -->
<template>
<view>
<user-list-with-loading
:loading="isLoading"
:users="users"
@select="handleUserSelect"
></user-list-with-loading>
<button @click="loadUsers">加载用户</button>
</view>
</template>
<script>
import UserList from '@/components/user-list/user-list.vue'
import withLoading from '@/hoc/with-loading.js'
const UserListWithLoading = withLoading(UserList)
export default {
components: {
UserListWithLoading
},
data() {
return {
users: [],
isLoading: false
}
},
methods: {
loadUsers() {
this.isLoading = true
// 模拟加载数据
setTimeout(() => {
this.users = [
{ id: 1, name: '张三', avatar: '/static/images/avatar1.png' },
{ id: 2, name: '李四', avatar: '/static/images/avatar2.png' },
{ id: 3, name: '王五', avatar: '/static/images/avatar3.png' }
]
this.isLoading = false
}, 2000)
},
handleUserSelect(user) {
uni.showToast({
title: `已选择用户: ${user.name}`,
icon: 'none'
})
}
}
}
</script>
最佳实践
组件命名
- 组件名应该是多个单词的,除了根组件 App
- 组件名应该以高级别的单词开头,以描述性的修饰词结尾
- 组件名应该是 PascalCase 的
javascript
// 好的命名
components: {
UserProfile,
SubmitButton,
TodoList,
SearchInput
}
// 不好的命名
components: {
'user-profile',
'submit-button',
'todo-list',
'search-input'
}
组件通信
- 尽量使用 props 和事件进行父子组件通信
- 对于兄弟组件通信,可以使用父组件作为中介
- 对于复杂的状态管理,使用 Vuex
- 避免过度使用 provide/inject,因为它会使数据流变得难以追踪
组件结构
- 保持组件的单一职责
- 将大型组件拆分为更小的组件
- 使用合适的目录结构组织组件
components/
├── common/ # 通用组件
│ ├── Button.vue
│ ├── Input.vue
│ └── Modal.vue
├── layout/ # 布局组件
│ ├── Header.vue
│ ├── Footer.vue
│ └── Sidebar.vue
└── business/ # 业务组件
├── user/
│ ├── UserCard.vue
│ └── UserForm.vue
└── product/
├── ProductList.vue
└── ProductDetail.vue
性能优化
- 使用
v-if
而不是v-show
来条件渲染不经常切换的组件 - 为
v-for
中的元素提供唯一的key
- 避免在模板中进行复杂计算,使用计算属性代替
- 对于大型列表,考虑使用虚拟滚动
- 合理使用异步组件和懒加载
vue
<!-- 异步组件示例 -->
<script>
export default {
components: {
HeavyComponent: () => import('@/components/heavy-component.vue')
}
}
</script>
总结
自定义组件是 uni-app 开发中非常重要的一部分,它可以帮助我们提高代码复用性、可维护性和开发效率。通过合理设计组件结构、组件通信方式和遵循最佳实践,可以构建出高质量的 uni-app 应用。