Vue3 业务组件封装别只会传 props:如何设计一个真正好用的组件

一个用户选择组件,很多人第一版会写成这样:

vue 复制代码
<UserSelectDialog
  :visible="visible"
  :selected-user="selectedUser"
  :user-list="userList"
  :loading="loading"
  @close="visible = false"
  @search="fetchUsers"
  @select="handleSelect"
/>

看起来组件已经封装了。

但调用方还是要关心:

text 复制代码
弹窗什么时候打开
搜索关键词怎么传
loading 怎么控制
用户列表在哪里请求
选中状态怎么同步
关闭弹窗时怎么清理

这类组件的问题不是"不能用",而是调用方太累。

真正好用的业务组件,调用方应该更接近这样:

vue 复制代码
<UserSelect
  v-model="form.user"
  title="选择经办人"
  placeholder="请输入姓名或手机号"
  @change="handleUserChange"
/>

调用方只关心结果:选中了谁。

组件内部负责弹窗、搜索、分页、loading、列表请求和选中确认。

这篇文章不讲怎么把代码复制进一个 .vue 文件,而是讲 Vue3 业务组件 API 应该怎么设计。

封装组件,不是把复杂度藏起来

很多业务组件封装失败,是因为只是把模板搬进了组件。

原来页面里有一坨代码:

vue 复制代码
<template>
  <button @click="visible = true">选择用户</button>

  <Modal v-model:open="visible">
    <Input v-model="keyword" @input="fetchUsers" />

    <Table
      :data-source="userList"
      :loading="loading"
      @row-click="selectedUser = $event"
    />

    <button @click="confirm">确定</button>
  </Modal>
</template>

后来把它挪进 UserSelectDialog.vue

但是状态还是外部传:

vue 复制代码
<UserSelectDialog
  :visible="visible"
  :keyword="keyword"
  :user-list="userList"
  :loading="loading"
  :selected-user="selectedUser"
  @update:visible="visible = $event"
  @update:keyword="keyword = $event"
  @search="fetchUsers"
  @select="selectedUser = $event"
/>

这不叫真正的封装。

这只是把 DOM 换了个地方。

一个业务组件的封装目标应该是:

text 复制代码
调用方少做决定
组件内部承担稳定逻辑
关键节点保留扩展能力
组件 API 一眼能看懂

先确定组件的核心值

设计组件 API 前,先问一个问题:

这个组件最核心的值是什么?

对于用户选择组件,核心值就是"当前选中的用户"。

所以它天然适合用 v-model

调用方:

vue 复制代码
<UserSelect v-model="form.user" />

组件内部:

vue 复制代码
<script setup lang="ts">
type User = {
  id: string
  name: string
  mobile: string
}

const model = defineModel<User | null>()
</script>

defineModel 是 Vue 3.4 开始推荐的组件 v-model 写法。它返回的是一个 ref,组件内部改 model.value,父组件绑定的值也会同步更新。

如果你的项目还没到 Vue 3.4,可以用老写法:

ts 复制代码
const props = defineProps<{
  modelValue: User | null
}>()

const emit = defineEmits<{
  'update:modelValue': [value: User | null]
}>()

function updateValue(user: User | null) {
  emit('update:modelValue', user)
}

从调用方看,仍然是:

vue 复制代码
<UserSelect v-model="form.user" />

只是组件内部实现不同。

props 是配置,不是状态垃圾桶

很多组件越封越难用,是因为 props 太多。

比如:

vue 复制代码
<UserSelect
  :visible="visible"
  :keyword="keyword"
  :page="page"
  :page-size="pageSize"
  :user-list="userList"
  :loading="loading"
  :selected-user="selectedUser"
  :show-search="true"
  :show-pagination="true"
  :modal-width="800"
  :columns="columns"
  :request-method="fetchUsers"
  :value-field="'id'"
  :label-field="'name'"
/>

这看起来很灵活,实际很难用。

调用方需要理解组件内部所有细节。每多一个 props,就多一个心智负担。

我更建议第一版只暴露稳定配置:

vue 复制代码
<UserSelect
  v-model="form.user"
  title="选择经办人"
  placeholder="请输入姓名或手机号"
  :disabled="readonly"
/>

组件内部:

ts 复制代码
const props = withDefaults(
  defineProps<{
    title?: string
    placeholder?: string
    disabled?: boolean
  }>(),
  {
    title: '选择用户',
    placeholder: '请输入姓名或手机号',
    disabled: false,
  },
)

判断标准很简单:

text 复制代码
props 应该表达外部配置
不应该把组件内部状态都暴露出去

