6.1 自定义组件基础

6.1.1 组件创建与注册

在 UniApp 中,组件是构建用户界面的基本单元。通过创建自定义组件,我们可以实现代码复用、提高开发效率。

<!-- components/MyButton/MyButton.vue -->
<template>
  <button 
    class="my-button" 
    :class="[
      `my-button--${type}`,
      `my-button--${size}`,
      {
        'my-button--disabled': disabled,
        'my-button--loading': loading,
        'my-button--round': round,
        'my-button--plain': plain
      }
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <!-- 加载状态 -->
    <view v-if="loading" class="my-button__loading">
      <view class="loading-spinner"></view>
    </view>
    
    <!-- 图标 -->
    <view v-if="icon && !loading" class="my-button__icon">
      <text class="iconfont" :class="icon"></text>
    </view>
    
    <!-- 文本内容 -->
    <view v-if="$slots.default" class="my-button__text">
      <slot></slot>
    </view>
  </button>
</template>

<script>
export default {
  name: 'MyButton',
  props: {
    // 按钮类型
    type: {
      type: String,
      default: 'default',
      validator: (value) => {
        return ['default', 'primary', 'success', 'warning', 'danger', 'info'].includes(value);
      }
    },
    // 按钮尺寸
    size: {
      type: String,
      default: 'normal',
      validator: (value) => {
        return ['mini', 'small', 'normal', 'large'].includes(value);
      }
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false
    },
    // 是否加载中
    loading: {
      type: Boolean,
      default: false
    },
    // 是否圆角
    round: {
      type: Boolean,
      default: false
    },
    // 是否朴素按钮
    plain: {
      type: Boolean,
      default: false
    },
    // 图标
    icon: {
      type: String,
      default: ''
    },
    // 防抖延迟
    debounce: {
      type: Number,
      default: 300
    }
  },
  data() {
    return {
      clickTimer: null
    };
  },
  methods: {
    handleClick(event) {
      if (this.disabled || this.loading) {
        return;
      }
      
      // 防抖处理
      if (this.clickTimer) {
        clearTimeout(this.clickTimer);
      }
      
      this.clickTimer = setTimeout(() => {
        this.$emit('click', event);
        this.clickTimer = null;
      }, this.debounce);
    }
  },
  beforeDestroy() {
    if (this.clickTimer) {
      clearTimeout(this.clickTimer);
    }
  }
};
</script>

<style scoped>
.my-button {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding: 0 16px;
  height: 44px;
  font-size: 16px;
  line-height: 1.2;
  text-align: center;
  border-radius: 4px;
  border: 1px solid transparent;
  background-color: #fff;
  color: #323233;
  cursor: pointer;
  transition: all 0.3s;
  user-select: none;
  -webkit-appearance: none;
  -webkit-text-size-adjust: 100%;
}

.my-button:active {
  opacity: 0.7;
}

/* 按钮类型样式 */
.my-button--primary {
  background-color: #1989fa;
  border-color: #1989fa;
  color: #fff;
}

.my-button--success {
  background-color: #07c160;
  border-color: #07c160;
  color: #fff;
}

.my-button--warning {
  background-color: #ff976a;
  border-color: #ff976a;
  color: #fff;
}

.my-button--danger {
  background-color: #ee0a24;
  border-color: #ee0a24;
  color: #fff;
}

.my-button--info {
  background-color: #909399;
  border-color: #909399;
  color: #fff;
}

/* 朴素按钮样式 */
.my-button--plain {
  background-color: #fff;
}

.my-button--plain.my-button--primary {
  color: #1989fa;
  border-color: #1989fa;
}

.my-button--plain.my-button--success {
  color: #07c160;
  border-color: #07c160;
}

.my-button--plain.my-button--warning {
  color: #ff976a;
  border-color: #ff976a;
}

.my-button--plain.my-button--danger {
  color: #ee0a24;
  border-color: #ee0a24;
}

.my-button--plain.my-button--info {
  color: #909399;
  border-color: #909399;
}

/* 按钮尺寸样式 */
.my-button--mini {
  height: 24px;
  padding: 0 8px;
  font-size: 12px;
}

.my-button--small {
  height: 32px;
  padding: 0 12px;
  font-size: 14px;
}

.my-button--large {
  height: 50px;
  padding: 0 20px;
  font-size: 18px;
}

/* 圆角按钮 */
.my-button--round {
  border-radius: 22px;
}

.my-button--round.my-button--mini {
  border-radius: 12px;
}

.my-button--round.my-button--small {
  border-radius: 16px;
}

.my-button--round.my-button--large {
  border-radius: 25px;
}

/* 禁用状态 */
.my-button--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.my-button--disabled:active {
  opacity: 0.5;
}

/* 加载状态 */
.my-button--loading {
  cursor: default;
}

.my-button__loading {
  margin-right: 8px;
}

.loading-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.my-button__icon {
  margin-right: 8px;
  font-size: 1.2em;
}

.my-button__text {
  flex: 1;
}
</style>

6.4 组件库开发

6.4.1 组件库架构设计

创建一个完整的 UniApp 组件库需要考虑以下几个方面:

my-ui/
├── packages/                 # 组件源码
│   ├── button/              # 按钮组件
│   │   ├── index.vue
│   │   ├── index.js
│   │   └── README.md
│   ├── input/               # 输入框组件
│   ├── modal/               # 模态框组件
│   └── index.js             # 组件库入口
├── examples/                # 示例项目
│   ├── pages/
│   ├── static/
│   └── main.js
├── docs/                    # 文档
├── build/                   # 构建脚本
├── tests/                   # 测试文件
├── package.json
└── README.md

组件库入口文件

// packages/index.js
import Button from './button';
import Input from './input';
import Modal from './modal';
import Toast from './toast';
import Loading from './loading';
import Picker from './picker';
import DatePicker from './date-picker';
import ActionSheet from './action-sheet';
import Popup from './popup';
import Overlay from './overlay';

const components = {
  Button,
  Input,
  Modal,
  Toast,
  Loading,
  Picker,
  DatePicker,
  ActionSheet,
  Popup,
  Overlay
};

// 定义 install 方法,接收 Vue 作为参数
const install = function(Vue) {
  // 判断是否安装
  if (install.installed) return;
  install.installed = true;
  
  // 遍历注册全局组件
  Object.keys(components).forEach(key => {
    Vue.component(components[key].name, components[key]);
  });
};

// 判断是否是直接引入文件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

export default {
  version: '1.0.0',
  install,
  ...components
};

export {
  Button,
  Input,
  Modal,
  Toast,
  Loading,
  Picker,
  DatePicker,
  ActionSheet,
  Popup,
  Overlay
};

6.4.2 通用组件开发

Toast 提示组件

