帮助文档
{{userInfo.nickname}}
用户设置 退出登录

{{wikiTitle}}

组件添加说明

添加组件说明

概述

CRMEB多店系统的组件分为两大类:移动端(uni-app)组件后台管理端(Vue + iView)组件。本文档详细介绍如何在这两种场景下开发和集成新的组件。

一、移动端组件开发(uni-app)

1.1 组件目录结构

view/uniapp/components/
├── BaseMoney.vue          # 金额显示组件
├── BaseTag.vue            # 标签组件
├── NavBar.vue             # 导航栏组件
├── emptyPage.vue          # 空状态组件
├── skeleton/              # 骨架屏组件
│   └── index.vue
├── countDown/             # 倒计时组件
│   └── index.vue
├── productWindow/         # 商品弹窗组件
│   └── productWindow.vue
├── couponWindow/          # 优惠券弹窗组件
│   └── couponWindow.vue
├── addressWindow/         # 地址选择组件
│   └── addressWindow.vue
├── authorize/             # 授权组件
│   └── index.vue
└── ...                    # 更多组件

1.2 创建基础组件

组件文件模板

<!-- components/myComponent/index.vue -->
<template>
  <view class="my-component" :style="customStyle">
    <!-- 组件内容 -->
    <view class="header" v-if="showHeader">
      <text class="title">{{ title }}</text>
      <slot name="header-right"></slot>
    </view>

    <view class="body">
      <!-- 默认插槽 -->
      <slot></slot>
    </view>

    <view class="footer" v-if="$slots.footer">
      <slot name="footer"></slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'MyComponent',

  // 组件属性定义
  props: {
    // 标题
    title: {
      type: String,
      default: ''
    },
    // 是否显示头部
    showHeader: {
      type: Boolean,
      default: true
    },
    // 自定义样式
    customStyle: {
      type: [String, Object],
      default: ''
    },
    // 数据列表
    list: {
      type: Array,
      default: () => []
    },
    // 数值类型
    count: {
      type: Number,
      default: 0
    }
  },

  // 组件数据
  data() {
    return {
      innerValue: '',
      loading: false
    };
  },

  // 计算属性
  computed: {
    // 计算显示内容
    displayText() {
      return this.title || '默认标题';
    },
    // 处理后的列表
    processedList() {
      return this.list.filter(item => item.show);
    }
  },

  // 监听器
  watch: {
    // 监听属性变化
    count: {
      handler(newVal, oldVal) {
        this.handleCountChange(newVal);
      },
      immediate: true
    }
  },

  // 生命周期
  created() {
    this.init();
  },

  mounted() {
    this.bindEvents();
  },

  beforeDestroy() {
    this.unbindEvents();
  },

  // 方法定义
  methods: {
    // 初始化
    init() {
      // 初始化逻辑
    },

    // 处理数量变化
    handleCountChange(val) {
      // 处理逻辑
    },

    // 绑定事件
    bindEvents() {
      // 事件绑定
    },

    // 解绑事件
    unbindEvents() {
      // 事件解绑
    },

    // 触发自定义事件
    handleClick(data) {
      // 向父组件发送事件
      this.$emit('click', data);
    },

    // 带回调的事件
    handleChange(value) {
      this.$emit('change', value);
      // 支持v-model
      this.$emit('input', value);
    },

    // 暴露给父组件的方法
    refresh() {
      this.loading = true;
      // 刷新逻辑
      setTimeout(() => {
        this.loading = false;
      }, 1000);
    }
  }
};
</script>

<style lang="scss" scoped>
.my-component {
  background-color: #fff;
  border-radius: 16rpx;
  overflow: hidden;

  .header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 24rpx;
    border-bottom: 1rpx solid #f5f5f5;

    .title {
      font-size: 30rpx;
      font-weight: 500;
      color: #333;
    }
  }

  .body {
    padding: 24rpx;
  }

  .footer {
    padding: 24rpx;
    border-top: 1rpx solid #f5f5f5;
  }
}
</style>

1.3 实战示例:倒计时组件

参考项目中的 countDown 组件实现:

