【AI 编程实战】第 8 篇:组件封装的艺术 - 从业务代码到可复用组件

2,003 阅读14分钟

组件封装是前端开发的核心技能,但很多人不知道什么时候该封装、怎么设计 Props、如何处理复杂逻辑。这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,从业务代码中提炼出可复用、可维护、灵活扩展的组件。

系列专栏【AI 编程实战:TRAE SOLO 全栈开发指南】

本篇主题:组件封装的艺术 - 从业务代码到可复用组件

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:什么时候该封装组件

1.1 新手常见的误区

误区一:过度封装

<!-- ❌ 只用一次的代码,没必要封装 -->
<MyPageTitle title="设置" />

<!-- 直接写就行 -->
<text class="text-lg font-bold">设置</text>

误区二:不敢封装

<!-- ❌ 同样的代码复制粘贴 5 遍 -->
<view class="flex items-center gap-2 bg-white rounded-xl p-4 pr-2">
  <view class="text-xs font-bold rounded-sm px-2 py-1" :style="{ backgroundColor: '#F3E8FF', color: '#8B5CF6' }">
    高情商
  </view>
  <text class="text-sm text-#1F2937 flex-1">回复内容...</text>
  <view class="w-9 h-9 flex items-center justify-center" @click="handleCopy">
    <image src="/static/images/copy.svg" class="w-5 h-5" />
  </view>
</view>

1.2 何时应该封装组件

场景是否封装原因
同样的 UI 出现 3 次以上✅ 封装避免重复,统一修改
复杂的交互逻辑✅ 封装隔离复杂度,便于测试
需要在多个页面使用✅ 封装提高复用性
只用一次的简单 UI❌ 不封装过度工程化
可能变化的业务逻辑⚠️ 谨慎考虑抽象成本

1.3 本文要封装的组件

项目中需要以下组件:

  • XButton:通用按钮,支持 loading、微信能力
  • ReplyCard:回复卡片,根据语气自动分配颜色
  • Modal:通用弹窗,支持动画、自定义内容
  • ActionSheetPicker:底部选择器,支持滚动选择
  • MenuItem:菜单项,支持箭头、自定义图标

二、从业务需求开始:XButton 按钮组件

2.1 我的需求

我:项目里很多按钮长得差不多,都是渐变背景、圆角、loading 效果。
    有些按钮还要用微信的 open-type(获取手机号、分享等)。
    能不能封装一个通用按钮?

2.2 AI 询问细节

AI:好的,我来帮你设计按钮组件。先确认几个问题:

🤔 问题:
1. 按钮的默认样式是什么?(颜色、大小、圆角)
2. 需要支持哪些微信 open-type?
3. loading 状态怎么表现?(文字变化、加载图标)
4. 是否需要禁用状态?

这些会影响 Props 的设计。

2.3 我补充信息

我:默认样式:渐变紫色背景(#B385F7 到 #6F6EE8),702rpx 宽,92rpx 高,圆角 16rpx。
    open-type:需要 getPhoneNumber、share、contact 这几个。
    loading:显示一个旋转的小圆圈,按钮变灰。
    需要禁用状态。

2.4 AI 设计 Props

AI:明白了,我来设计 Props 结构:

📐 Props 设计:

interface Props {
  text?: string;          // 按钮文字
  loading?: boolean;      // 加载状态
  disabled?: boolean;     // 禁用状态
  buttonStyle?: object;   // 自定义样式(覆盖默认)
  openType?: string;      // 微信开放能力
  btnClass?: string;      // 自定义 class
}

📐 Events 设计:

click         // 点击事件
getphonenumber  // 获取手机号回调
getUserInfo   // 获取用户信息回调
contact       // 客服消息回调
error         // 错误回调

🎯 设计要点:
1. buttonStyle 用 computed 合并默认样式,支持覆盖
2. loading/disabled 时阻止点击,样式变灰
3. 使用 slot 支持自定义内容(不只是文字)
4. 微信事件直接透传给父组件

开始生成代码?

2.5 AI 生成组件

<!-- src/components/XButton.vue -->
<template>
  <button
    :disabled="disabled || loading"
    :style="buttonStyle"
    :open-type="openType"
    :class="[
      btnClass,
      'relative flex items-center justify-center outline-none font-bold text-white border-0 transition-opacity duration-200',
      { 'opacity-70 cursor-not-allowed': disabled || loading },
    ]"
    @click="handleClick"
    @getphonenumber="$emit('getphonenumber', $event)"
    @getUserInfo="$emit('getUserInfo', $event)"
    @contact="$emit('contact', $event)"
    @error="$emit('error', $event)"
  >
    <span class="inline-flex items-center gap-2">
      <span v-if="loading" class="x-btn-spinner" />
      <slot>{{ text }}</slot>
    </span>
  </button>