<!-- packages/toast/index.vue -->
<template>
  <view v-if="visible" class="my-toast" :class="toastClass" @click="handleClick">
    <view class="my-toast__content">
      <view v-if="icon" class="my-toast__icon">
        <text class="iconfont" :class="iconClass"></text>
      </view>
      <text class="my-toast__text">{{ message }}</text>
    </view>
  </view>
</template>

<script>
export default {
  name: 'MyToast',
  props: {
    message: {
      type: String,
      default: ''
    },
    type: {
      type: String,
      default: 'default',
      validator: (value) => {
        return ['default', 'success', 'error', 'warning', 'loading'].includes(value);
      }
    },
    position: {
      type: String,
      default: 'center',
      validator: (value) => {
        return ['top', 'center', 'bottom'].includes(value);
      }
    },
    duration: {
      type: Number,
      default: 2000
    },
    mask: {
      type: Boolean,
      default: false
    },
    icon: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      visible: false,
      timer: null
    };
  },
  computed: {
    toastClass() {
      return [
        `my-toast--${this.type}`,
        `my-toast--${this.position}`,
        {
          'my-toast--mask': this.mask
        }
      ];
    },
    iconClass() {
      const iconMap = {
        success: 'icon-check-circle',
        error: 'icon-close-circle',
        warning: 'icon-warning-circle',
        loading: 'icon-loading'
      };
      return iconMap[this.type];
    }
  },
  methods: {
    show() {
      this.visible = true;
      
      if (this.duration > 0 && this.type !== 'loading') {
        this.timer = setTimeout(() => {
          this.hide();
        }, this.duration);
      }
    },
    hide() {
      this.visible = false;
      this.clearTimer();
      this.$emit('close');
    },
    handleClick() {
      if (!this.mask) {
        this.hide();
      }
    },
    clearTimer() {
      if (this.timer) {
        clearTimeout(this.timer);
        this.timer = null;
      }
    }
  },
  beforeDestroy() {
    this.clearTimer();
  }
};
</script>

<style scoped>
.my-toast {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  z-index: 9999;
  max-width: 80%;
  min-width: 120px;
  pointer-events: none;
}

.my-toast--top {
  top: 20%;
}

.my-toast--center {
  top: 50%;
  transform: translate(-50%, -50%);
}

.my-toast--bottom {
  bottom: 20%;
}

.my-toast--mask {
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  transform: none;
  pointer-events: auto;
}

.my-toast__content {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 16px 20px;
  background-color: rgba(0, 0, 0, 0.8);
  color: #fff;
  border-radius: 8px;
  font-size: 14px;
  line-height: 1.4;
  word-break: break-all;
}

.my-toast--success .my-toast__content {
  background-color: rgba(7, 193, 96, 0.9);
}

.my-toast--error .my-toast__content {
  background-color: rgba(238, 10, 36, 0.9);
}

.my-toast--warning .my-toast__content {
  background-color: rgba(255, 151, 106, 0.9);
}

.my-toast__icon {
  margin-bottom: 8px;
  font-size: 24px;
}

.my-toast__icon .icon-loading {
  animation: rotate 1s linear infinite;
}

@keyframes rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.my-toast__text {
  text-align: center;
}
</style>

Toast 插件化

// packages/toast/index.js
import Vue from 'vue';
import ToastComponent from './index.vue';

const ToastConstructor = Vue.extend(ToastComponent);

let instance;
let instances = [];
let seed = 1;

const Toast = function(options = {}) {
  if (typeof options === 'string') {
    options = {
      message: options
    };
  }
  
  const id = 'toast_' + seed++;
  
  instance = new ToastConstructor({
    data: options
  });
  
  instance.id = id;
  instance.$mount();
  document.body.appendChild(instance.$el);
  
  instance.show();
  
  instance.$on('close', () => {
    Toast.close(id);
  });
  
  instances.push(instance);
  
  return instance;
};

// 快捷方法
['success', 'error', 'warning', 'loading'].forEach(type => {
  Toast[type] = function(options = {}) {
    if (typeof options === 'string') {
      options = {
        message: options
      };
    }
    options.type = type;
    return Toast(options);
  };
});

// 关闭指定 Toast
Toast.close = function(id) {
  const index = instances.findIndex(instance => instance.id === id);
  if (index !== -1) {
    const instance = instances[index];
    instances.splice(index, 1);
    
    if (instance.$el && instance.$el.parentNode) {
      instance.$el.parentNode.removeChild(instance.$el);
    }
    instance.$destroy();
  }
};

// 关闭所有 Toast
Toast.clear = function() {
  instances.forEach(instance => {
    if (instance.$el && instance.$el.parentNode) {
      instance.$el.parentNode.removeChild(instance.$el);
    }
    instance.$destroy();
  });
  instances = [];
};

// Vue 插件安装
Toast.install = function(Vue) {
  Vue.prototype.$toast = Toast;
};

export default Toast;

Popup 弹出层组件

<!-- packages/popup/index.vue -->
<template>
  <view>
    <!-- 遮罩层 -->
    <view 
      v-if="visible" 
      class="my-popup__overlay" 
      :class="{ 'my-popup__overlay--transparent': !overlay }"
      @click="handleOverlayClick"
    ></view>
    
    <!-- 弹出内容 -->
    <view 
      v-if="visible"
      class="my-popup"
      :class="popupClass"
      :style="popupStyle"
    >
      <!-- 关闭按钮 -->
      <view v-if="closeable" class="my-popup__close" @click="close">
        <text class="iconfont icon-close"></text>
      </view>
      
      <!-- 内容区域 -->
      <view class="my-popup__content">
        <slot></slot>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'MyPopup',
  props: {
    // 是否显示
    value: {
      type: Boolean,
      default: false
    },
    // 弹出位置
    position: {
      type: String,
      default: 'center',
      validator: (value) => {
        return ['center', 'top', 'bottom', 'left', 'right'].includes(value);
      }
    },
    // 是否显示遮罩
    overlay: {
      type: Boolean,
      default: true
    },
    // 点击遮罩是否关闭
    closeOnClickOverlay: {
      type: Boolean,
      default: true
    },
    // 是否显示关闭按钮
    closeable: {
      type: Boolean,
      default: false
    },
    // 自定义样式
    customStyle: {
      type: Object,
      default: () => ({})
    },
    // 圆角
    round: {
      type: Boolean,
      default: false
    },
    // 安全区域适配
    safeAreaInsetBottom: {
      type: Boolean,
      default: false
    },
    // 动画时长
    duration: {
      type: Number,
      default: 300
    }
  },
  data() {
    return {
      visible: this.value
    };
  },
  computed: {
    popupClass() {
      return [
        `my-popup--${this.position}`,
        {
          'my-popup--round': this.round,
          'my-popup--safe-area': this.safeAreaInsetBottom
        }
      ];
    },
    popupStyle() {
      return {
        ...this.customStyle,
        transitionDuration: `${this.duration}ms`
      };
    }
  },
  watch: {
    value(newVal) {
      this.visible = newVal;
    },
    visible(newVal) {
      this.$emit('input', newVal);
      if (newVal) {
        this.$emit('open');
      } else {
        this.$emit('close');
      }
    }
  },
  methods: {
    open() {
      this.visible = true;
    },
    close() {
      this.visible = false;
    },
    handleOverlayClick() {
      if (this.closeOnClickOverlay) {
        this.close();
      }
    }
  }
};
</script>