<!-- components/countDown/index.vue -->
<template>
  <view class="count-down" :style="justifyLeft">
    <!-- 提示文字 -->
    <text class="tip-text" v-if="tipText.trim()">{{ tipText }}</text>

    <!-- 天数 -->
    <text class="time-block" :style="blockStyle" v-if="isDay">
      {{ day }}
      <text class="day-text">{{ inDayText }}</text>
    </text>
    <text class="separator" :style="separatorStyle" v-if="dayText">{{ dayText }}</text>

    <!-- 小时 -->
    <text class="time-block" :style="blockStyle">{{ hour }}</text>
    <text class="separator" :style="separatorStyle" v-if="hourText">{{ hourText }}</text>

    <!-- 分钟 -->
    <text class="time-block" :style="blockStyle">{{ minute }}</text>
    <text class="separator" :style="separatorStyle" v-if="minuteText">{{ minuteText }}</text>

    <!-- 秒数 -->
    <text class="time-block" :style="blockStyle" v-if="isSecond">{{ second }}</text>
    <text class="separator" :style="separatorStyle" v-if="secondText">{{ secondText }}</text>

    <!-- 底部插槽 -->
    <slot name="bottom"></slot>
  </view>
</template>

<script>
export default {
  name: 'CountDown',

  props: {
    // 对齐方式样式
    justifyLeft: {
      type: String,
      default: ''
    },
    // 提示文字
    tipText: {
      type: String,
      default: '倒计时'
    },
    // 天数单位(块内)
    inDayText: {
      type: String,
      default: '天'
    },
    // 天数分隔符
    dayText: {
      type: String,
      default: '天'
    },
    // 小时分隔符
    hourText: {
      type: String,
      default: '时'
    },
    // 分钟分隔符
    minuteText: {
      type: String,
      default: '分'
    },
    // 秒数分隔符
    secondText: {
      type: String,
      default: '秒'
    },
    // 目标时间戳
    datatime: {
      type: Number,
      default: 0
    },
    // 是否显示天
    isDay: {
      type: Boolean,
      default: true
    },
    // 是否显示秒
    isSecond: {
      type: Boolean,
      default: true
    },
    // 时间块背景色
    bgColor: {
      type: String,
      default: ''
    },
    // 时间数字颜色
    colors: {
      type: String,
      default: '#e93323'
    },
    // 分隔符颜色
    dotColor: {
      type: String,
      default: '#ffffff'
    }
  },

  data() {
    return {
      day: '00',
      hour: '00',
      minute: '00',
      second: '00',
      timer: null
    };
  },

  computed: {
    // 时间块样式
    blockStyle() {
      return `background-color: ${this.bgColor}; color: ${this.colors};`;
    },
    // 分隔符样式
    separatorStyle() {
      return `color: ${this.dotColor};`;
    }
  },

  watch: {
    datatime: {
      handler(val) {
        this.startCountDown();
      },
      immediate: true
    }
  },

  beforeDestroy() {
    this.clearTimer();
  },

  methods: {
    // 开始倒计时
    startCountDown() {
      this.clearTimer();
      this.updateTime();
      this.timer = setInterval(() => {
        this.updateTime();
      }, 1000);
    },

    // 更新时间
    updateTime() {
      const now = Date.parse(new Date()) / 1000;
      let diff = this.datatime - now;

      if (diff > 0) {
        let day = 0, hour = 0, minute = 0, second = 0;

        if (this.isDay) {
          day = Math.floor(diff / (60 * 60 * 24));
        }

        hour = Math.floor(diff / (60 * 60)) - day * 24;
        minute = Math.floor(diff / 60) - day * 24 * 60 - hour * 60;
        second = Math.floor(diff) - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60;

        this.day = day;
        this.hour = hour <= 9 ? '0' + hour : hour;
        this.minute = minute <= 9 ? '0' + minute : minute;
        this.second = second <= 9 ? '0' + second : second;
      } else {
        this.day = '00';
        this.hour = '00';
        this.minute = '00';
        this.second = '00';
        this.clearTimer();
        // 倒计时结束事件
        this.$emit('endTime');
      }
    },

    // 清除定时器
    clearTimer() {
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
    }
  }
};
</script>

<style lang="scss" scoped>
.count-down {
  display: flex;
  justify-content: center;
  align-items: center;

  .tip-text {
    color: #e93323;
    margin-right: 8rpx;
  }

  .time-block {
    min-width: 40rpx;
    height: 40rpx;
    line-height: 40rpx;
    text-align: center;
    border-radius: 6rpx;
    font-size: 24rpx;

    .day-text {
      font-size: 20rpx;
      margin-left: 4rpx;
    }
  }

  .separator {
    margin: 0 4rpx;
    font-size: 24rpx;
  }
}
</style>

