Vue3 组件复用设计:Props / 插槽 / 组合式函数,三种复用方式选型|组件化设计基础篇

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

📑 文章目录

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构

面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维

这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。

帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。


一、写在前面:为什么你会觉得"复用"很乱?

很多同学(包括工作多年的前端)写 Vue3 时都会遇到这几个现象:

  • 组件越来越"大",一个组件里什么都做
  • 同样的逻辑在多个页面复制粘贴
  • 看到 propsslotuseXxx 都能复用,但不知道怎么选
  • 后期改需求时,一改就牵一堆地方,心里发慌
    根因通常不是"不会写代码",而是复用边界没想清楚

你到底是在复用"数据配置"、复用"界面骨架"、还是复用"状态逻辑"?

[⬆ 返回目录](#⬆ 返回目录)


二、先给结论:三种复用方式怎么选?

先记住一句话:

  • 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:作用域插槽变量命名混乱
    • 建议统一:itemrowdata 等可读命名
  • 坑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
    • 后期根本不知道干嘛,建议业务语义命名:useUserListuseCountdown
  • 坑3:既做数据请求,又改 UI 展示细节
    • Composable 负责逻辑,UI 细节尽量留在组件层

[⬆ 返回目录](#⬆ 返回目录)


四、实战选型:一个需求,三种方式如何配合?

需求:做一个"商品列表卡片"模块

  • 卡片样式统一
  • 每个业务线头部操作区不同
  • 列表查询和分页逻辑可复用

推荐设计

  • 卡片容器:BaseCard(Slots)
  • 商品项配置:ProductItem(Props)
  • 列表请求逻辑:useProductList(Composable)

这就是典型的:

  • Props 管参数
  • Slots 管结构
  • Composable 管逻辑

比起"一个超级组件全包",这种拆法更稳、更容易维护和测试。

[⬆ 返回目录](#⬆ 返回目录)


五、7年前端也容易踩的"习惯坑"

  • 把复用理解成"少写代码"
    • 真正目标是"降低变更成本",不是追求抽象炫技
  • 过度提前抽象
    • 还没出现第二个真实场景,就抽一堆 BaseXXX
  • 组件职责不清
    • 组件同时负责请求、权限、UI、埋点,最后没人敢改
  • 把 slot 当万能胶
    • 到处开洞,后面无法约束
  • Composable 无边界增长
    • 一个 usePage 500 行,逐渐变成"新型大泥球"

[⬆ 返回目录](#⬆ 返回目录)


六、可落地的团队规范(可直接抄到项目规范)

  1. 先判断复用类型,再选技术方案

    • 参数差异优先 Props
    • 结构差异优先 Slots
    • 行为逻辑复用优先 Composable
  2. 一个组件只做一层职责

    • 展示层组件尽量"纯",减少副作用
  3. 命名必须有业务语义

    • useOrderSearch 优于 useCommonLogic
  4. 默认值和类型声明要完整

    • Props 使用 withDefaults + TS 接口
  5. 插槽要有语义边界

    • 常用 header/body/footer/empty,不要无序扩散
  6. Composable 输出稳定 API

    • 返回字段结构固定,避免上层频繁改调用方式

[⬆ 返回目录](#⬆ 返回目录)


七、给新手的"傻瓜决策树"

当你准备复用时,按这个顺序问自己:

  1. 只是值不同,结构不变吗 ?是:用 props

  2. 结构某部分需要父组件自定义吗 ?是:用 slots

  3. 多个组件都要同一套状态和行为吗 ?是:用 composable

  4. 三者都满足一部分 ?组合使用,不冲突

[⬆ 返回目录](#⬆ 返回目录)


八、总结

Vue3 的复用设计,不是三选一,而是明确边界后的"各司其职":

  • props 解决"同组件参数化"
  • slots 解决"结构扩展"
  • composable 解决"逻辑沉淀"

你会发现,基础越扎实,越能写出不炫技但很耐用的代码。

真正高级的工程能力,往往就是:每次都做正确、克制、可维护的选择。

[⬆ 返回目录](#⬆ 返回目录)

🔍 系列模块导航

📝 组件化设计基础

持续更新中,敬请期待~

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

📚 系列总览

前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试

四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力

每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。

全套内容持续更新中,敬请期待~

[⬆ 返回目录](#⬆ 返回目录)


前端的成长路径很清晰:

会写代码 → 写规范代码 → 做可扩展架构。

每一步,都是职业晋升的关键台阶。

后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。

我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~

相关推荐
nFBD29OFC2 小时前
利用Vue元素指令自动合并tailwind类名
前端·javascript·vue.js
ISkp3V8b43 小时前
ASP.NET MVC]Contact Manager开发之旅之迭代2 - 修改样式,美化应用
前端·chrome
Highcharts.js3 小时前
高级可视化图表的暗色模式与主题|Highcharts 自适应主题配色全解
前端·react.js·实时图表
i220818 Faiz Ul4 小时前
动漫商城|基于springboot + vue动漫商城系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·动漫商城系统
zk_one4 小时前
【无标题】
开发语言·前端·javascript
AIBox3655 小时前
openclaw api 配置排查与接入指南:网关启动、配置文件和模型接入全流程
javascript·人工智能·gpt
precious。。。5 小时前
1.2.1 三角不等式演示
前端·javascript·html
阿珊和她的猫5 小时前
TypeScript 中的 `extends` 条件类型:定义与应用
javascript·typescript·状态模式
众创岛6 小时前
iframe的属性获取
开发语言·javascript·ecmascript