<style scoped>
.my-popup__overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 999;
}

.my-popup__overlay--transparent {
  background-color: transparent;
}

.my-popup {
  position: fixed;
  z-index: 1000;
  background-color: #fff;
  transition: all 0.3s;
}

.my-popup--center {
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  max-width: 90%;
  max-height: 90%;
}

.my-popup--top {
  top: 0;
  left: 0;
  width: 100%;
  transform: translateY(-100%);
}

.my-popup--top.my-popup {
  transform: translateY(0);
}

.my-popup--bottom {
  bottom: 0;
  left: 0;
  width: 100%;
  transform: translateY(100%);
}

.my-popup--bottom.my-popup {
  transform: translateY(0);
}

.my-popup--left {
  top: 0;
  left: 0;
  height: 100%;
  transform: translateX(-100%);
}

.my-popup--left.my-popup {
  transform: translateX(0);
}

.my-popup--right {
  top: 0;
  right: 0;
  height: 100%;
  transform: translateX(100%);
}

.my-popup--right.my-popup {
  transform: translateX(0);
}

.my-popup--round {
  border-radius: 16px;
}

.my-popup--round.my-popup--top {
  border-radius: 0 0 16px 16px;
}

.my-popup--round.my-popup--bottom {
  border-radius: 16px 16px 0 0;
}

.my-popup--safe-area {
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

.my-popup__close {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.1);
  border-radius: 50%;
  z-index: 1;
}

.my-popup__content {
  width: 100%;
  height: 100%;
  overflow: auto;
}
</style>

6.4.3 组件文档生成

组件文档模板

<!-- packages/button/README.md -->
# Button 按钮

按钮用于触发一个操作,如提交表单。

## 引入

```javascript
import { Button } from 'my-ui';

export default {
  components: {
    Button
  }
};

代码演示

按钮类型

<template>
  <view>
    <my-button>默认按钮</my-button>
    <my-button type="primary">主要按钮</my-button>
    <my-button type="success">成功按钮</my-button>
    <my-button type="warning">警告按钮</my-button>
    <my-button type="danger">危险按钮</my-button>
  </view>
</template>

朴素按钮

<template>
  <view>
    <my-button plain>朴素按钮</my-button>
    <my-button type="primary" plain>主要按钮</my-button>
  </view>
</template>

按钮尺寸

<template>
  <view>
    <my-button size="large">大号按钮</my-button>
    <my-button size="normal">普通按钮</my-button>
    <my-button size="small">小型按钮</my-button>
    <my-button size="mini">迷你按钮</my-button>
  </view>
</template>

禁用状态

<template>
  <view>
    <my-button disabled>禁用状态</my-button>
    <my-button type="primary" disabled>禁用状态</my-button>
  </view>
</template>

加载状态

<template>
  <view>
    <my-button loading>加载中</my-button>
    <my-button type="primary" loading>加载中</my-button>
  </view>
</template>

图标按钮

<template>
  <view>
    <my-button icon="icon-plus">添加</my-button>
    <my-button type="primary" icon="icon-search">搜索</my-button>
  </view>
</template>

API

Props

参数 说明 类型 默认值
type 类型,可选值为 primary success warning danger info string default
size 尺寸,可选值为 large normal small mini string normal
disabled 是否禁用按钮 boolean false
loading 是否显示为加载状态 boolean false
round 是否为圆角按钮 boolean false
plain 是否为朴素按钮 boolean false
icon 左侧图标名称 string -
debounce 防抖延迟,单位毫秒 number 300

Events

事件名 说明 回调参数
click 点击按钮时触发 event: Event

Slots

名称 说明
default 按钮内容

主题定制

样式变量

组件提供了下列 CSS 变量,可用于自定义样式。

名称 默认值 描述
–button-height 44px 按钮高度
–button-font-size 16px 按钮字体大小
–button-border-radius 4px 按钮圆角
–button-primary-color #1989fa 主要按钮颜色
–button-success-color #07c160 成功按钮颜色
–button-warning-color #ff976a 警告按钮颜色
–button-danger-color #ee0a24 危险按钮颜色

### 6.4.4 组件库构建配置

#### Webpack 配置

```javascript
// build/webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  mode: 'production',
  entry: {
    index: './packages/index.js'
  },
  output: {
    path: path.resolve(__dirname, '../lib'),
    filename: '[name].js',
    library: 'MyUI',
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  externals: {
    vue: {
      root: 'Vue',
      commonjs: 'vue',
      commonjs2: 'vue',
      amd: 'vue'
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader',
          'postcss-loader'
        ]
      },
      {
        test: /\.scss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ],
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      '@': path.resolve(__dirname, '../packages')
    }
  }
};

构建脚本

{
  "name": "my-ui",
  "version": "1.0.0",
  "description": "A Vue.js UI library for UniApp",
  "main": "lib/index.js",
  "files": [
    "lib",
    "packages"
  ],
  "scripts": {
    "build": "webpack --config build/webpack.config.js",
    "build:lib": "npm run build",
    "dev": "webpack-dev-server --config build/webpack.dev.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "lint": "eslint packages/**/*.{js,vue}",
    "docs:dev": "vuepress dev docs",
    "docs:build": "vuepress build docs"
  },
  "keywords": [
    "vue",
    "uniapp",
    "ui",
    "components"
  ],
  "author": "Your Name",
  "license": "MIT",
  "peerDependencies": {
    "vue": "^2.6.0"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "@babel/preset-env": "^7.0.0",
    "@vue/test-utils": "^1.0.0",
    "babel-loader": "^8.0.0",
    "css-loader": "^3.0.0",
    "eslint": "^6.0.0",
    "eslint-plugin-vue": "^6.0.0",
    "jest": "^24.0.0",
    "sass": "^1.0.0",
    "sass-loader": "^8.0.0",
    "vue-loader": "^15.0.0",
    "vue-style-loader": "^4.0.0",
    "vue-template-compiler": "^2.6.0",
    "vuepress": "^1.0.0",
    "webpack": "^4.0.0",
    "webpack-cli": "^3.0.0",
    "webpack-dev-server": "^3.0.0"
  }
}