</template>

<script setup lang="ts">
import { computed } from 'vue';

interface Props {
  text?: string;
  loading?: boolean;
  disabled?: boolean;
  buttonStyle?: any;
  openType?: 'share' | 'getUserInfo' | 'contact' | 'getPhoneNumber' | 'launchApp' | 'openSetting' | 'feedback';
  btnClass?: string;
}

const props = withDefaults(defineProps<Props>(), {
  text: '',
  loading: false,
  disabled: false,
  buttonStyle: () => ({}),
  openType: undefined,
  btnClass: '',
});

const emit = defineEmits<{
  click: [];
  getphonenumber: [event: any];
  getUserInfo: [event: any];
  contact: [event: any];
  error: [event: any];
}>();

// 默认样式
const originButtonStyle = {
  width: '702rpx',
  height: '92rpx',
  borderRadius: '16rpx',
  background: 'linear-gradient(80deg, #B385F7 1%, #817FF9 50%, #6F6EE8 99%)',
  color: '#fff',
  border: 'none',
  fontWeight: 'bold',
  fontSize: '32rpx',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  padding: '20rpx',
  boxSizing: 'border-box',
};

// 合并自定义样式
const buttonStyle = computed(() => ({
  ...originButtonStyle,
  ...props.buttonStyle,
}));

const handleClick = () => {
  if (!props.disabled && !props.loading) {
    emit('click');
  }
};
</script>

<style scoped>
/* 清除小程序按钮默认边框 */
button::after {
  display: none !important;
}

/* #ifdef MP-WEIXIN */
button::after {
  border: none !important;
  background: transparent !important;
}
/* #endif */

/* Loading 动画 */
.x-btn-spinner {
  width: 24rpx;
  height: 24rpx;
  border-radius: 999px;
  border: 4rpx solid rgba(255, 255, 255, 0.4);
  border-top-color: #ffffff;
  animation: x-btn-spin 0.8s linear infinite;
}

@keyframes x-btn-spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

2.6 我追问:样式覆盖的设计

我:为什么用 buttonStyle 而不是直接用 class 覆盖?
AI:两种方式各有优劣:

📊 对比分析:

【class 方式】
✅ 优点:符合 CSS 习惯,优先级可控
❌ 缺点:小程序里 style 优先级高于 class,难以覆盖

【buttonStyle 方式】
✅ 优点:style 优先级最高,一定能覆盖
✅ 优点:可以动态计算,支持响应式样式
❌ 缺点:写法略繁琐

📌 结论:
在小程序环境下,推荐 buttonStyle 方式,因为:
1. 确保自定义样式一定生效
2. 支持动态样式(如根据状态变色)
3. 配合 computed 实现样式合并

当然也保留了 btnClass,用于添加额外的 UnoCSS 原子类。

2.7 使用示例

<!-- 基础用法 -->
<XButton text="立即登录" @click="handleLogin" />

<!-- Loading 状态 -->
<XButton text="提交中..." :loading="isSubmitting" @click="handleSubmit" />

<!-- 微信获取手机号 -->
<XButton
  text="手机号快捷登录"
  open-type="getPhoneNumber"
  @getphonenumber="handlePhoneNumber"
/>

<!-- 自定义样式 -->
<XButton
  text="确认"
  :button-style="{ width: '100%', height: '80rpx' }"
  @click="handleConfirm"
/>

<!-- 使用 slot -->
<XButton @click="handleShare">
  <image src="/static/share.svg" class="w-4 h-4 mr-2" />
  分享给好友
</XButton>

