自訂元件
自訂元件是 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 應用。