6.5 组件测试

6.5.1 单元测试

Jest 配置

// jest.config.js
module.exports = {
  moduleFileExtensions: [
    'js',
    'json',
    'vue'
  ],
  transform: {
    '^.+\.vue$': 'vue-jest',
    '.+\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
    '^.+\.jsx?$': 'babel-jest'
  },
  transformIgnorePatterns: [
    '/node_modules/'
  ],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/packages/$1'
  },
  snapshotSerializers: [
    'jest-serializer-vue'
  ],
  testMatch: [
    '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
  ],
  testURL: 'http://localhost/',
  collectCoverage: true,
  collectCoverageFrom: [
    'packages/**/*.{js,vue}',
    '!packages/index.js',
    '!**/node_modules/**'
  ]
};

组件测试示例

// tests/unit/button.spec.js
import { mount } from '@vue/test-utils';
import Button from '@/button/index.vue';

describe('Button', () => {
  // 基础渲染测试
  it('renders correctly', () => {
    const wrapper = mount(Button, {
      slots: {
        default: 'Click me'
      }
    });
    expect(wrapper.text()).toBe('Click me');
    expect(wrapper.classes()).toContain('my-button');
  });

  // Props 测试
  it('renders different types', () => {
    const wrapper = mount(Button, {
      propsData: {
        type: 'primary'
      }
    });
    expect(wrapper.classes()).toContain('my-button--primary');
  });

  it('renders different sizes', () => {
    const wrapper = mount(Button, {
      propsData: {
        size: 'large'
      }
    });
    expect(wrapper.classes()).toContain('my-button--large');
  });

  it('renders disabled state', () => {
    const wrapper = mount(Button, {
      propsData: {
        disabled: true
      }
    });
    expect(wrapper.classes()).toContain('my-button--disabled');
    expect(wrapper.attributes('disabled')).toBe('disabled');
  });

  it('renders loading state', () => {
    const wrapper = mount(Button, {
      propsData: {
        loading: true
      }
    });
    expect(wrapper.classes()).toContain('my-button--loading');
    expect(wrapper.find('.loading-spinner').exists()).toBe(true);
  });

  // 事件测试
  it('emits click event', () => {
    const wrapper = mount(Button);
    wrapper.trigger('click');
    expect(wrapper.emitted().click).toBeTruthy();
  });

  it('does not emit click when disabled', () => {
    const wrapper = mount(Button, {
      propsData: {
        disabled: true
      }
    });
    wrapper.trigger('click');
    expect(wrapper.emitted().click).toBeFalsy();
  });

  it('does not emit click when loading', () => {
    const wrapper = mount(Button, {
      propsData: {
        loading: true
      }
    });
    wrapper.trigger('click');
    expect(wrapper.emitted().click).toBeFalsy();
  });

  // 防抖测试
  it('debounces click events', (done) => {
    const wrapper = mount(Button, {
      propsData: {
        debounce: 100
      }
    });
    
    // 快速点击多次
    wrapper.trigger('click');
    wrapper.trigger('click');
    wrapper.trigger('click');
    
    // 应该没有立即触发事件
    expect(wrapper.emitted().click).toBeFalsy();
    
    // 等待防抖时间后检查
    setTimeout(() => {
      expect(wrapper.emitted().click).toBeTruthy();
      expect(wrapper.emitted().click.length).toBe(1);
      done();
    }, 150);
  });

  // 快照测试
  it('matches snapshot', () => {
    const wrapper = mount(Button, {
      propsData: {
        type: 'primary',
        size: 'large'
      },
      slots: {
        default: 'Primary Button'
      }
    });
    expect(wrapper.html()).toMatchSnapshot();
  });
});

6.5.2 集成测试

// tests/integration/toast.spec.js
import { mount, createLocalVue } from '@vue/test-utils';
import Toast from '@/toast/index.js';

const localVue = createLocalVue();
localVue.use(Toast);

describe('Toast Integration', () => {
  let wrapper;
  
  beforeEach(() => {
    // 清理之前的 Toast 实例
    Toast.clear();
  });
  
  afterEach(() => {
    if (wrapper) {
      wrapper.destroy();
    }
    Toast.clear();
  });

  it('shows toast through plugin', () => {
    const TestComponent = {
      template: '<div><button @click="showToast">Show Toast</button></div>',
      methods: {
        showToast() {
          this.$toast('Hello World');
        }
      }
    };
    
    wrapper = mount(TestComponent, { localVue });
    wrapper.find('button').trigger('click');
    
    // 检查 Toast 是否被创建
    const toastElement = document.querySelector('.my-toast');
    expect(toastElement).toBeTruthy();
    expect(toastElement.textContent).toContain('Hello World');
  });

  it('shows different types of toast', () => {
    Toast.success('Success message');
    Toast.error('Error message');
    Toast.warning('Warning message');
    
    const toastElements = document.querySelectorAll('.my-toast');
    expect(toastElements.length).toBe(3);
  });

  it('auto closes toast after duration', (done) => {
    Toast({
      message: 'Auto close',
      duration: 100
    });
    
    setTimeout(() => {
      const toastElement = document.querySelector('.my-toast');
      expect(toastElement).toBeFalsy();
      done();
    }, 150);
  });

  it('clears all toasts', () => {
    Toast('Toast 1');
    Toast('Toast 2');
    Toast('Toast 3');
    
    let toastElements = document.querySelectorAll('.my-toast');
    expect(toastElements.length).toBe(3);
    
    Toast.clear();
    
    toastElements = document.querySelectorAll('.my-toast');
    expect(toastElements.length).toBe(0);
  });
});

6.5.3 E2E 测试

// tests/e2e/button.e2e.js
describe('Button E2E Tests', () => {
  beforeEach(() => {
    cy.visit('/examples/button');
  });

  it('should render different button types', () => {
    cy.get('[data-test="default-button"]').should('be.visible');
    cy.get('[data-test="primary-button"]').should('be.visible').and('have.class', 'my-button--primary');
    cy.get('[data-test="success-button"]').should('be.visible').and('have.class', 'my-button--success');
  });

  it('should handle click events', () => {
    cy.get('[data-test="click-button"]').click();
    cy.get('[data-test="click-count"]').should('contain', '1');
    
    cy.get('[data-test="click-button"]').click();
    cy.get('[data-test="click-count"]').should('contain', '2');
  });

  it('should not click when disabled', () => {
    cy.get('[data-test="disabled-button"]').should('be.disabled');
    cy.get('[data-test="disabled-button"]').click({ force: true });
    cy.get('[data-test="click-count"]').should('contain', '0');
  });

  it('should show loading state', () => {
    cy.get('[data-test="loading-button"]').click();
    cy.get('[data-test="loading-button"]').should('have.class', 'my-button--loading');
    cy.get('.loading-spinner').should('be.visible');
  });
});

