【Vue3 Props+Slots+Composable】中后台组件复用实战:从复用边界到落地选型,彻底搞懂组件化架构设计,避开过度抽象与维护泥潭!

📑 文章目录
- 一、写在前面:为什么你会觉得"复用"很乱?
- 二、先给结论:三种复用方式怎么选?
- 三、基础扫盲:三种方式分别是什么?
- 1)Props:组件的"可配置参数"
- [2)插槽 Slots:组件的"可插拔区域"](#2)插槽 Slots:组件的“可插拔区域”)
- [3)组合式函数 Composable:抽离"逻辑能力"](#3)组合式函数 Composable:抽离“逻辑能力”)
- 四、实战选型:一个需求,三种方式如何配合?
- 五、7年前端也容易踩的"习惯坑"
- 六、可落地的团队规范(可直接抄到项目规范)
- 七、给新手的"傻瓜决策树"
- 八、总结
- [🔍 系列模块导航](#🔍 系列模块导航)
- [📝 组件化设计基础](#📝 组件化设计基础)
- [📚 系列总览](#📚 系列总览)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构。
面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维。
这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。
帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。
一、写在前面:为什么你会觉得"复用"很乱?
很多同学(包括工作多年的前端)写 Vue3 时都会遇到这几个现象:
- 组件越来越"大",一个组件里什么都做
- 同样的逻辑在多个页面复制粘贴
- 看到
props、slot、useXxx都能复用,但不知道怎么选 - 后期改需求时,一改就牵一堆地方,心里发慌
根因通常不是"不会写代码",而是复用边界没想清楚:
你到底是在复用"数据配置"、复用"界面骨架"、还是复用"状态逻辑"?
[⬆ 返回目录](#⬆ 返回目录)
二、先给结论:三种复用方式怎么选?
先记住一句话:
- Props:复用"参数化差异"(同一组件,不同配置)
- 插槽(Slots):复用"结构占位"(父组件决定某块长什么样)
- 组合式函数(Composable):复用"状态 + 行为逻辑"(跨组件共享能力)
选型速查表(建议收藏)
| 场景 | 优先方案 | 原因 |
|---|---|---|
| 同一组件只是标题、颜色、大小等不同 | Props | 成本最低,语义清晰 |
| 组件框架固定,但某些区域 UI 经常变化(header/footer/item) | Slots | 保留骨架,开放局部结构 |
| 多个组件都要用同一套请求、分页、校验、倒计时等逻辑 | Composable | 把逻辑抽离,复用最稳定 |
| 页面差异是"结构 + 逻辑"都变化大 | Slots + Composable | 结构交给插槽,行为交给组合函数 |
| 只想省事,先"复制一份改改" | 不建议 | 短期快,长期维护成本高 |
[⬆ 返回目录](#⬆ 返回目录)
三、基础扫盲:三种方式分别是什么?
1)Props:组件的"可配置参数"
你可以把组件想成一个函数,props 就是函数入参。
示例:通用按钮组件
html
<!-- BaseButton.vue -->
<script setup lang="ts">
interface Props {
type?: 'primary' | 'default' | 'danger'
loading?: boolean
disabled?: boolean
}
withDefaults(defineProps<Props>(), {
type: 'default',
loading: false,
disabled: false
})
const emit = defineEmits<{
(e: 'click'): void
}>()
function onClick() {
emit('click')
}
</script>
<template>
<button
class="base-btn"
:class="[`base-btn--${type}`]"
:disabled="disabled || loading"
@click="onClick"
>
<span v-if="loading">加载中...</span>
<span v-else>
<slot>按钮</slot>
</span>
</button>
</template>
<style scoped>
.base-btn { padding: 8px 14px; border-radius: 6px; border: 1px solid #ddd; cursor: pointer; }
.base-btn--primary { background: #1677ff; color: #fff; border-color: #1677ff; }
.base-btn--default { background: #fff; color: #333; }
.base-btn--danger { background: #ff4d4f; color: #fff; border-color: #ff4d4f; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
</style>
使用:
html
<BaseButton type="primary" @click="save">保存</BaseButton>
<BaseButton type="danger" :loading="submitting">删除</BaseButton>
Props 常见坑
- 坑1:直接改 props
props.xxx = ...会报错(单向数据流)- 需要本地可改副本时,用
ref(props.xxx)或computed映射
- 坑2:不写默认值
- 导致业务处处判空,建议
withDefaults
- 导致业务处处判空,建议
- 坑3:Boolean 类型传值混乱
disabled写成disabled="false"实际是字符串,可能被当真值
[⬆ 返回目录](#⬆ 返回目录)
2)插槽 Slots:组件的"可插拔区域"
插槽不是"传值",而是"传模板结构"。
示例:通用卡片组件(结构固定,内容可变)
html
<!-- BaseCard.vue -->
<template>
<section class="base-card">
<header class="base-card__header">
<slot name="header">
<h3>默认标题</h3>
</slot>
</header>
<main class="base-card__body">
<slot />
</main>
<footer class="base-card__footer" v-if="$slots.footer">
<slot name="footer" />
</footer>
</section>
</template>
<style scoped>
.base-card { border: 1px solid #eee; border-radius: 8px; padding: 16px; background: #fff; }
.base-card__header { margin-bottom: 12px; }
.base-card__footer { margin-top: 12px; text-align: right; }
</style>
使用:
html
<BaseCard>
<template #header>
<div style="display:flex;justify-content:space-between;">
<h3>订单信息</h3>
<span>待支付</span>
</div>
</template>
<p>订单号:20260326001</p>
<p>金额:199.00</p>
<template #footer>
<BaseButton type="primary">去支付</BaseButton>
</template>
</BaseCard>
插槽常见坑
- 坑1:所有内容都塞插槽
- 会把组件变成"空壳",失去语义边界
- 坑2:作用域插槽变量命名混乱
- 建议统一:
item、row、data等可读命名
- 建议统一:
- 坑3:不知道什么时候用默认插槽,什么时候用具名插槽
- 默认插槽:主体内容
- 具名插槽:header/footer/empty 等语义明确区域
[⬆ 返回目录](#⬆ 返回目录)
3)组合式函数 Composable:抽离"逻辑能力"
组合式函数最适合复用"有状态的行为逻辑",例如:搜索、分页、请求加载态、倒计时。
示例:封装列表请求逻辑 useUserList
ts
// composables/useUserList.ts
import { ref } from 'vue'
interface UserItem {
id: number
name: string
email: string
}
interface QueryParams {
keyword: string
page: number
pageSize: number
}
interface ApiResult {
list: UserItem[]
total: number
}
// 模拟请求
function mockFetchUsers(params: QueryParams): Promise<ApiResult> {
return new Promise((resolve) => {
setTimeout(() => {
const all = Array.from({ length: 53 }, (_, i) => ({
id: i + 1,
name: `用户${i + 1}`,
email: `user${i + 1}@test.com`
}))
const filtered = all.filter(u => u.name.includes(params.keyword))
const start = (params.page - 1) * params.pageSize
const end = start + params.pageSize
resolve({
list: filtered.slice(start, end),
total: filtered.length
})
}, 500)
})
}
export function useUserList() {
const loading = ref(false)
const list = ref<UserItem[]>([])
const total = ref(0)
const query = ref<QueryParams>({
keyword: '',
page: 1,
pageSize: 10
})
async function fetchList() {
loading.value = true
try {
const res = await mockFetchUsers(query.value)
list.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}
function onSearch(keyword: string) {
query.value.keyword = keyword
query.value.page = 1
fetchList()
}
function onPageChange(page: number) {
query.value.page = page
fetchList()
}
return {
loading,
list,
total,
query,
fetchList,
onSearch,
onPageChange
}
}
页面使用:
html
<!-- UserListPage.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useUserList } from '@/composables/useUserList'
const keywordInput = ref('')
const { loading, list, total, query, fetchList, onSearch, onPageChange } = useUserList()
onMounted(fetchList)
</script>
<template>
<div>
<input v-model="keywordInput" placeholder="输入用户名搜索" />
<button @click="onSearch(keywordInput)">搜索</button>
<p v-if="loading">加载中...</p>
<ul v-else>
<li v-for="item in list" :key="item.id">
{{ item.name }} - {{ item.email }}
</li>
</ul>
<div style="margin-top:12px;">
<button :disabled="query.page <= 1" @click="onPageChange(query.page - 1)">上一页</button>
<span style="margin:0 8px;">第 {{ query.page }} 页 / 共 {{ Math.ceil(total / query.pageSize) }} 页</span>
<button :disabled="query.page >= Math.ceil(total / query.pageSize)" @click="onPageChange(query.page + 1)">下一页</button>
</div>
</div>
</template>
Composable 常见坑
- 坑1:在函数外定义响应式状态,导致意外共享
- 每次调用要独立实例,就把
ref/reactive放在函数内部
- 每次调用要独立实例,就把
- 坑2:命名太泛(
useData、useCommon)- 后期根本不知道干嘛,建议业务语义命名:
useUserList、useCountdown
- 后期根本不知道干嘛,建议业务语义命名:
- 坑3:既做数据请求,又改 UI 展示细节
- Composable 负责逻辑,UI 细节尽量留在组件层
[⬆ 返回目录](#⬆ 返回目录)
四、实战选型:一个需求,三种方式如何配合?
需求:做一个"商品列表卡片"模块
- 卡片样式统一
- 每个业务线头部操作区不同
- 列表查询和分页逻辑可复用
推荐设计
- 卡片容器:
BaseCard(Slots) - 商品项配置:
ProductItem(Props) - 列表请求逻辑:
useProductList(Composable)
这就是典型的:
- Props 管参数
- Slots 管结构
- Composable 管逻辑
比起"一个超级组件全包",这种拆法更稳、更容易维护和测试。
[⬆ 返回目录](#⬆ 返回目录)
五、7年前端也容易踩的"习惯坑"
- 把复用理解成"少写代码"
- 真正目标是"降低变更成本",不是追求抽象炫技
- 过度提前抽象
- 还没出现第二个真实场景,就抽一堆
BaseXXX
- 还没出现第二个真实场景,就抽一堆
- 组件职责不清
- 组件同时负责请求、权限、UI、埋点,最后没人敢改
- 把 slot 当万能胶
- 到处开洞,后面无法约束
- Composable 无边界增长
- 一个
usePage500 行,逐渐变成"新型大泥球"
- 一个
[⬆ 返回目录](#⬆ 返回目录)
六、可落地的团队规范(可直接抄到项目规范)
-
先判断复用类型,再选技术方案
- 参数差异优先 Props
- 结构差异优先 Slots
- 行为逻辑复用优先 Composable
-
一个组件只做一层职责
- 展示层组件尽量"纯",减少副作用
-
命名必须有业务语义
useOrderSearch优于useCommonLogic
-
默认值和类型声明要完整
- Props 使用
withDefaults+ TS 接口
- Props 使用
-
插槽要有语义边界
- 常用
header/body/footer/empty,不要无序扩散
- 常用
-
Composable 输出稳定 API
- 返回字段结构固定,避免上层频繁改调用方式
[⬆ 返回目录](#⬆ 返回目录)
七、给新手的"傻瓜决策树"
当你准备复用时,按这个顺序问自己:
-
只是值不同,结构不变吗 ?是:用
props -
结构某部分需要父组件自定义吗 ?是:用
slots -
多个组件都要同一套状态和行为吗 ?是:用
composable -
三者都满足一部分 ?组合使用,不冲突
[⬆ 返回目录](#⬆ 返回目录)
八、总结
Vue3 的复用设计,不是三选一,而是明确边界后的"各司其职":
props解决"同组件参数化"slots解决"结构扩展"composable解决"逻辑沉淀"
你会发现,基础越扎实,越能写出不炫技但很耐用的代码。
真正高级的工程能力,往往就是:每次都做正确、克制、可维护的选择。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 组件化设计基础
持续更新中,敬请期待~
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试
四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力
- 前端基础实战系列 : 《前端基础实战:JS/TS与Vue体系化扫盲(47 篇完整目录 + 避坑)》
- 前端规范实战系列 : 《JS/TS/Vue 前端规范实战:从写对到写优,搞定中后台规范落地,打造可维护代码(40 篇全目录)》
- 前端架构实战系列:聚焦工程化、性能优化、可维护架构、中后台体系设计(持续更新中)
- 前端大厂面试系列:覆盖高频考点、手写题、项目深挖、简历与面试技巧(规划中)
每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。
全套内容持续更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
前端的成长路径很清晰:
会写代码 → 写规范代码 → 做可扩展架构。
每一步,都是职业晋升的关键台阶。
后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。
我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~