Skip to content

自訂元件

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

一次開發,多端部署 - 讓跨平台開發更簡單