6.6 本章总结

在本章中,我们深入学习了 UniApp 中的组件开发与复用,主要包括以下内容:

学习要点回顾

  1. 自定义组件基础

    • 组件创建与注册方法
    • 全局注册和局部注册
    • easycom 组件规范的使用
    • 组件的生命周期和最佳实践
  2. 组件通信

    • Props 和 Events 的使用
    • Provide/Inject 依赖注入
    • 事件总线(Event Bus)
    • 不同场景下的通信选择
  3. 高级组件模式

    • 高阶组件(HOC)的实现
    • 渲染函数和 JSX 的使用
    • 插槽(Slots)的进阶用法
    • 作用域插槽和具名插槽
  4. 组件库开发

    • 组件库架构设计
    • 通用组件开发(Toast、Popup 等)
    • 组件文档生成
    • 构建配置和发布流程
  5. 组件测试

    • 单元测试的编写
    • 集成测试和 E2E 测试
    • 测试覆盖率和质量保证
    • 自动化测试流程

实践练习

  1. 创建自定义组件

    • 开发一个多功能的表单组件
    • 实现数据验证和错误提示
    • 支持多种输入类型和自定义样式
    • 添加防抖和节流功能
  2. 实现组件通信

    • 创建一个购物车系统
    • 使用不同的通信方式连接组件
    • 实现数据的双向绑定和状态同步
    • 添加事件监听和清理机制
  3. 开发组件库

    • 设计并实现一套完整的 UI 组件库
    • 包含常用的基础组件和业务组件
    • 编写详细的文档和使用示例
    • 配置构建和发布流程

常见问题解答

Q: 如何选择合适的组件通信方式? A: 父子组件使用 Props/Events,跨级组件使用 Provide/Inject,兄弟组件使用事件总线或状态管理,复杂应用推荐使用 Vuex/Pinia。

Q: 如何优化组件的性能? A: 使用 v-memo、computed、watch 优化响应式数据,合理使用 v-if/v-show,避免不必要的重新渲染,使用虚拟滚动处理大量数据。

Q: 如何设计可复用的组件? A: 遵循单一职责原则,提供灵活的 Props 配置,使用插槽支持内容定制,保持 API 的一致性和向后兼容性。

Q: 如何处理组件的样式隔离? A: 使用 scoped CSS、CSS Modules 或 CSS-in-JS,避免全局样式污染,提供主题定制能力。

Q: 如何测试组件的交互行为? A: 使用 @vue/test-utils 进行单元测试,模拟用户交互,测试事件触发和数据变化,使用快照测试验证渲染结果。

最佳实践建议

  1. 组件设计

    • 保持组件的单一职责和高内聚
    • 提供清晰的 API 和文档
    • 支持主题定制和国际化
    • 考虑无障碍访问和用户体验
  2. 代码质量

    • 使用 TypeScript 提供类型安全
    • 编写完整的测试用例
    • 遵循代码规范和最佳实践
    • 定期重构和优化代码
  3. 性能优化

    • 合理使用组件缓存和懒加载
    • 优化组件的渲染性能
    • 减少不必要的依赖和包体积
    • 监控和分析性能指标
  4. 维护管理

    • 建立版本管理和发布流程
    • 提供迁移指南和变更日志
    • 收集用户反馈和需求
    • 持续改进和功能迭代
  5. 团队协作

    • 制定组件开发规范
    • 建立代码审查机制
    • 分享最佳实践和经验
    • 培训和知识传递

通过本章的学习,你应该能够熟练地开发自定义组件、实现组件通信、构建组件库并进行有效的测试。下一章我们将学习《性能优化与调试》,包括性能分析、优化策略、调试技巧等内容。

6.1.2 组件注册方式

全局注册

// main.js
import Vue from 'vue';
import MyButton from '@/components/MyButton/MyButton.vue';

// 全局注册组件
Vue.component('MyButton', MyButton);

// 或者批量注册
const components = {
  MyButton: () => import('@/components/MyButton/MyButton.vue'),
  MyInput: () => import('@/components/MyInput/MyInput.vue'),
  MyModal: () => import('@/components/MyModal/MyModal.vue')
};

Object.keys(components).forEach(key => {
  Vue.component(key, components[key]);
});

局部注册

<!-- pages/index/index.vue -->
<template>
  <view class="container">
    <MyButton type="primary" @click="handleClick">点击我</MyButton>
    <MyButton type="success" size="small" icon="icon-check">成功</MyButton>
    <MyButton type="danger" :loading="loading" @click="handleAsyncAction">异步操作</MyButton>
  </view>
</template>

<script>
import MyButton from '@/components/MyButton/MyButton.vue';

export default {
  components: {
    MyButton
  },
  data() {
    return {
      loading: false
    };
  },
  methods: {
    handleClick() {
      uni.showToast({
        title: '按钮被点击了',
        icon: 'none'
      });
    },
    async handleAsyncAction() {
      this.loading = true;
      try {
        // 模拟异步操作
        await new Promise(resolve => setTimeout(resolve, 2000));
        uni.showToast({
          title: '操作成功',
          icon: 'success'
        });
      } catch (error) {
        uni.showToast({
          title: '操作失败',
          icon: 'error'
        });
      } finally {
        this.loading = false;
      }
    }
  }
};
</script>

6.1.3 easycom 组件规范

UniApp 支持 easycom 组件规范,可以自动引入组件,无需手动注册。

// pages.json
{
  "easycom": {
    "autoscan": true,
    "custom": {
      "^my-(.*)$": "@/components/my-$1/my-$1.vue",
      "^uni-(.*)$": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
    }
  },
  "pages": [
    // 页面配置
  ]
}

使用 easycom 规范后,可以直接在模板中使用组件:

<template>
  <view>
    <!-- 自动引入 @/components/my-button/my-button.vue -->
    <my-button type="primary">按钮</my-button>
    
    <!-- 自动引入 @/components/my-input/my-input.vue -->
    <my-input v-model="value" placeholder="请输入内容"></my-input>
  </view>
</template>

6.2 组件通信

6.2.1 Props 和 Events

父组件向子组件传递数据(Props)