1.4 使用组件

全局注册

main.js 中全局注册组件:

// main.js
import Vue from 'vue';
import MyComponent from './components/myComponent/index.vue';
import CountDown from './components/countDown/index.vue';

// 全局注册
Vue.component('MyComponent', MyComponent);
Vue.component('CountDown', CountDown);

局部引入

在页面中局部引入:

<template>
  <view>
    <!-- 使用倒计时组件 -->
    <count-down 
      :datatime="endTime"
      tip-text="距结束"
      bg-color="#ff5500"
      colors="#ffffff"
      @endTime="handleEnd">
    </count-down>

    <!-- 使用自定义组件 -->
    <my-component 
      title="我的组件"
      :list="dataList"
      @click="handleClick">
      <text>内容区域</text>
      <template #footer>
        <button>底部按钮</button>
      </template>
    </my-component>
  </view>
</template>

<script>
import CountDown from '@/components/countDown/index.vue';
import MyComponent from '@/components/myComponent/index.vue';

export default {
  components: {
    CountDown,
    MyComponent
  },

  data() {
    return {
      endTime: Math.floor(Date.now() / 1000) + 3600, // 1小时后
      dataList: []
    };
  },

  methods: {
    handleEnd() {
      uni.showToast({ title: '倒计时结束', icon: 'none' });
    },

    handleClick(data) {
      console.log('clicked:', data);
    }
  }
};
</script>

1.5 实战示例:空状态组件

<!-- components/emptyPage.vue -->
<template>
  <view class="empty-page">
    <!-- 空状态图片 -->
    <image class="empty-img" :src="imgSrc"></image>

    <!-- 未登录提示 -->
    <view v-if="!isLogin" class="title">暂未登录</view>

    <!-- 提示文字 -->
    <view class="desc">{{ title }}</view>

    <!-- 登录按钮 -->
    <view class="login-btn" v-if="!isLogin" @tap="goLogin">
      立即登录
    </view>

    <!-- 底部插槽 -->
    <slot name="bottom"></slot>
  </view>
</template>

<script>
import { HTTP_REQUEST_URL } from '@/config/app';
import { toLogin } from '@/libs/login.js';

export default {
  name: 'EmptyPage',

  props: {
    // 提示文字
    title: {
      type: String,
      default: '暂无记录'
    },
    // 图片路径
    src: {
      type: String,
      default: '/statics/images/empty-box.gif'
    },
    // 是否已登录
    isLogin: {
      type: Boolean,
      default: true
    }
  },

  computed: {
    imgSrc() {
      return HTTP_REQUEST_URL + this.src;
    }
  },

  methods: {
    goLogin() {
      toLogin();
    }
  }
};
</script>

<style lang="scss" scoped>
.empty-page {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 82rpx 0 160rpx;
  background-color: #fff;
  border-radius: 24rpx;

  .empty-img {
    width: 440rpx;
    height: 360rpx;
  }

  .title {
    font-size: 28rpx;
    font-weight: 500;
    color: #282828;
    margin-top: 20rpx;
  }

  .desc {
    font-size: 26rpx;
    color: #999;
    line-height: 36rpx;
    margin-top: 16rpx;
  }

  .login-btn {
    width: 360rpx;
    height: 72rpx;
    border-radius: 36rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 26rpx;
    font-weight: 500;
    border: 1px solid var(--view-theme);
    color: var(--view-theme);
    margin-top: 48rpx;
  }
}
</style>

二、后台管理端组件开发(Vue + iView)

2.1 组件目录结构

view/admin/src/components/
├── cards/                 # 卡片组件
├── copyright/             # 版权组件
├── couponList/            # 优惠券列表
├── customerInfo/          # 客户信息
├── echarts/               # 图表组件
├── from/                  # 表单组件
├── fromBuild/             # 动态表单构建
├── goodsList/             # 商品列表选择器
├── uploadPictures/        # 图片上传组件
├── uploadVideo/           # 视频上传组件
├── searchFrom/            # 搜索表单
├── wangEditor/            # 富文本编辑器
├── map/                   # 地图组件
└── ...                    # 更多组件