三、智能配色:ReplyCard 回复卡片

3.1 我的需求

我:首页有个回复列表,每个回复有"语气标签"和"回复内容"。
    标签颜色想根据语气类型自动变化,比如"高情商"是紫色,"幽默"是青色。
    但我不想手动维护颜色映射表,太麻烦了。

3.2 AI 给出方案

AI:可以用哈希算法自动分配颜色。

🔍 设计思路:
1. 预设 5 种颜色组合(背景色 + 文字色)
2. 根据语气文字的字符码计算哈希值
3. 哈希值对颜色数量取模,得到颜色索引

这样的好处:
1. 同样的语气文字永远是同一个颜色
2. 不需要维护映射表
3. 新增语气类型自动分配颜色

📐 Props 设计:
- tone: string      // 语气标签文字
- text: string      // 回复内容
- hideTag?: boolean // 是否隐藏标签

3.3 AI 生成组件

<!-- src/pages/index/components/ReplyCard.vue -->
<template>
  <view class="reply-card flex items-center gap-2.5 bg-white rounded-xl p-4 pr-2">
    <!-- 标签(支持隐藏,但保留 DOM) -->
    <view
      v-show="!hideTag"
      class="tag text-xs font-bold rounded-sm px-2 py-1 flex-shrink-0"
      :style="{ backgroundColor: tagStyle.bg, color: tagStyle.text }"
    >
      {{ tone }}
    </view>

    <!-- 回复文本 -->
    <view class="text-content text-sm text-#1F2937 flex-1">
      {{ text }}
    </view>

    <!-- 复制按钮 -->
    <view
      class="copy-btn cursor-pointer w-9 h-9 flex items-center justify-center"
      @click="onCopy"
    >
      <image src="/static/images/home/copy.svg" mode="aspectFit" class="w-5 h-5" />
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed } from 'vue';

interface Props {
  tone: string;
  text: string;
  hideTag?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  hideTag: false,
});

const emit = defineEmits(['copy']);

// 5 种预设颜色
const COLORS = [
  { bg: '#F3E8FF', text: '#8B5CF6' },  // 紫色
  { bg: '#E6FFFA', text: '#319795' },  // 青色
  { bg: '#FFF5E6', text: '#F56500' },  // 橙色
  { bg: '#FFE4E6', text: '#E11D48' },  // 粉色
  { bg: '#E0F2FE', text: '#0284C7' },  // 蓝色
];

// 根据语气文字哈希分配颜色
const tagStyle = computed(() => {
  const tone = props.tone || '';
  let hash = 0;
  for (let i = 0; i < tone.length; i += 1) {
    hash = (hash + tone.charCodeAt(i)) % COLORS.length;
  }
  return COLORS[hash];
});

const onCopy = () => {
  emit('copy', props.text);
};
</script>

3.4 我追问设计决策

我:为什么用 v-show 而不是 v-if 隐藏标签?
AI:这是有意为之的设计:

【v-if vs v-show】

v-if:
- 完全移除 DOM 元素
- 会影响布局计算
- 适合"要么显示要么不存在"的场景

v-show:
- 只是 display: none,DOM 元素还在
- 不影响布局结构
- 适合"可能会再显示"的场景

📌 这里用 v-show 的原因:
1. 保持卡片布局一致性(有标签和无标签的卡片对齐)
2. 动态切换显示/隐藏时不会闪烁
3. 性能更好(不需要重新创建 DOM)

如果你的场景是"完全不需要标签",也可以改成 v-if。

3.5 使用示例

<template>
  <view class="space-y-3">
    <ReplyCard
      v-for="reply in replies"
      :key="reply.id"
      :tone="reply.tone"
      :text="reply.content"
      @copy="handleCopy"
    />
  </view>
</template>

<script setup>
const handleCopy = async (text) => {
  await uni.setClipboardData({ data: text });
  toast.success('已复制');
};
</script>

四、通用弹窗:Modal 组件

4.1 我的需求

我:项目里有很多弹窗,比如确认框、信息展示、表单弹窗。
    u-popup 不太够用,想封装一个更通用的 Modal。
    要有动画效果,支持自定义内容。

4.2 AI 设计方案