<!-- components/UserCard/UserCard.vue -->
<template>
  <view class="user-card" :class="{ 'user-card--active': active }">
    <image class="user-avatar" :src="user.avatar" @error="handleAvatarError"></image>
    <view class="user-info">
      <text class="user-name">{{ user.name }}</text>
      <text class="user-desc">{{ user.description }}</text>
      <view class="user-tags">
        <text 
          v-for="tag in user.tags" 
          :key="tag" 
          class="user-tag"
        >
          {{ tag }}
        </text>
      </view>
    </view>
    <view class="user-actions">
      <button @click="handleFollow" class="follow-btn" :class="{ followed: isFollowed }">
        {{ isFollowed ? '已关注' : '关注' }}
      </button>
      <button @click="handleMessage" class="message-btn">私信</button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'UserCard',
  props: {
    // 用户信息对象
    user: {
      type: Object,
      required: true,
      default: () => ({}),
      validator: (value) => {
        return value && typeof value.name === 'string';
      }
    },
    // 是否激活状态
    active: {
      type: Boolean,
      default: false
    },
    // 是否已关注
    followed: {
      type: Boolean,
      default: false
    },
    // 是否显示操作按钮
    showActions: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      isFollowed: this.followed,
      defaultAvatar: '/static/images/default-avatar.png'
    };
  },
  watch: {
    followed(newVal) {
      this.isFollowed = newVal;
    }
  },
  methods: {
    handleFollow() {
      this.isFollowed = !this.isFollowed;
      this.$emit('follow', {
        user: this.user,
        followed: this.isFollowed
      });
    },
    handleMessage() {
      this.$emit('message', this.user);
    },
    handleAvatarError() {
      // 头像加载失败时使用默认头像
      this.$emit('avatar-error', this.user);
    }
  }
};
</script>

<style scoped>
.user-card {
  display: flex;
  align-items: center;
  padding: 16px;
  background-color: #fff;
  border-radius: 8px;
  margin-bottom: 12px;
  transition: all 0.3s;
}

.user-card--active {
  border: 2px solid #1989fa;
  box-shadow: 0 2px 12px rgba(25, 137, 250, 0.2);
}

.user-avatar {
  width: 60px;
  height: 60px;
  border-radius: 30px;
  margin-right: 12px;
}

.user-info {
  flex: 1;
}

.user-name {
  display: block;
  font-size: 16px;
  font-weight: bold;
  color: #333;
  margin-bottom: 4px;
}

.user-desc {
  display: block;
  font-size: 14px;
  color: #666;
  margin-bottom: 8px;
}

.user-tags {
  display: flex;
  flex-wrap: wrap;
}

.user-tag {
  padding: 2px 8px;
  margin-right: 8px;
  margin-bottom: 4px;
  font-size: 12px;
  background-color: #f0f0f0;
  color: #666;
  border-radius: 12px;
}

.user-actions {
  display: flex;
  flex-direction: column;
}

.follow-btn,
.message-btn {
  padding: 8px 16px;
  margin-bottom: 8px;
  font-size: 14px;
  border-radius: 4px;
  border: 1px solid #ddd;
  background-color: #fff;
  color: #333;
}

.follow-btn.followed {
  background-color: #1989fa;
  border-color: #1989fa;
  color: #fff;
}

.message-btn {
  background-color: #f8f8f8;
}
</style>

子组件向父组件传递数据(Events)

<!-- pages/users/users.vue -->
<template>
  <view class="users-page">
    <view class="search-bar">
      <input 
        v-model="searchKeyword" 
        placeholder="搜索用户" 
        @input="handleSearch"
        class="search-input"
      />
    </view>
    
    <scroll-view class="users-list" scroll-y>
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        :user="user"
        :active="selectedUserId === user.id"
        :followed="user.followed"
        @follow="handleUserFollow"
        @message="handleUserMessage"
        @avatar-error="handleAvatarError"
        @click="handleUserClick"
      />
    </scroll-view>
  </view>
</template>

<script>
import UserCard from '@/components/UserCard/UserCard.vue';

export default {
  components: {
    UserCard
  },
  data() {
    return {
      searchKeyword: '',
      selectedUserId: null,
      users: [
        {
          id: 1,
          name: '张三',
          description: '前端开发工程师',
          avatar: '/static/images/avatar1.jpg',
          tags: ['Vue', 'JavaScript', 'CSS'],
          followed: false
        },
        {
          id: 2,
          name: '李四',
          description: '后端开发工程师',
          avatar: '/static/images/avatar2.jpg',
          tags: ['Node.js', 'Python', 'MySQL'],
          followed: true
        }
      ]
    };
  },
  computed: {
    filteredUsers() {
      if (!this.searchKeyword) {
        return this.users;
      }
      return this.users.filter(user => 
        user.name.includes(this.searchKeyword) ||
        user.description.includes(this.searchKeyword) ||
        user.tags.some(tag => tag.includes(this.searchKeyword))
      );
    }
  },
  methods: {
    handleSearch() {
      // 搜索防抖处理
      clearTimeout(this.searchTimer);
      this.searchTimer = setTimeout(() => {
        console.log('搜索关键词:', this.searchKeyword);
      }, 300);
    },
    handleUserFollow(data) {
      const { user, followed } = data;
      console.log(`${followed ? '关注' : '取消关注'}用户:`, user.name);
      
      // 更新用户关注状态
      const targetUser = this.users.find(u => u.id === user.id);
      if (targetUser) {
        targetUser.followed = followed;
      }
      
      // 显示提示
      uni.showToast({
        title: followed ? '关注成功' : '取消关注',
        icon: 'success'
      });
    },
    handleUserMessage(user) {
      console.log('发送私信给用户:', user.name);
      uni.navigateTo({
        url: `/pages/chat/chat?userId=${user.id}&userName=${user.name}`
      });
    },
    handleAvatarError(user) {
      console.log('用户头像加载失败:', user.name);
      // 可以在这里处理头像加载失败的逻辑
    },
    handleUserClick(user) {
      this.selectedUserId = user.id;
      console.log('选中用户:', user.name);
    }
  },
  beforeDestroy() {
    if (this.searchTimer) {
      clearTimeout(this.searchTimer);
    }
  }
};
</script>

<style>
.users-page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #f5f5f5;
}

.search-bar {
  padding: 16px;
  background-color: #fff;
  border-bottom: 1px solid #eee;
}

.search-input {
  width: 100%;
  padding: 12px 16px;
  border: 1px solid #ddd;
  border-radius: 24px;
  font-size: 16px;
  background-color: #f8f8f8;
}

.users-list {
  flex: 1;
  padding: 16px;
}
</style>

6.2.2 Provide/Inject 依赖注入

对于深层嵌套的组件通信,可以使用 provide/inject 模式:

<!-- components/ThemeProvider/ThemeProvider.vue -->
<template>
  <view class="theme-provider" :class="`theme-${currentTheme}`">
    <slot></slot>
  </view>
</template>