2.2 创建基础组件

<!-- src/components/myAdminComponent/index.vue -->
<template>
  <div class="my-admin-component">
    <!-- 头部区域 -->
    <div class="header" v-if="showHeader">
      <span class="title">{{ title }}</span>
      <div class="actions">
        <slot name="header-actions"></slot>
      </div>
    </div>

    <!-- 内容区域 -->
    <div class="content">
      <slot></slot>
    </div>

    <!-- 加载状态 -->
    <Spin fix v-if="loading">
      <Icon type="ios-loading" size="18" class="spin-icon"></Icon>
      <div>加载中...</div>
    </Spin>
  </div>
</template>

<script>
export default {
  name: 'MyAdminComponent',

  props: {
    // 标题
    title: {
      type: String,
      default: ''
    },
    // 是否显示头部
    showHeader: {
      type: Boolean,
      default: true
    },
    // 加载状态
    loading: {
      type: Boolean,
      default: false
    }
  },

  data() {
    return {
      innerData: null
    };
  },

  methods: {
    // 刷新
    refresh() {
      this.$emit('refresh');
    },

    // 提交
    submit(data) {
      this.$emit('submit', data);
    }
  }
};
</script>

<style lang="stylus" scoped>
.my-admin-component
  background-color #fff
  border-radius 4px
  padding 20px

  .header
    display flex
    justify-content space-between
    align-items center
    margin-bottom 20px
    padding-bottom 15px
    border-bottom 1px solid #e8eaec

    .title
      font-size 16px
      font-weight 500
      color #17233d

  .content
    min-height 200px

  .spin-icon
    animation spin 1s linear infinite

@keyframes spin
  from
    transform rotate(0deg)
  to
    transform rotate(360deg)
</style>

2.3 实战示例:商品选择器组件

参考项目中的 goodsList 组件,实现一个完整的商品选择器:

<!-- src/components/goodsList/index.vue -->
<template>
  <div class="goods-list-selector">
    <!-- 搜索表单 -->
    <Form 
      ref="formValidate" 
      :model="formValidate" 
      :label-width="labelWidth"
      :label-position="labelPosition"
      inline
      class="search-form">

      <!-- 商品分类 -->
      <FormItem label="商品分类:">
        <Cascader
          :data="categoryList"
          placeholder="请选择商品分类"
          change-on-select
          filterable
          class="input-add"
          @on-change="handleCategoryChange">
        </Cascader>
      </FormItem>

      <!-- 商品名称搜索 -->
      <FormItem label="商品搜索:">
        <Input
          v-model="formValidate.keyword"
          placeholder="请输入商品名称/关键字/编号"
          class="input-add mr14"
        />
        <Button type="primary" @click="handleSearch">查询</Button>
      </FormItem>
    </Form>

    <!-- 商品表格 -->
    <Table
      ref="table"
      :columns="columns"
      :data="tableData"
      :loading="loading"
      no-data-text="暂无商品数据"
      @on-select="handleSelect"
      @on-select-cancel="handleSelectCancel"
      @on-select-all="handleSelectAll"
      @on-select-all-cancel="handleSelectAllCancel"
      height="500">

      <!-- 商品名称列 -->
      <template slot-scope="{ row }" slot="store_name">
        <Tooltip max-width="200" placement="bottom">
          <span class="line2">{{ row.store_name }}</span>
          <p slot="content">{{ row.store_name }}</p>
        </Tooltip>
      </template>

      <!-- 商品图片列 -->
      <template slot-scope="{ row }" slot="image">
        <div class="table-img">
          <img v-lazy="row.image" />
        </div>
      </template>

      <!-- 商品类型列 -->
      <template slot-scope="{ row }" slot="product_type">
        <span v-if="row.product_type === 0">普通商品</span>
        <span v-else-if="row.product_type === 1">卡密商品</span>
        <span v-else-if="row.product_type === 3">虚拟商品</span>
        <span v-else-if="row.product_type === 4">次卡商品</span>
        <span v-else-if="row.product_type === 5">卡项商品</span>
        <span v-else-if="row.product_type === 6">预约商品</span>
      </template>
    </Table>

    <!-- 分页 -->
    <div class="page-wrapper">
      <Page
        :total="total"
        :current="formValidate.page"
        :page-size="formValidate.limit"
        show-elevator
        show-total
        @on-change="handlePageChange"
      />
    </div>

    <!-- 提交按钮(多选模式) -->
    <div class="footer" v-if="multiple">
      <Button 
        type="primary" 
        size="large" 
        long 
        :loading="submitting"
        @click="handleSubmit">
        确定选择(已选{{ selectedList.length }}件)
      </Button>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex';