loadingkeywordpagevisible,如果是组件内部稳定逻辑,就应该留在组件内部。

emits 是事件,不是让父组件帮你管内部状态

先把两个概念分清楚:

text 复制代码
状态:组件现在是什么样
事件:组件刚刚发生了什么

比如用户选择组件里有这些状态:

text 复制代码
visible:弹窗是否打开
keyword:当前搜索关键词
page:当前第几页
loading:是否正在请求
selectedUser:弹窗里临时选中的用户

这些状态大多数只服务于组件内部。

调用方真正关心的通常只有一个结果:

text 复制代码
最终选中了哪个用户

所以这个结果适合用 v-model 同步:

vue 复制代码
<UserSelect v-model="form.user" />

emits 适合告诉外部"某件事发生了"。

比如用户点击确定,选择完成:

ts 复制代码
const emit = defineEmits<{
  change: [value: User | null]
}>()

function confirm() {
  model.value = selectedUser.value
  emit('change', selectedUser.value)
  close()
}

调用方:

vue 复制代码
<UserSelect
  v-model="form.user"
  @change="handleUserChange"
/>
ts 复制代码
function handleUserChange(user: User | null) {
  form.userId = user?.id ?? ''
  form.userName = user?.name ?? ''
}

这段代码里:

text 复制代码
v-model 负责把选中的用户同步给父组件
change 事件负责通知父组件"用户选择完成了,你可以做后续处理"

这就是 v-modelemits 的边界。

更具体一点:

text 复制代码
v-model:适合表达"值是什么"
emits:适合表达"发生了什么"

不要把组件内部每个状态变化都抛给父组件。

反例:

vue 复制代码
<UserSelect
  @update-keyword="keyword = $event"
  @update-page="page = $event"
  @update-loading="loading = $event"
/>

这段代码的问题是:父组件被迫参与了组件内部流程。

搜索关键词变了,父组件要知道。

分页变了,父组件要知道。

loading 变了,父组件也要知道。

最后组件内部反而没剩多少职责。

更好的方式是:

vue 复制代码
<UserSelect
  v-model="form.user"
  @change="handleUserChange"
/>

组件内部自己管理:

text 复制代码
visible
keyword
page
loading
selectedUser

父组件只接收最终结果。

什么时候需要把事件抛给外部?

可以看这几个例子:

text 复制代码
change:用户选择完成
clear:用户清空选择
open:弹窗打开了,父组件想埋点
close:弹窗关闭了,父组件想做额外处理
error:请求失败,父组件想统一提示

这些都是"事件"。

而下面这些通常不该抛出去:

text 复制代码
update-keyword
update-page
update-loading
update-internal-list

它们更像组件内部状态同步。

如果这些都要父组件处理,组件封装就变成了"父组件远程控制子组件"。

slots 是给展示差异的,不是给逻辑差异的

业务组件经常遇到展示差异。

比如用户列表里,有些页面想展示手机号,有些页面想展示部门。

这个适合用 slot:

vue 复制代码
<UserSelect v-model="form.user">
  <template #option="{ user }">
    <div class="user-option">
      <strong>{{ user.name }}</strong>
      <span>{{ user.mobile }}</span>
      <span>{{ user.departmentName }}</span>
    </div>
  </template>
</UserSelect>

组件内部:

vue 复制代码
<li
  v-for="user in userList"
  :key="user.id"
  @click="selectUser(user)"
>
  <slot name="option" :user="user">
    <div>
      <strong>{{ user.name }}</strong>
      <span>{{ user.mobile }}</span>
    </div>
  </slot>
</li>

这样调用方可以改展示,但不会破坏组件核心流程。

不建议这样设计:

vue 复制代码
<UserSelect>
  <template #search>
    <!-- 调用方自己写搜索逻辑 -->
  </template>
</UserSelect>

如果搜索逻辑完全交给外部,组件内部还剩什么?

一个实用判断:

text 复制代码
展示差异用 slots
核心流程留在组件内部
业务结果用 v-model / emits

组件内部请求,还是外部传 request

这是业务组件设计里最容易争论的地方。

方式一:组件内部请求

调用方最简单:

vue 复制代码
<UserSelect v-model="form.user" />

组件内部直接调用接口:

ts 复制代码
async function fetchUsers() {
  loading.value = true

  try {
    const res = await getUserList({
      keyword: keyword.value,
      page: page.value,
      pageSize: pageSize.value,
    })

    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

适合:

text 复制代码
接口固定
业务固定
多个页面都一样

缺点是扩展性弱。

如果另一个页面要多传一个 enterpriseId,组件就要改。

方式二:外部传 request

调用方:

vue 复制代码
<UserSelect
  v-model="form.user"
  :request="searchUsers"
/>
ts 复制代码
async function searchUsers(params: {
  keyword: string
  page: number
  pageSize: number
}) {
  return getUserList({
    ...params,
    enterpriseId: form.enterpriseId,
  })
}

组件内部:

ts 复制代码
type RequestParams = {
  keyword: string
  page: number
  pageSize: number
}

type RequestResult = {
  list: User[]
  total: number
}

const props = defineProps<{
  request?: (params: RequestParams) => Promise<RequestResult>
}>()

async function fetchUsers() {
  if (!props.request) {
    return
  }

  loading.value = true

  try {
    const res = await props.request({
      keyword: keyword.value,
      page: page.value,
      pageSize: pageSize.value,
    })

    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

这种方式更适合半业务组件。

组件负责交互,调用方决定数据来源。

我的建议:

text 复制代码
纯通用组件:外部传数据
强业务组件:组件内部请求
半业务组件:外部传 request

UserSelect 这种组件,通常更适合外部传 request,但可以提供一个默认 request。

defineExpose 要少用

Vue3 可以通过 defineExpose 暴露组件方法。

组件内部:

ts 复制代码
function open() {
  visible.value = true
}

function close() {
  visible.value = false
}

function reload() {
  fetchUsers()
}

defineExpose({
  open,
  close,
  reload,
})

调用方:

vue 复制代码
<UserSelect
  ref="userSelectRef"
  v-model="form.user"
/>
ts 复制代码
const userSelectRef = ref<{
  open: () => void
  close: () => void
  reload: () => void
}>()

function handleClick() {
  userSelectRef.value?.open()
}

这个能力有用,但要克制。

适合暴露:

text 复制代码
open
close
reload
reset
focus

不适合暴露:

text 复制代码
setKeyword
setPage
setLoading
setInternalUserList

如果调用方需要频繁操作内部状态,说明组件边界有问题。

优先级应该是:

text 复制代码
能用 v-model 表达,就不要用 ref 调方法
能用 emits 表达,就不要暴露内部状态
确实是命令式行为,再用 defineExpose

一个完整的 UserSelect 设计

调用方最终应该像这样:

vue 复制代码
<script setup lang="ts">
import UserSelect from './UserSelect.vue'

const form = reactive({
  user: null as User | null,
  userId: '',
  userName: '',
  enterpriseId: 'ent_001',
})

async function searchUsers(params: {
  keyword: string
  page: number
  pageSize: number
}) {
  return getUserList({
    ...params,
    enterpriseId: form.enterpriseId,
  })
}

function handleUserChange(user: User | null) {
  form.userId = user?.id ?? ''
  form.userName = user?.name ?? ''
}
</script>

<template>
  <UserSelect
    v-model="form.user"
    title="选择经办人"
    placeholder="请输入姓名或手机号"
    :request="searchUsers"
    @change="handleUserChange"
  >
    <template #option="{ user }">
      <div class="user-option">
        <strong>{{ user.name }}</strong>
        <span>{{ user.mobile }}</span>
      </div>
    </template>
  </UserSelect>
</template>

组件内部大概这样:

vue 复制代码
<script setup lang="ts">
import { ref, watch } from 'vue'

type User = {
  id: string
  name: string
  mobile: string
}

type RequestParams = {
  keyword: string
  page: number
  pageSize: number
}

type RequestResult = {
  list: User[]
  total: number
}

const model = defineModel<User | null>()

const props = withDefaults(
  defineProps<{
    title?: string
    placeholder?: string
    disabled?: boolean
    request: (params: RequestParams) => Promise<RequestResult>
  }>(),
  {
    title: '选择用户',
    placeholder: '请输入姓名或手机号',
    disabled: false,
  },
)

const emit = defineEmits<{
  change: [value: User | null]
}>()

const visible = ref(false)
const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const userList = ref<User[]>([])
const selectedUser = ref<User | null>(null)

function open() {
  if (props.disabled) {
    return
  }

  visible.value = true
  selectedUser.value = model.value ?? null
  fetchUsers()
}

function close() {
  visible.value = false
  keyword.value = ''
  page.value = 1
}

async function fetchUsers() {
  loading.value = true

  try {
    const res = await props.request({
      keyword: keyword.value,
      page: page.value,
      pageSize: pageSize.value,
    })

    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

function handleSearch(value: string) {
  keyword.value = value
  page.value = 1
  fetchUsers()
}

function selectUser(user: User) {
  selectedUser.value = user
}

function confirm() {
  model.value = selectedUser.value
  emit('change', selectedUser.value)
  close()
}

defineExpose({
  open,
  close,
  reload: fetchUsers,
})
</script>

模板可以保持清晰:

vue 复制代码
<template>
  <button
    type="button"
    :disabled="disabled"
    @click="open"
  >
    {{ model?.name || placeholder }}
  </button>

  <Modal
    v-model:open="visible"
    :title="title"
  >
    <Input
      :model-value="keyword"
      :placeholder="placeholder"
      @update:model-value="handleSearch"
    />

    <div v-if="loading">加载中...</div>

    <ul v-else>
      <li
        v-for="user in userList"
        :key="user.id"
        :class="{ active: selectedUser?.id === user.id }"
        @click="selectUser(user)"
      >
        <slot name="option" :user="user">
          <strong>{{ user.name }}</strong>
          <span>{{ user.mobile }}</span>
        </slot>
      </li>
    </ul>

    <Pagination
      :current="page"
      :page-size="pageSize"
      :total="total"
      @change="(nextPage) => { page = nextPage; fetchUsers() }"
    />

    <template #footer>
      <button @click="close">取消</button>
      <button @click="confirm">确定</button>
    </template>
  </Modal>
</template>

这不是一个完整可复制的 UI 组件库代码,但它展示了业务组件的边界:

text 复制代码
组件内部管理交互流程
调用方传 request 控制数据来源
v-model 同步核心值
emits 通知外部副作用
slot 处理展示差异
defineExpose 暴露少量命令式能力

常见误区

误区一:props 越多越灵活

props 多不等于灵活。

很多时候只是把组件复杂度转嫁给调用方。

判断一个 props 是否应该存在,可以问:

text 复制代码
这个配置是否稳定?
是否多个页面都会用?
调用方是否真的需要关心?

如果答案不明确,先别加。

误区二:组件必须完全通用

业务组件不一定要通用到所有场景。

过度通用会导致 API 很重,调用方每次都要传一堆配置。

好的业务组件应该先服务高频场景,再逐步抽象。

误区三:slot 什么都能解决

slot 适合扩展展示,不适合把核心逻辑外包。

如果一个组件的大部分逻辑都要通过 slot 自己写,那它可能不该被封装成这个组件。

误区四:defineExpose 很方便,所以到处用

defineExpose 是命令式接口。

它适合打开、关闭、聚焦、刷新这类动作。

如果用它读写内部状态,组件就会变得不可预测。

组件 API 设计清单

封装业务组件前,可以按这个清单过一遍:

text 复制代码
1. 这个组件的核心值是什么?
2. 核心值是否应该用 v-model?
3. 哪些是稳定配置,适合做 props?
4. 哪些变化只是展示差异,适合做 slots?
5. 哪些节点需要通知外部,适合做 emits?
6. 哪些逻辑必须留在组件内部?
7. 是否真的需要 defineExpose?
8. 调用方能不能一眼看懂?

如果一个组件调用起来比原来还麻烦,那它就不是封装,而是换了一种方式制造复杂度。

总结

Vue3 的组件封装,不是把所有东西都传成 props。

propsemitsslotsv-modeldefineExpose 各自解决的问题不一样。

我的经验是:

text 复制代码
props 管配置
v-model 管核心值
emits 管事件通知
slots 管展示差异
defineExpose 管少量命令式动作

一个好的业务组件,应该让调用方少做决定,但在关键位置保留扩展能力。

封装组件不是把复杂度藏起来,而是把复杂度放到正确的位置。

参考资料

相关推荐
前端那点事1 小时前
Vue3 script setup 语法糖最全教程!零基础吃透+项目落地+面试满分
前端·vue.js
卷帘依旧2 小时前
Vue 响应式原理:Object.defineProperty vs Proxy 深度对比
前端·vue.js
布局呆星2 小时前
Vue3 路由守卫详解:全局守卫、路由独享守卫、组件内守卫
前端·javascript·vue.js
小李子呢02112 小时前
前端八股Vue---ref操作 DOM 元素或组件,调用子组件方法
前端·javascript·vue.js
Yoram2 小时前
Vue3 响应性:跨上下文的传递、转换与作用域控制
前端·vue.js
qingy_20464 小时前
浏览器页面出现竖向滚动条的解决方案
前端·javascript·vue.js
前端小万4 小时前
用 AI 写了个 VSCode 摸鱼插件,从开发到上架全过程
vue.js
蜡台4 小时前
Vue3 + ECharts 实现地图显示,深蓝色科技风地图、涟漪点、向上连线 ,标签
vue.js·科技·echarts·map·地图
iuu_star4 小时前
跑通最简单的Vue3+Python前后端分离项目
前端·vue.js·python