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 应用。

一次开发,多端部署 - 让跨平台开发更简单