import { cascaderListApi, changeListApi } from '@/api/product';

export default {
  name: 'GoodsListSelector',

  props: {
    // 是否多选
    multiple: {
      type: Boolean,
      default: false
    },
    // 场景类型
    chooseType: {
      type: Number,
      default: 0
    },
    // 已选商品
    selectedIds: {
      type: Array,
      default: () => []
    }
  },

  data() {
    return {
      loading: false,
      submitting: false,
      categoryList: [],
      tableData: [],
      selectedList: [],
      total: 0,
      formValidate: {
        page: 1,
        limit: 10,
        cate_id: '',
        keyword: '',
        choose_type: this.chooseType
      },
      columns: []
    };
  },

  computed: {
    ...mapState('admin/layout', ['isMobile']),

    labelWidth() {
      return this.isMobile ? undefined : 100;
    },

    labelPosition() {
      return this.isMobile ? 'top' : 'right';
    }
  },

  created() {
    this.initColumns();
    this.getCategoryList();
    this.getGoodsList();
  },

  methods: {
    // 初始化表格列
    initColumns() {
      const selectionColumn = {
        type: 'selection',
        width: 60,
        align: 'center'
      };

      const radioColumn = {
        title: '选择',
        width: 70,
        align: 'center',
        render: (h, params) => {
          return h('Radio', {
            props: {
              value: this.selectedList.some(item => item.id === params.row.id)
            },
            on: {
              'on-change': () => {
                this.selectedList = [params.row];
                this.$emit('select', params.row);
              }
            }
          });
        }
      };

      const dataColumns = [
        { title: '商品ID', key: 'id', width: 100 },
        { title: '图片', slot: 'image', width: 80 },
        { title: '商品名称', slot: 'store_name', minWidth: 200 },
        { title: '商品类型', slot: 'product_type', width: 120 },
        { title: '商品分类', key: 'cate_name', minWidth: 150 }
      ];

      this.columns = this.multiple 
        ? [selectionColumn, ...dataColumns]
        : [radioColumn, ...dataColumns];
    },

    // 获取商品分类
    async getCategoryList() {
      try {
        const res = await cascaderListApi({ type: 0, relation_id: 0 });
        this.categoryList = res.data;
      } catch (error) {
        this.$Message.error(error.msg || '获取分类失败');
      }
    },

    // 获取商品列表
    async getGoodsList() {
      this.loading = true;
      try {
        const res = await changeListApi(this.formValidate);
        this.tableData = res.data.list || [];
        this.total = res.data.count;

        // 回显已选中状态
        this.restoreSelection();
      } catch (error) {
        this.$Message.error(error.msg || '获取商品失败');
      } finally {
        this.loading = false;
      }
    },

    // 恢复选中状态
    restoreSelection() {
      if (this.selectedList.length) {
        this.tableData.forEach(item => {
          if (this.selectedList.some(s => s.id === item.id)) {
            item._checked = true;
          }
        });
      }
    },

    // 分类变化
    handleCategoryChange(value) {
      this.formValidate.cate_id = value[value.length - 1] || '';
      this.formValidate.page = 1;
      this.getGoodsList();
    },

    // 搜索
    handleSearch() {
      this.formValidate.page = 1;
      this.getGoodsList();
    },

    // 分页变化
    handlePageChange(page) {
      this.formValidate.page = page;
      this.getGoodsList();
    },

    // 选中行
    handleSelect(selection, row) {
      if (!this.selectedList.some(item => item.id === row.id)) {
        this.selectedList.push(row);
      }
    },

    // 取消选中
    handleSelectCancel(selection, row) {
      const index = this.selectedList.findIndex(item => item.id === row.id);
      if (index > -1) {
        this.selectedList.splice(index, 1);
      }
    },

    // 全选
    handleSelectAll(selection) {
      selection.forEach(row => {
        if (!this.selectedList.some(item => item.id === row.id)) {
          this.selectedList.push(row);
        }
      });
    },

    // 取消全选
    handleSelectAllCancel() {
      this.tableData.forEach(row => {
        const index = this.selectedList.findIndex(item => item.id === row.id);
        if (index > -1) {
          this.selectedList.splice(index, 1);
        }
      });
    },

    // 提交选择
    handleSubmit() {
      if (!this.selectedList.length) {
        this.$Message.warning('请先选择商品');
        return;
      }
      this.$emit('confirm', this.selectedList);
    },

    // 清空选择
    clearSelection() {
      this.selectedList = [];
      this.$refs.table.selectAll(false);
    }
  }
};
</script>