AI:我来设计一个通用 Modal 组件。

📐 Props 设计:

interface Props {
  modelValue: boolean;       // 控制显示(v-model)
  closeOnClickMask?: boolean; // 点击遮罩关闭
  rootZIndex?: number;       // 层级
  width?: string;            // 弹窗宽度
  customStyle?: object;      // 自定义样式
  safeAreaInsetBottom?: boolean; // 安全区适配
}

📐 Events:

update:modelValue  // v-model 更新
close              // 关闭事件

🎯 设计要点:
1. 使用 v-model 双向绑定,符合 Vue 习惯
2. 动画分两阶段:先渲染 DOM,再触发过渡
3. slot 支持任意内容
4. 遮罩和内容分层,便于控制样式

4.3 AI 生成组件

<!-- src/components/Modal.vue -->
<template>
  <view
    v-if="visible"
    class="modal__root"
    :class="{
      'modal__root--show': showContent,
      'modal__root--safe-area': safeAreaInsetBottom
    }"
  >
    <!-- 遮罩层 -->
    <view class="modal__mask" @click="handleMaskClick" />

    <!-- 内容区域 -->
    <view class="modal__container" :style="containerStyle">
      <slot />
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue';

interface Props {
  modelValue: boolean;
  closeOnClickMask?: boolean;
  rootZIndex?: number;
  width?: string;
  customStyle?: Record<string, any>;
  safeAreaInsetBottom?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  closeOnClickMask: false,
  rootZIndex: 10020,
  width: '700rpx',
  customStyle: () => ({}),
  safeAreaInsetBottom: false,
});

const emit = defineEmits<{
  'update:modelValue': [value: boolean];
  close: [];
}>();

// 双向绑定
const visible = computed({
  get: () => props.modelValue,
  set: (value: boolean) => emit('update:modelValue', value),
});

// 控制动画
const showContent = ref(false);

// 监听显示状态,控制动画时序
watch(
  () => props.modelValue,
  async (newVal) => {
    if (newVal) {
      // 打开:先显示 DOM,再触发动画
      await nextTick();
      setTimeout(() => {
        showContent.value = true;
      }, 10);
    } else {
      // 关闭:先触发动画,再隐藏 DOM
      showContent.value = false;
    }
  },
  { immediate: true }
);

// 点击遮罩
const handleMaskClick = () => {
  if (!props.closeOnClickMask) return;
  emit('close');
  visible.value = false;
};

// 容器样式
const containerStyle = computed(() => ({
  width: props.width,
  ...props.customStyle,
}));
</script>

<style scoped lang="scss">
.modal__root {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: v-bind(rootZIndex);
  opacity: 0;
  transition: opacity 0.3s ease;
}

