组件封装是前端开发的核心技能,但很多人不知道什么时候该封装、怎么设计 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、样式的设计决策。
如果这篇文章对你有帮助,请点赞、收藏、转发!