<script>
export default {
  name: 'ThemeProvider',
  props: {
    theme: {
      type: String,
      default: 'light'
    }
  },
  data() {
    return {
      currentTheme: this.theme
    };
  },
  provide() {
    return {
      theme: this.currentTheme,
      changeTheme: this.changeTheme,
      getThemeColor: this.getThemeColor
    };
  },
  methods: {
    changeTheme(theme) {
      this.currentTheme = theme;
      this.$emit('theme-change', theme);
    },
    getThemeColor(type) {
      const themes = {
        light: {
          primary: '#1989fa',
          background: '#fff',
          text: '#333'
        },
        dark: {
          primary: '#409eff',
          background: '#1a1a1a',
          text: '#fff'
        }
      };
      return themes[this.currentTheme][type];
    }
  }
};
</script>

<style>
.theme-light {
  background-color: #fff;
  color: #333;
}

.theme-dark {
  background-color: #1a1a1a;
  color: #fff;
}
</style>
<!-- components/ThemedButton/ThemedButton.vue -->
<template>
  <button 
    class="themed-button" 
    :style="buttonStyle"
    @click="$emit('click', $event)"
  >
    <slot></slot>
  </button>
</template>

<script>
export default {
  name: 'ThemedButton',
  inject: ['theme', 'getThemeColor'],
  computed: {
    buttonStyle() {
      return {
        backgroundColor: this.getThemeColor('primary'),
        color: this.getThemeColor('background'),
        border: `1px solid ${this.getThemeColor('primary')}`
      };
    }
  }
};
</script>

<style scoped>
.themed-button {
  padding: 12px 24px;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.3s;
}

.themed-button:hover {
  opacity: 0.8;
}
</style>

6.2.3 事件总线(Event Bus)

对于非父子组件间的通信,可以使用事件总线:

// utils/eventBus.js
import Vue from 'vue';

class EventBus {
  constructor() {
    this.vue = new Vue();
  }

  // 监听事件
  on(event, callback) {
    this.vue.$on(event, callback);
  }

  // 触发事件
  emit(event, data) {
    this.vue.$emit(event, data);
  }

  // 移除事件监听
  off(event, callback) {
    this.vue.$off(event, callback);
  }

  // 只监听一次
  once(event, callback) {
    this.vue.$once(event, callback);
  }

  // 移除所有监听器
  clear() {
    this.vue.$off();
  }
}

const eventBus = new EventBus();

export default eventBus;

使用事件总线进行组件通信:

<!-- components/CartButton/CartButton.vue -->
<template>
  <button class="cart-button" @click="addToCart">
    <text class="cart-icon">🛒</text>
    <text class="cart-count" v-if="cartCount > 0">{{ cartCount }}</text>
  </button>
</template>

<script>
import eventBus from '@/utils/eventBus';

export default {
  name: 'CartButton',
  props: {
    product: {
      type: Object,
      required: true
    }
  },
  data() {
    return {
      cartCount: 0
    };
  },
  mounted() {
    // 监听购物车更新事件
    eventBus.on('cart-updated', this.handleCartUpdate);
  },
  beforeDestroy() {
    // 移除事件监听
    eventBus.off('cart-updated', this.handleCartUpdate);
  },
  methods: {
    addToCart() {
      // 触发添加到购物车事件
      eventBus.emit('add-to-cart', {
        product: this.product,
        quantity: 1
      });
    },
    handleCartUpdate(data) {
      this.cartCount = data.totalCount;
    }
  }
};
</script>
<!-- components/CartManager/CartManager.vue -->
<template>
  <view class="cart-manager">
    <!-- 购物车内容 -->
  </view>
</template>

<script>
import eventBus from '@/utils/eventBus';

export default {
  name: 'CartManager',
  data() {
    return {
      cartItems: [],
      totalCount: 0
    };
  },
  mounted() {
    // 监听添加到购物车事件
    eventBus.on('add-to-cart', this.handleAddToCart);
  },
  beforeDestroy() {
    eventBus.off('add-to-cart', this.handleAddToCart);
  },
  methods: {
    handleAddToCart(data) {
      const { product, quantity } = data;
      
      // 添加商品到购物车
      const existingItem = this.cartItems.find(item => item.id === product.id);
      if (existingItem) {
        existingItem.quantity += quantity;
      } else {
        this.cartItems.push({
          ...product,
          quantity
        });
      }
      
      // 更新总数量
      this.updateTotalCount();
      
      // 通知其他组件购物车已更新
      eventBus.emit('cart-updated', {
        items: this.cartItems,
        totalCount: this.totalCount
      });
      
      // 显示提示
      uni.showToast({
        title: '已添加到购物车',
        icon: 'success'
      });
    },
    updateTotalCount() {
      this.totalCount = this.cartItems.reduce((total, item) => total + item.quantity, 0);
    }
  }
};
</script>

6.3 高级组件模式

6.3.1 高阶组件(HOC)

高阶组件是一个函数,接收一个组件并返回一个新的组件:

// mixins/withLoading.js
export function withLoading(WrappedComponent) {
  return {
    name: `WithLoading${WrappedComponent.name}`,
    props: {
      loading: {
        type: Boolean,
        default: false
      },
      loadingText: {
        type: String,
        default: '加载中...'
      }
    },
    render(h) {
      if (this.loading) {
        return h('view', {
          class: 'loading-wrapper'
        }, [
          h('view', {
            class: 'loading-spinner'
          }),
          h('text', {
            class: 'loading-text'
          }, this.loadingText)
        ]);
      }
      
      return h(WrappedComponent, {
        props: this.$props,
        attrs: this.$attrs,
        on: this.$listeners
      }, this.$slots.default);
    }
  };
}

6.3.2 渲染函数和 JSX

// components/DynamicList/DynamicList.js
export default {
  name: 'DynamicList',
  props: {
    items: {
      type: Array,
      default: () => []
    },
    renderItem: {
      type: Function,
      required: true
    },
    keyField: {
      type: String,
      default: 'id'
    }
  },
  render(h) {
    const listItems = this.items.map((item, index) => {
      return h('view', {
        key: item[this.keyField] || index,
        class: 'list-item'
      }, [
        this.renderItem(h, item, index)
      ]);
    });
    
    return h('view', {
      class: 'dynamic-list'
    }, listItems);
  }
};

使用动态列表组件:

<template>
  <view>
    <DynamicList 
      :items="products" 
      :render-item="renderProduct"
      key-field="id"
    />
  </view>
</template>

<script>
import DynamicList from '@/components/DynamicList/DynamicList.js';

export default {
  components: {
    DynamicList
  },
  data() {
    return {
      products: [
        { id: 1, name: '商品1', price: 100 },
        { id: 2, name: '商品2', price: 200 }
      ]
    };
  },
  methods: {
    renderProduct(h, product, index) {
      return h('view', {
        class: 'product-item',
        on: {
          click: () => this.handleProductClick(product)
        }
      }, [
        h('text', { class: 'product-name' }, product.name),
        h('text', { class: 'product-price' }, `¥${product.price}`)
      ]);
    },
    handleProductClick(product) {
      console.log('点击商品:', product);
    }
  }
};
</script>