.modal__root--show {
  opacity: 1;

  .modal__mask {
    opacity: 1;
  }

  .modal__container {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

.modal__mask {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  opacity: 0;
  transition: opacity 0.3s ease;
}

.modal__container {
  position: relative;
  z-index: calc(v-bind(rootZIndex) + 1);
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  overflow: hidden;
  opacity: 0;
  transform: scale(0.9) translateY(-20px);
  transition: transform 0.3s ease, opacity 0.3s ease;
}
</style>

4.4 我追问动画实现

我:为什么动画要分两阶段?直接用 CSS transition 不行吗?
AI:直接用 transition 有问题,需要分阶段处理。

🔍 问题分析:

【错误方式】直接设置 v-if + transition
- v-if 切换时,DOM 还没渲染完,transition 就触发了
- 导致动画不生效或闪烁

【正确方式】分两阶段
阶段1:v-if 让 DOM 渲染(opacity: 0)
阶段2:nextTick + setTimeout 后,添加 show 类触发动画

📐 时序图:
props.modelValue = true
    ↓
v-if 渲染 DOM(不可见)
    ↓
await nextTick(DOM 已挂载)
    ↓
setTimeout 10ms(确保浏览器已绘制)
    ↓
showContent = true(触发 CSS 过渡)
    ↓
动画效果显示

关闭时相反:
showContent = false(触发退出动画)
    ↓
等动画结束后
    ↓
v-if 移除 DOM

4.5 使用示例

<template>
  <!-- 确认弹窗 -->
  <Modal v-model="showConfirm" :close-on-click-mask="true" @close="handleClose">
    <view class="bg-white rounded-xl p-6">
      <text class="block text-lg font-bold mb-4">确认删除?</text>
      <text class="block text-gray-500 mb-6">删除后无法恢复</text>
      <view class="flex gap-4">
        <XButton text="取消" :button-style="cancelStyle" @click="showConfirm = false" />
        <XButton text="确认" @click="handleConfirm" />
      </view>
    </view>
  </Modal>
</template>

五、复杂交互:ActionSheetPicker 选择器

5.1 我的需求

我:需要一个底部滚动选择器,类似日期选择器那种效果。
    要支持确认/取消按钮,选项滚动时有渐隐效果。

5.2 AI 分析复杂度

AI:这是一个交互较复杂的组件,需要处理几个关键点:

🔍 复杂度分析:

1. 选项格式统一
   - 用户可能传 ['选项1', '选项2'](字符串数组)
   - 也可能传 [{ label: '选项1', value: 1 }](对象数组)
   - 需要归一化处理

2. 边界情况
   - 选项列表为空
   - defaultIndex 超出范围
   - 快速切换开关状态

3. 动画协调
   - 遮罩淡入
   - 面板滑入
   - 关闭时先动画再隐藏 DOM

4. 视觉效果
   - 当前选项高亮
   - 远离中心的选项渐隐

📐 Props 设计:

interface Props {
  modelValue: boolean;        // v-model 控制显示
  options: Array<OptionItem | string>;  // 选项列表
  defaultIndex?: number;      // 默认选中索引
  cancelText?: string;        // 取消按钮文字
  confirmText?: string;       // 确认按钮文字
  overlayClosable?: boolean;  // 点击遮罩关闭
  showHeader?: boolean;       // 是否显示头部
}

5.3 AI 生成关键代码

// 选项格式归一化
const normalizedOptions = computed<OptionItem[]>(() => {
  return (props.options || []).map((item) =>
    typeof item === 'string'
      ? { label: item, value: item }
      : { label: item.label, value: item.value }
  );
});

// 边界处理:确保索引在有效范围内
const clampIndex = (idx: unknown, fallback = 0) => {
  if (!normalizedOptions.value.length) return 0;
  const numericIdx = Number(idx);
  const base = Number.isFinite(numericIdx) ? numericIdx : Number(fallback) || 0;
  if (base < 0) return 0;
  if (base >= normalizedOptions.value.length) {
    return normalizedOptions.value.length - 1;
  }
  return Math.round(base);
};

// 渐隐效果
const baseColor = [48, 49, 51];
const opacityByDistance = [1, 0.75, 0.55, 0.35, 0.18];

const getItemStyle = (idx: number) => {
  const distance = Math.abs(idx - internalIndex.value);
  const opacity = opacityByDistance[Math.min(distance, opacityByDistance.length - 1)];
  return {
    color: `rgba(${baseColor.join(',')}, ${opacity})`,
  };
};

5.4 我追问设计决策

我:为什么要用 clampIndex 函数?直接用 Math.min/max 不行吗?
AI:clampIndex 处理了更多边界情况:

📊 对比:

【简单方式】
const index = Math.min(Math.max(defaultIndex, 0), options.length - 1);
// 问题:
// - defaultIndex 是 undefined → NaN
// - defaultIndex 是 '2'(字符串)→ 可能出错
// - options 为空 → -1

【clampIndex 方式】
1. 处理 undefined/null → 使用 fallback
2. 处理字符串数字 → Number() 转换
3. 处理空数组 → 返回 0
4. 处理小数 → Math.round() 取整
5. 处理负数 → 返回 0
6. 处理超限 → 返回最大有效索引

📌 结论:
复杂组件要考虑各种"奇怪的输入",防御性编程很重要。
用户可能传错误类型的 props,组件不应该崩溃。

5.5 使用示例

<template>
  <!-- 性别选择 -->
  <ActionSheetPicker
    v-model="showGenderPicker"
    :options="genderOptions"
    :default-index="defaultGenderIndex"
    @confirm="handleGenderConfirm"
  />
</template>

<script setup>
const genderOptions = [
  { label: '男', value: 1 },
  { label: '女', value: 2 },
];

const handleGenderConfirm = (option, index) => {
  console.log('选择了:', option.label, option.value);
};
</script>

六、配置驱动:VipCard 业务组件

6.1 我的需求

我:有个 VIP 卡片,根据用户状态显示不同内容:
    - 未登录:显示"免费赠送 3 次"
    - 已登录未开通:显示"剩余 x/3 次"
    - VIP 用户:显示"有效期至 xx"
    - 已过期:显示"会员已过期"

    每种状态的按钮文字也不一样。

6.2 AI 推荐配置驱动模式

AI:这种多状态场景,推荐用"配置驱动"模式。

🔍 设计思路:
不是用 v-if 写 4 套 UI,而是用 computed 返回配置对象,
模板根据配置渲染,逻辑和 UI 分离。

📐 配置结构:

interface VipCardConfig {
  title: string;           // 主标题
  subtitle?: string;       // 副标题
  buttonText: string;      // 按钮文字
  showExpireTip?: boolean; // 是否显示到期说明
}

📐 配置逻辑(computed):
根据 vipInfo.status 返回不同配置

6.3 AI 生成关键代码

// VIP 卡片配置(配置驱动模式)
const cardConfig = computed<VipCardConfig>(() => {
  const { status, expireTime, remainingTimes } = props.vipInfo;

  // 未登录或未开通 VIP
  if (status === 'none') {
    if (!props.isLoggedIn) {
      return {
        title: '免费赠送3次生成次数',
        subtitle: '首次登录即可获得',
        buttonText: '立即体验',
        showExpireTip: false,
      };
    }
    return {
      title: `剩余${remainingTimes || 0}/3次免费生成次数`,
      subtitle: '开通VIP,解锁畅享超值特权',
      buttonText: '立即开通',
      showExpireTip: false,
    };
  }

  // 已过期
  if (status === 'expired') {
    return {
      title: '开通会员,尊享VIP特权',
      subtitle: '你的会员已过期,请重新开通',
      buttonText: '立即开通',
      showExpireTip: false,
    };
  }

  // VIP 用户
  if (status === 'vip') {
    return {
      title: '恭喜您,成为VIP会员',
      subtitle: expireTime ? `有效期至${expireTime}` : undefined,
      buttonText: '立即续费',
      showExpireTip: Boolean(expireTime),
    };
  }

  // 默认配置(兜底)
  return {
    title: '剩余0/3次免费生成次数',
    subtitle: '开通VIP,解锁畅享超值特权',
    buttonText: '立即开通',
    showExpireTip: false,
  };
});

6.4 模板使用配置

<template>
  <view class="vip-card relative rounded-xl overflow-hidden">
    <!-- 内容区域 -->
    <view class="relative z-10 px-3 py-4 flex items-center justify-between h-full">
      <!-- 左侧内容 -->
      <view class="flex items-center flex-1">
        <image src="/static/images/my/member.png" class="w-10 h-10 mr-3" />
        <view class="flex-1">
          <!-- 使用配置的 title -->
          <text class="block text-30rpx font-medium text-#402F1B mb-10rpx">
            {{ cardConfig.title }}
          </text>
          <!-- 使用配置的 subtitle -->
          <view v-if="cardConfig.subtitle" class="text-xs text-#7E5D34">
            <text>{{ cardConfig.subtitle }}</text>
            <image
              v-if="cardConfig.showExpireTip"
              src="/static/images/my/info.svg"
              class="w-4 h-4 ml-1"
              @click.stop="openVipInfoModal"
            />
          </view>
        </view>
      </view>

      <!-- 右侧按钮(使用配置的 buttonText) -->
      <view class="rounded-full px-4.5 py-2 bg-#3D3D3D/90" @click="handleUpgrade">
        <text class="text-sm text-#FFF5E2">{{ cardConfig.buttonText }}</text>
      </view>
    </view>
  </view>
</template>

6.5 配置驱动 vs 条件渲染

AI:为什么推荐配置驱动而不是条件渲染?

📊 对比:

【条件渲染方式】
<view v-if="status === 'none' && !isLoggedIn">
  <text>免费赠送3次...</text>
  <button>立即体验</button>
</view>
<view v-else-if="status === 'none' && isLoggedIn">
  <text>剩余x/3次...</text>
  <button>立即开通</button>
</view>
<view v-else-if="status === 'expired'">
  ...
</view>
<!-- 4 套几乎一样的 UI -->

❌ 问题:
- 重复代码多
- 修改 UI 要改 4 处
- 逻辑和 UI 耦合

【配置驱动方式】
<text>{{ cardConfig.title }}</text>
<button>{{ cardConfig.buttonText }}</button>

✅ 优点:
- UI 代码只有一份
- 修改 UI 只需改一处
- 逻辑集中在 computed
- 容易新增状态

七、组件封装的核心经验

7.1 Props 设计原则

原则说明示例
必填 vs 可选核心数据必填,配置项可选text 必填,loading 可选
默认值合理不传也能正常工作loading: false
类型严格使用 TypeScript 定义openType?: 'share' | 'getPhoneNumber'
命名清晰看名字就知道作用closeOnClickMask 而非 maskClose

7.2 Events 设计原则

// ✅ 推荐:事件透传,让父组件决定处理逻辑
const emit = defineEmits(['copy']);
const onCopy = () => {
  emit('copy', props.text);  // 只通知,不处理
};

// ❌ 不推荐:组件内部处理业务逻辑
const onCopy = () => {
  uni.setClipboardData({ data: props.text });
  toast.success('已复制');  // 组件不该管 toast
};

7.3 样式设计原则

<!-- ✅ 推荐:提供自定义入口 -->
<view :class="btnClass" :style="buttonStyle">

<!-- ❌ 不推荐:写死样式无法覆盖 -->
<view class="w-702rpx h-92rpx bg-gradient-to-r from-purple-400 to-blue-500">

7.4 复杂逻辑处理

// ✅ 推荐:配置驱动,逻辑集中
const cardConfig = computed(() => {
  if (status === 'vip') return { title: 'VIP用户', buttonText: '续费' };
  if (status === 'expired') return { title: '已过期', buttonText: '开通' };
  return { title: '未开通', buttonText: '立即开通' };
});

// ❌ 不推荐:模板里写大量 v-if
<text v-if="status === 'vip'">VIP用户</text>
<text v-else-if="status === 'expired'">已过期</text>
<text v-else>未开通</text>

八、总结:组件封装的方法论

8.1 封装决策流程

需要封装吗?
    ├── 复用 3 次以上 → ✅ 封装
    ├── 逻辑复杂 → ✅ 封装
    ├── 多页面使用 → ✅ 封装
    └── 只用一次 → ❌ 不封装

怎么设计 Props?
    ├── 数据类 → 必填,类型严格
    ├── 配置类 → 可选,有默认值
    └── 样式类 → 提供自定义入口

怎么处理事件?
    ├── 简单事件 → 直接 emit
    └── 复杂交互 → 暴露方法 + emit

怎么处理样式?
    ├── 默认样式 → 写在组件内
    ├── 自定义样式 → buttonStyle/customStyle
    └── 额外样式 → btnClass slot

8.2 本文封装的组件清单

组件类型核心功能设计要点
XButton通用组件按钮 + 微信能力样式合并、事件透传、slot
ReplyCard业务组件回复卡片哈希配色、事件透传
Modal通用组件弹窗容器v-model、动画分阶段
ActionSheetPicker通用组件底部选择器格式归一化、边界处理
VipCard业务组件VIP 状态卡片配置驱动

8.3 下一篇预告

《【AI 编程实战】第 9 篇:Hooks 封装的艺术 - 让逻辑复用更优雅》

下一篇展示如何封装自定义 Hooks:

  • 从重复代码中提取 Hooks
  • useRequireLogin:登录检查 Hook
  • useClipboard:剪贴板操作 Hook
  • 组合式 API 的最佳实践

组件封装不是"把代码挪到另一个文件",而是设计可复用、可维护的抽象。 通过和 AI 对话,逐步理清 Props、Events、样式的设计决策。

如果这篇文章对你有帮助,请点赞、收藏、转发!