<style lang="stylus" scoped>
.goods-list-selector
  .search-form
    margin-bottom 20px

    /deep/ .ivu-form-item
      margin-bottom 16px

  .input-add
    width 200px

  .mr14
    margin-right 14px

  .table-img
    width 36px
    height 36px
    border-radius 4px
    overflow hidden

    img
      width 100%
      height 100%
      object-fit cover

  .line2
    display -webkit-box
    -webkit-line-clamp 2
    -webkit-box-orient vertical
    overflow hidden

  .page-wrapper
    display flex
    justify-content flex-end
    margin-top 20px

  .footer
    margin-top 20px
    padding-top 20px
    border-top 1px solid #e8eaec
</style>

2.4 使用后台组件

<template>
  <div class="my-page">
    <!-- 使用商品选择器 -->
    <Modal
      v-model="showGoodsModal"
      title="选择商品"
      width="900"
      :mask-closable="false">
      <goods-list-selector
        ref="goodsSelector"
        :multiple="true"
        :choose-type="1"
        @confirm="handleGoodsConfirm"
      />
    </Modal>

    <Button type="primary" @click="showGoodsModal = true">
      选择商品
    </Button>

    <!-- 已选商品展示 -->
    <div class="selected-goods" v-if="selectedGoods.length">
      <div class="goods-item" v-for="item in selectedGoods" :key="item.id">
        <img :src="item.image" />
        <span>{{ item.store_name }}</span>
        <Icon type="ios-close" @click="removeGoods(item.id)" />
      </div>
    </div>
  </div>
</template>

<script>
import GoodsListSelector from '@/components/goodsList/index.vue';

export default {
  components: {
    GoodsListSelector
  },

  data() {
    return {
      showGoodsModal: false,
      selectedGoods: []
    };
  },

  methods: {
    handleGoodsConfirm(goods) {
      this.selectedGoods = goods;
      this.showGoodsModal = false;
    },

    removeGoods(id) {
      const index = this.selectedGoods.findIndex(item => item.id === id);
      if (index > -1) {
        this.selectedGoods.splice(index, 1);
      }
    }
  }
};
</script>

2.5 全局注册后台组件

main.js 中全局注册:

// src/main.js
import Vue from 'vue';
import GoodsList from '@/components/goodsList/index.vue';
import UploadPictures from '@/components/uploadPictures/index.vue';

// 全局注册
Vue.component('GoodsList', GoodsList);
Vue.component('UploadPictures', UploadPictures);

三、组件开发最佳实践

3.1 Props 设计原则

props: {
  // 1. 明确类型
  count: {
    type: Number,
    default: 0
  },

  // 2. 多类型支持
  value: {
    type: [String, Number],
    default: ''
  },

  // 3. 必填校验
  id: {
    type: Number,
    required: true
  },

  // 4. 自定义校验
  status: {
    type: String,
    validator: (value) => {
      return ['pending', 'success', 'error'].includes(value);
    }
  },

  // 5. 对象/数组默认值用工厂函数
  list: {
    type: Array,
    default: () => []
  },
  options: {
    type: Object,
    default: () => ({})
  }
}

3.2 事件命名规范

// 使用 kebab-case 命名事件
this.$emit('update:value', newValue);  // 支持.sync修饰符
this.$emit('input', newValue);          // 支持v-model
this.$emit('change', newValue);         // 值变化事件
this.$emit('click', data);              // 点击事件
this.$emit('select', item);             // 选择事件
this.$emit('confirm', result);          // 确认事件
this.$emit('cancel');                   // 取消事件
this.$emit('load-more');                // 加载更多事件

3.3 插槽使用