6.3.3 插槽(Slots)进阶用法

具名插槽和作用域插槽

<!-- components/DataTable/DataTable.vue -->
<template>
  <view class="data-table">
    <!-- 表头 -->
    <view class="table-header">
      <slot name="header" :columns="columns">
        <view class="default-header">
          <text v-for="column in columns" :key="column.key" class="header-cell">
            {{ column.title }}
          </text>
        </view>
      </slot>
    </view>
    
    <!-- 表格内容 -->
    <scroll-view class="table-body" scroll-y>
      <view v-for="(row, rowIndex) in data" :key="rowIndex" class="table-row">
        <slot name="row" :row="row" :index="rowIndex" :columns="columns">
          <!-- 默认行渲染 -->
          <view v-for="column in columns" :key="column.key" class="table-cell">
            <slot 
              :name="`cell-${column.key}`" 
              :value="row[column.key]" 
              :row="row" 
              :column="column"
              :index="rowIndex"
            >
              {{ row[column.key] }}
            </slot>
          </view>
        </slot>
      </view>
    </scroll-view>
    
    <!-- 表尾 -->
    <view class="table-footer" v-if="$slots.footer">
      <slot name="footer" :data="data" :total="total"></slot>
    </view>
    
    <!-- 空状态 -->
    <view v-if="data.length === 0" class="empty-state">
      <slot name="empty">
        <text class="empty-text">暂无数据</text>
      </slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'DataTable',
  props: {
    columns: {
      type: Array,
      required: true
    },
    data: {
      type: Array,
      default: () => []
    }
  },
  computed: {
    total() {
      return this.data.length;
    }
  }
};
</script>

<style scoped>
.data-table {
  border: 1px solid #eee;
  border-radius: 4px;
  overflow: hidden;
}

.table-header {
  background-color: #f5f5f5;
  border-bottom: 1px solid #eee;
}

.default-header {
  display: flex;
}

.header-cell {
  flex: 1;
  padding: 12px;
  font-weight: bold;
  text-align: center;
  border-right: 1px solid #eee;
}

.table-body {
  max-height: 400px;
}

.table-row {
  display: flex;
  border-bottom: 1px solid #eee;
}

.table-row:hover {
  background-color: #f9f9f9;
}

.table-cell {
  flex: 1;
  padding: 12px;
  text-align: center;
  border-right: 1px solid #eee;
}

.table-footer {
  padding: 12px;
  background-color: #f5f5f5;
  border-top: 1px solid #eee;
}

.empty-state {
  padding: 40px;
  text-align: center;
}

.empty-text {
  color: #999;
  font-size: 14px;
}
</style>

使用数据表格组件:

<template>
  <view class="page">
    <DataTable :columns="columns" :data="users">
      <!-- 自定义表头 -->
      <template #header="{ columns }">
        <view class="custom-header">
          <text v-for="column in columns" :key="column.key" class="header-title">
            {{ column.title }}
            <text v-if="column.sortable" class="sort-icon">↕</text>
          </text>
        </view>
      </template>
      
      <!-- 自定义头像列 -->
      <template #cell-avatar="{ value, row }">
        <image class="user-avatar" :src="value" @error="handleAvatarError"></image>
      </template>
      
      <!-- 自定义状态列 -->
      <template #cell-status="{ value }">
        <text class="status-badge" :class="`status-${value}`">
          {{ getStatusText(value) }}
        </text>
      </template>
      
      <!-- 自定义操作列 -->
      <template #cell-actions="{ row }">
        <view class="action-buttons">
          <button @click="editUser(row)" class="edit-btn">编辑</button>
          <button @click="deleteUser(row)" class="delete-btn">删除</button>
        </view>
      </template>
      
      <!-- 自定义表尾 -->
      <template #footer="{ total }">
        <text class="footer-text">共 {{ total }} 条记录</text>
      </template>
      
      <!-- 自定义空状态 -->
      <template #empty>
        <view class="custom-empty">
          <text class="empty-icon">📭</text>
          <text class="empty-message">还没有用户数据</text>
          <button @click="addUser" class="add-btn">添加用户</button>
        </view>
      </template>
    </DataTable>
  </view>
</template>

<script>
import DataTable from '@/components/DataTable/DataTable.vue';

export default {
  components: {
    DataTable
  },
  data() {
    return {
      columns: [
        { key: 'avatar', title: '头像' },
        { key: 'name', title: '姓名', sortable: true },
        { key: 'email', title: '邮箱' },
        { key: 'status', title: '状态' },
        { key: 'actions', title: '操作' }
      ],
      users: [
        {
          id: 1,
          name: '张三',
          email: 'zhangsan@example.com',
          avatar: '/static/images/avatar1.jpg',
          status: 'active'
        },
        {
          id: 2,
          name: '李四',
          email: 'lisi@example.com',
          avatar: '/static/images/avatar2.jpg',
          status: 'inactive'
        }
      ]
    };
  },
  methods: {
    getStatusText(status) {
      const statusMap = {
        active: '活跃',
        inactive: '非活跃',
        banned: '已禁用'
      };
      return statusMap[status] || '未知';
    },
    editUser(user) {
      console.log('编辑用户:', user);
    },
    deleteUser(user) {
      console.log('删除用户:', user);
    },
    addUser() {
      console.log('添加用户');
    },
    handleAvatarError() {
      console.log('头像加载失败');
    }
  }
};
</script>

<style>
.custom-header {
  display: flex;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.header-title {
  flex: 1;
  padding: 12px;
  text-align: center;
  font-weight: bold;
}

.sort-icon {
  margin-left: 4px;
  opacity: 0.7;
}

.user-avatar {
  width: 40px;
  height: 40px;
  border-radius: 20px;
}

.status-badge {
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 12px;
  color: white;
}

.status-active {
  background-color: #52c41a;
}

.status-inactive {
  background-color: #faad14;
}

.status-banned {
  background-color: #f5222d;
}

.action-buttons {
  display: flex;
  gap: 8px;
}

.edit-btn,
.delete-btn {
  padding: 4px 8px;
  font-size: 12px;
  border-radius: 4px;
  border: none;
}

.edit-btn {
  background-color: #1890ff;
  color: white;
}

.delete-btn {
  background-color: #ff4d4f;
  color: white;
}

.footer-text {
  color: #666;
  font-size: 14px;
}

.custom-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 40px;
}

.empty-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.empty-message {
  color: #999;
  margin-bottom: 16px;
}

.add-btn {
  padding: 8px 16px;
  background-color: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
}
</style>