一个用户选择组件,很多人第一版会写成这样:
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 应该表达外部配置
不应该把组件内部状态都暴露出去
像 loading、keyword、page、visible,如果是组件内部稳定逻辑,就应该留在组件内部。
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-model 和 emits 的边界。
更具体一点:
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。
props、emits、slots、v-model、defineExpose 各自解决的问题不一样。
我的经验是:
text
props 管配置
v-model 管核心值
emits 管事件通知
slots 管展示差异
defineExpose 管少量命令式动作
一个好的业务组件,应该让调用方少做决定,但在关键位置保留扩展能力。
封装组件不是把复杂度藏起来,而是把复杂度放到正确的位置。