<template>
  <div class="my-component">
    <!-- 默认插槽 -->
    <slot></slot>

    <!-- 具名插槽 -->
    <slot name="header"></slot>
    <slot name="footer"></slot>

    <!-- 作用域插槽 -->
    <slot name="item" :data="item" :index="index"></slot>
  </div>
</template>

<!-- 使用方 -->
<my-component>
  <!-- 默认插槽内容 -->
  <div>默认内容</div>

  <!-- 具名插槽 -->
  <template #header>
    <h1>头部内容</h1>
  </template>

  <!-- 作用域插槽 -->
  <template #item="{ data, index }">
    <div>{{ index }}: {{ data.name }}</div>
  </template>
</my-component>

3.4 组件通信方式

// 1. Props / Events(父子通信)
// 父组件
<child :value="data" @change="handleChange"></child>

// 子组件
this.$emit('change', newValue);

// 2. v-model(双向绑定)
// 子组件
props: ['value'],
methods: {
  updateValue(val) {
    this.$emit('input', val);
  }
}

// 3. .sync 修饰符
// 父组件
<child :visible.sync="isVisible"></child>

// 子组件
this.$emit('update:visible', false);

// 4. provide / inject(跨层级)
// 祖先组件
provide() {
  return {
    theme: this.theme
  };
}

// 后代组件
inject: ['theme']

// 5. EventBus(全局事件)
// 发送
this.$eventHub.$emit('eventName', data);

// 接收
this.$eventHub.$on('eventName', handler);

四、常见问题

Q1: 如何实现组件懒加载?

// 路由懒加载
const MyComponent = () => import('@/components/MyComponent.vue');

// 组件懒加载
components: {
  MyComponent: () => import('@/components/MyComponent.vue')
}

Q2: 如何在组件中使用全局方法?

// main.js 挂载
Vue.prototype.$toast = (msg) => {
  uni.showToast({ title: msg, icon: 'none' });
};

// 组件中使用
this.$toast('提示信息');

Q3: 组件如何响应主题变化?

<script>
import colors from '@/mixins/color.js';

export default {
  mixins: [colors],

  computed: {
    themeStyle() {
      return {
        '--primary-color': this.colorStyle['--view-theme'],
        '--bg-color': this.colorStyle['--view-minorColorTwo']
      };
    }
  }
};
</script>

Q4: 如何创建递归组件?

<!-- TreeNode.vue -->
<template>
  <div class="tree-node">
    <div class="node-content">{{ node.name }}</div>
    <div class="node-children" v-if="node.children">
      <!-- 递归调用自身 -->
      <tree-node 
        v-for="child in node.children" 
        :key="child.id"
        :node="child">
      </tree-node>
    </div>
  </div>
</template>

<script>
export default {
  name: 'TreeNode',  // 必须定义name用于递归
  props: {
    node: Object
  }
};
</script>

Q5: 如何优化大型组件的性能?

export default {
  // 1. 使用计算属性缓存
  computed: {
    processedList() {
      return this.list.filter(item => item.visible);
    }
  },

  // 2. 使用v-once渲染静态内容
  // <div v-once>{{ staticContent }}</div>

  // 3. 使用v-memo缓存(Vue3)

  // 4. 组件按需加载
  components: {
    HeavyComponent: () => import('./HeavyComponent.vue')
  },

  // 5. 长列表使用虚拟滚动

  // 6. 避免深层响应式
  data() {
    return {
      // 使用Object.freeze()冻结大数据
      bigData: Object.freeze(largeArray)
    };
  }
};
{{cateWiki.like_num}}人点赞
0人点赞
评论({{cateWiki.comment_num}}) {{commentWhere.order ? '评论从旧到新':'评论从新到旧'}} {{cateWiki.page_view_num}}人看过该文档
评论(0) {{commentWhere.order ? '评论从旧到新':'评论从新到旧'}} 15人看过该文档
评论
{{item.user ? item.user.nickname : ''}} (自评)
{{item.content}}
{{item.create_time}} 删除
{{item.like ? item.like.like_num : 0}} {{replyIndex == index ? '取消回复' : '回复'}}
评论
{{items.user ? items.user.nickname : '暂无昵称'}} (自评)
{{items.content}}
{{items.create_time}} 删除
{{items.like ? items.like.like_num : 0}} {{replyIndexJ == (index+'|'+indexJ) ? '取消回复' : '回复'}}
评论
目录
  • {{item}}